Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions cmd/shell/banner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright 2022-2026 Salesforce, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package shell

import (
"fmt"
"io"
"strings"

"github.com/charmbracelet/lipgloss"
)

// bannerView returns the styled banner string at the given width.
func bannerView(width int, version string) string {
line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", width))
face := renderSlackbot()
title := lipgloss.NewStyle().Bold(true).Foreground(colorAubergine).Render("Slack CLI Shell")
ver := lipgloss.NewStyle().Foreground(colorPool).Render(" " + version)
hint := lipgloss.NewStyle().Foreground(colorGray).Italic(true).Render("Type 'help' for commands, 'exit' to quit")
info := title + ver + "\n" + hint
body := lipgloss.JoinHorizontal(lipgloss.Center, face, " "+info)
return line + "\n" + body + "\n" + line
}

// Slack brand colors (reused from internal/style/charm_theme.go)
var (
colorAubergine = lipgloss.Color("#7C2852")
colorPool = lipgloss.Color("#78d7dd")
colorGray = lipgloss.Color("#5e5d60")
colorGreen = lipgloss.Color("#2eb67d")
colorBlue = lipgloss.Color("#36c5f0")
)

// renderSlackbot returns a multi-colored ASCII slackbot face.
func renderSlackbot() string {
box := lipgloss.NewStyle().Foreground(colorPool)
eye := lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
smile := lipgloss.NewStyle().Foreground(colorGreen)
lines := []string{
box.Render(" ╭───────╮"),
box.Render(" │ ") + eye.Render("●") + box.Render(" ") + eye.Render("●") + box.Render(" │"),
box.Render(" │ ") + smile.Render("◡") + box.Render(" │"),
box.Render(" ╰───────╯"),
}
return strings.Join(lines, "\n")
}

// renderGoodbye writes the goodbye message to the writer.
func renderGoodbye(w io.Writer) {
msg := lipgloss.NewStyle().Foreground(colorGreen).Render("Goodbye!")
fmt.Fprintf(w, "%s\n", msg)
}
137 changes: 137 additions & 0 deletions cmd/shell/input.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright 2022-2026 Salesforce, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package shell

import (
"strings"

"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/slackapi/slack-cli/internal/shared"
)

// inputModel wraps a bubbles textinput with shell history navigation.
type inputModel struct {
textInput textinput.Model
history []string
histIndex int // len(history) = "new line" position
saved string // user's in-progress text before navigating history
done bool
value string
width int
bannerVersion string // non-empty = show banner above input
}

// readLine runs a short-lived bubbletea program to collect one line of input.
func readLine(clients *shared.ClientFactory, history []string, bannerVersion string) (string, error) {
m := newInputModel(history, bannerVersion)
p := tea.NewProgram(m,
tea.WithInput(clients.IO.ReadIn()),
tea.WithOutput(clients.IO.WriteOut()),
)
result, err := p.Run()
if err != nil {
return "", err
}
final := result.(inputModel)
return final.value, nil
}

func newInputModel(history []string, bannerVersion string) inputModel {
ti := textinput.New()
ti.Prompt = "❯ "
ti.PromptStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBlue)
ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#e8a400"))
ti.Focus()

// Disable built-in suggestion navigation (we use Up/Down for history)
ti.KeyMap.NextSuggestion.SetEnabled(false)
ti.KeyMap.PrevSuggestion.SetEnabled(false)

return inputModel{
textInput: ti,
history: history,
histIndex: len(history),
bannerVersion: bannerVersion,
}
}

func (m inputModel) Init() tea.Cmd {
return textinput.Blink
}

func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
return m, tea.ClearScreen
case tea.KeyMsg:
switch msg.Type {
case tea.KeyEnter:
m.done = true
m.value = m.textInput.Value()
return m, tea.Quit
case tea.KeyCtrlC:
m.done = true
m.value = "exit"
return m, tea.Quit
case tea.KeyUp:
if m.histIndex > 0 {
// Save current text on first Up press
if m.histIndex == len(m.history) {
m.saved = m.textInput.Value()
}
m.histIndex--
m.textInput.SetValue(m.history[m.histIndex])
m.textInput.CursorEnd()
}
return m, nil
case tea.KeyDown:
if m.histIndex < len(m.history) {
m.histIndex++
if m.histIndex == len(m.history) {
m.textInput.SetValue(m.saved)
} else {
m.textInput.SetValue(m.history[m.histIndex])
}
m.textInput.CursorEnd()
}
return m, nil
}
}

var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}

func (m inputModel) View() string {
if m.done {
return ""
}
w := m.width
if w <= 0 {
w = 80
}
line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", w))
content := " " + m.textInput.View()
input := line + "\n" + content + "\n" + line

if m.bannerVersion != "" {
return bannerView(w, m.bannerVersion) + "\n" + input
}
return input
}
194 changes: 194 additions & 0 deletions cmd/shell/input_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright 2022-2026 Salesforce, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package shell

import (
"testing"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
"github.com/stretchr/testify/assert"
)

func TestInputModel(t *testing.T) {
tests := map[string]struct {
setup func() inputModel
actions func(inputModel) inputModel
assertFn func(t *testing.T, m inputModel)
}{
"enter submits text": {
setup: func() inputModel {
return newInputModel(nil, "")
},
actions: func(m inputModel) inputModel {
for _, r := range "deploy" {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = updated.(inputModel)
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.True(t, m.done)
assert.Equal(t, "deploy", m.value)
},
},
"ctrl+c returns exit": {
setup: func() inputModel {
return newInputModel(nil, "")
},
actions: func(m inputModel) inputModel {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.True(t, m.done)
assert.Equal(t, "exit", m.value)
},
},
"up arrow recalls history": {
setup: func() inputModel {
return newInputModel([]string{"deploy", "run"}, "")
},
actions: func(m inputModel) inputModel {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.Equal(t, "run", m.textInput.Value())
assert.Equal(t, 1, m.histIndex)
},
},
"up arrow twice recalls older history": {
setup: func() inputModel {
return newInputModel([]string{"deploy", "run"}, "")
},
actions: func(m inputModel) inputModel {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.Equal(t, "deploy", m.textInput.Value())
assert.Equal(t, 0, m.histIndex)
},
},
"down arrow restores saved text": {
setup: func() inputModel {
m := newInputModel([]string{"deploy", "run"}, "")
for _, r := range "ver" {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
m = updated.(inputModel)
}
return m
},
actions: func(m inputModel) inputModel {
// Go up - saves "ver", shows "run"
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
// Go back down - restores "ver"
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.Equal(t, "ver", m.textInput.Value())
assert.Equal(t, 2, m.histIndex)
},
},
"up at oldest entry does nothing": {
setup: func() inputModel {
return newInputModel([]string{"deploy"}, "")
},
actions: func(m inputModel) inputModel {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(inputModel)
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.Equal(t, "deploy", m.textInput.Value())
assert.Equal(t, 0, m.histIndex)
},
},
"view renders border": {
setup: func() inputModel {
return newInputModel(nil, "")
},
actions: func(m inputModel) inputModel {
return m
},
assertFn: func(t *testing.T, m inputModel) {
view := ansi.Strip(m.View())
assert.Contains(t, view, "─")
assert.Contains(t, view, "❯")
},
},
"view renders banner when version set": {
setup: func() inputModel {
m := newInputModel(nil, "v1.0.0")
m.width = 40
return m
},
actions: func(m inputModel) inputModel { return m },
assertFn: func(t *testing.T, m inputModel) {
view := ansi.Strip(m.View())
assert.Contains(t, view, "Slack CLI Shell")
assert.Contains(t, view, "v1.0.0")
assert.Contains(t, view, "❯")
},
},
"view renders no banner when version empty": {
setup: func() inputModel {
m := newInputModel(nil, "")
m.width = 40
return m
},
actions: func(m inputModel) inputModel { return m },
assertFn: func(t *testing.T, m inputModel) {
view := ansi.Strip(m.View())
assert.NotContains(t, view, "Slack CLI Shell")
assert.Contains(t, view, "❯")
},
},
"view is empty when done": {
setup: func() inputModel {
m := newInputModel(nil, "")
m.done = true
return m
},
actions: func(m inputModel) inputModel {
return m
},
assertFn: func(t *testing.T, m inputModel) {
assert.Equal(t, "", m.View())
},
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
m := tc.setup()
m = tc.actions(m)
tc.assertFn(t, m)
})
}
}
Loading
Loading