Skip to content

Commit c230fdc

Browse files
committed
feat: dynamic banner resizing via bubbletea view
Move the shell banner into the bubbletea input model's View() so it responds to terminal resizes. The banner renders above the input on the first prompt only and disappears after the user submits a command.
1 parent 1e7cf7f commit c230fdc

5 files changed

Lines changed: 689 additions & 0 deletions

File tree

cmd/shell/banner.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package shell
16+
17+
import (
18+
"fmt"
19+
"io"
20+
"strings"
21+
22+
"github.com/charmbracelet/lipgloss"
23+
)
24+
25+
// bannerView returns the styled banner string at the given width.
26+
func bannerView(width int, version string) string {
27+
line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", width))
28+
face := renderSlackbot()
29+
title := lipgloss.NewStyle().Bold(true).Foreground(colorAubergine).Render("Slack CLI Shell")
30+
ver := lipgloss.NewStyle().Foreground(colorPool).Render(" " + version)
31+
hint := lipgloss.NewStyle().Foreground(colorGray).Italic(true).Render("Type 'help' for commands, 'exit' to quit")
32+
info := title + ver + "\n" + hint
33+
body := lipgloss.JoinHorizontal(lipgloss.Center, face, " "+info)
34+
return line + "\n" + body + "\n" + line
35+
}
36+
37+
// Slack brand colors (reused from internal/style/charm_theme.go)
38+
var (
39+
colorAubergine = lipgloss.Color("#7C2852")
40+
colorPool = lipgloss.Color("#78d7dd")
41+
colorGray = lipgloss.Color("#5e5d60")
42+
colorGreen = lipgloss.Color("#2eb67d")
43+
colorBlue = lipgloss.Color("#36c5f0")
44+
)
45+
46+
// renderSlackbot returns a multi-colored ASCII slackbot face.
47+
func renderSlackbot() string {
48+
box := lipgloss.NewStyle().Foreground(colorPool)
49+
eye := lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
50+
smile := lipgloss.NewStyle().Foreground(colorGreen)
51+
lines := []string{
52+
box.Render(" ╭───────╮"),
53+
box.Render(" │ ") + eye.Render("●") + box.Render(" ") + eye.Render("●") + box.Render(" │"),
54+
box.Render(" │ ") + smile.Render("◡") + box.Render(" │"),
55+
box.Render(" ╰───────╯"),
56+
}
57+
return strings.Join(lines, "\n")
58+
}
59+
60+
// renderGoodbye writes the goodbye message to the writer.
61+
func renderGoodbye(w io.Writer) {
62+
msg := lipgloss.NewStyle().Foreground(colorGreen).Render("Goodbye!")
63+
fmt.Fprintf(w, "%s\n", msg)
64+
}

cmd/shell/input.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package shell
16+
17+
import (
18+
"strings"
19+
20+
"github.com/charmbracelet/bubbles/textinput"
21+
tea "github.com/charmbracelet/bubbletea"
22+
"github.com/charmbracelet/lipgloss"
23+
"github.com/slackapi/slack-cli/internal/shared"
24+
)
25+
26+
// inputModel wraps a bubbles textinput with shell history navigation.
27+
type inputModel struct {
28+
textInput textinput.Model
29+
history []string
30+
histIndex int // len(history) = "new line" position
31+
saved string // user's in-progress text before navigating history
32+
done bool
33+
value string
34+
width int
35+
bannerVersion string // non-empty = show banner above input
36+
}
37+
38+
// readLine runs a short-lived bubbletea program to collect one line of input.
39+
func readLine(clients *shared.ClientFactory, history []string, bannerVersion string) (string, error) {
40+
m := newInputModel(history, bannerVersion)
41+
p := tea.NewProgram(m,
42+
tea.WithInput(clients.IO.ReadIn()),
43+
tea.WithOutput(clients.IO.WriteOut()),
44+
)
45+
result, err := p.Run()
46+
if err != nil {
47+
return "", err
48+
}
49+
final := result.(inputModel)
50+
return final.value, nil
51+
}
52+
53+
func newInputModel(history []string, bannerVersion string) inputModel {
54+
ti := textinput.New()
55+
ti.Prompt = "❯ "
56+
ti.PromptStyle = lipgloss.NewStyle().Bold(true).Foreground(colorBlue)
57+
ti.Cursor.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#e8a400"))
58+
ti.Focus()
59+
60+
// Disable built-in suggestion navigation (we use Up/Down for history)
61+
ti.KeyMap.NextSuggestion.SetEnabled(false)
62+
ti.KeyMap.PrevSuggestion.SetEnabled(false)
63+
64+
return inputModel{
65+
textInput: ti,
66+
history: history,
67+
histIndex: len(history),
68+
bannerVersion: bannerVersion,
69+
}
70+
}
71+
72+
func (m inputModel) Init() tea.Cmd {
73+
return textinput.Blink
74+
}
75+
76+
func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
77+
switch msg := msg.(type) {
78+
case tea.WindowSizeMsg:
79+
m.width = msg.Width
80+
return m, nil
81+
case tea.KeyMsg:
82+
switch msg.Type {
83+
case tea.KeyEnter:
84+
m.done = true
85+
m.value = m.textInput.Value()
86+
return m, tea.Quit
87+
case tea.KeyCtrlC:
88+
m.done = true
89+
m.value = "exit"
90+
return m, tea.Quit
91+
case tea.KeyUp:
92+
if m.histIndex > 0 {
93+
// Save current text on first Up press
94+
if m.histIndex == len(m.history) {
95+
m.saved = m.textInput.Value()
96+
}
97+
m.histIndex--
98+
m.textInput.SetValue(m.history[m.histIndex])
99+
m.textInput.CursorEnd()
100+
}
101+
return m, nil
102+
case tea.KeyDown:
103+
if m.histIndex < len(m.history) {
104+
m.histIndex++
105+
if m.histIndex == len(m.history) {
106+
m.textInput.SetValue(m.saved)
107+
} else {
108+
m.textInput.SetValue(m.history[m.histIndex])
109+
}
110+
m.textInput.CursorEnd()
111+
}
112+
return m, nil
113+
}
114+
}
115+
116+
var cmd tea.Cmd
117+
m.textInput, cmd = m.textInput.Update(msg)
118+
return m, cmd
119+
}
120+
121+
func (m inputModel) View() string {
122+
if m.done {
123+
return ""
124+
}
125+
w := m.width
126+
if w <= 0 {
127+
w = 80
128+
}
129+
line := lipgloss.NewStyle().Foreground(colorPool).Render(strings.Repeat("─", w))
130+
content := " " + m.textInput.View()
131+
input := line + "\n" + content + "\n" + line
132+
133+
if m.bannerVersion != "" {
134+
return bannerView(w, m.bannerVersion) + "\n" + input
135+
}
136+
return input
137+
}

cmd/shell/input_test.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package shell
16+
17+
import (
18+
"testing"
19+
20+
tea "github.com/charmbracelet/bubbletea"
21+
"github.com/charmbracelet/x/ansi"
22+
"github.com/stretchr/testify/assert"
23+
)
24+
25+
func TestInputModel(t *testing.T) {
26+
tests := map[string]struct {
27+
setup func() inputModel
28+
actions func(inputModel) inputModel
29+
assertFn func(t *testing.T, m inputModel)
30+
}{
31+
"enter submits text": {
32+
setup: func() inputModel {
33+
return newInputModel(nil, "")
34+
},
35+
actions: func(m inputModel) inputModel {
36+
for _, r := range "deploy" {
37+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
38+
m = updated.(inputModel)
39+
}
40+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
41+
m = updated.(inputModel)
42+
return m
43+
},
44+
assertFn: func(t *testing.T, m inputModel) {
45+
assert.True(t, m.done)
46+
assert.Equal(t, "deploy", m.value)
47+
},
48+
},
49+
"ctrl+c returns exit": {
50+
setup: func() inputModel {
51+
return newInputModel(nil, "")
52+
},
53+
actions: func(m inputModel) inputModel {
54+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC})
55+
m = updated.(inputModel)
56+
return m
57+
},
58+
assertFn: func(t *testing.T, m inputModel) {
59+
assert.True(t, m.done)
60+
assert.Equal(t, "exit", m.value)
61+
},
62+
},
63+
"up arrow recalls history": {
64+
setup: func() inputModel {
65+
return newInputModel([]string{"deploy", "run"}, "")
66+
},
67+
actions: func(m inputModel) inputModel {
68+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
69+
m = updated.(inputModel)
70+
return m
71+
},
72+
assertFn: func(t *testing.T, m inputModel) {
73+
assert.Equal(t, "run", m.textInput.Value())
74+
assert.Equal(t, 1, m.histIndex)
75+
},
76+
},
77+
"up arrow twice recalls older history": {
78+
setup: func() inputModel {
79+
return newInputModel([]string{"deploy", "run"}, "")
80+
},
81+
actions: func(m inputModel) inputModel {
82+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
83+
m = updated.(inputModel)
84+
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
85+
m = updated.(inputModel)
86+
return m
87+
},
88+
assertFn: func(t *testing.T, m inputModel) {
89+
assert.Equal(t, "deploy", m.textInput.Value())
90+
assert.Equal(t, 0, m.histIndex)
91+
},
92+
},
93+
"down arrow restores saved text": {
94+
setup: func() inputModel {
95+
m := newInputModel([]string{"deploy", "run"}, "")
96+
for _, r := range "ver" {
97+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
98+
m = updated.(inputModel)
99+
}
100+
return m
101+
},
102+
actions: func(m inputModel) inputModel {
103+
// Go up - saves "ver", shows "run"
104+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
105+
m = updated.(inputModel)
106+
// Go back down - restores "ver"
107+
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
108+
m = updated.(inputModel)
109+
return m
110+
},
111+
assertFn: func(t *testing.T, m inputModel) {
112+
assert.Equal(t, "ver", m.textInput.Value())
113+
assert.Equal(t, 2, m.histIndex)
114+
},
115+
},
116+
"up at oldest entry does nothing": {
117+
setup: func() inputModel {
118+
return newInputModel([]string{"deploy"}, "")
119+
},
120+
actions: func(m inputModel) inputModel {
121+
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
122+
m = updated.(inputModel)
123+
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
124+
m = updated.(inputModel)
125+
return m
126+
},
127+
assertFn: func(t *testing.T, m inputModel) {
128+
assert.Equal(t, "deploy", m.textInput.Value())
129+
assert.Equal(t, 0, m.histIndex)
130+
},
131+
},
132+
"view renders border": {
133+
setup: func() inputModel {
134+
return newInputModel(nil, "")
135+
},
136+
actions: func(m inputModel) inputModel {
137+
return m
138+
},
139+
assertFn: func(t *testing.T, m inputModel) {
140+
view := ansi.Strip(m.View())
141+
assert.Contains(t, view, "─")
142+
assert.Contains(t, view, "❯")
143+
},
144+
},
145+
"view renders banner when version set": {
146+
setup: func() inputModel {
147+
m := newInputModel(nil, "v1.0.0")
148+
m.width = 40
149+
return m
150+
},
151+
actions: func(m inputModel) inputModel { return m },
152+
assertFn: func(t *testing.T, m inputModel) {
153+
view := ansi.Strip(m.View())
154+
assert.Contains(t, view, "Slack CLI Shell")
155+
assert.Contains(t, view, "v1.0.0")
156+
assert.Contains(t, view, "❯")
157+
},
158+
},
159+
"view renders no banner when version empty": {
160+
setup: func() inputModel {
161+
m := newInputModel(nil, "")
162+
m.width = 40
163+
return m
164+
},
165+
actions: func(m inputModel) inputModel { return m },
166+
assertFn: func(t *testing.T, m inputModel) {
167+
view := ansi.Strip(m.View())
168+
assert.NotContains(t, view, "Slack CLI Shell")
169+
assert.Contains(t, view, "❯")
170+
},
171+
},
172+
"view is empty when done": {
173+
setup: func() inputModel {
174+
m := newInputModel(nil, "")
175+
m.done = true
176+
return m
177+
},
178+
actions: func(m inputModel) inputModel {
179+
return m
180+
},
181+
assertFn: func(t *testing.T, m inputModel) {
182+
assert.Equal(t, "", m.View())
183+
},
184+
},
185+
}
186+
187+
for name, tc := range tests {
188+
t.Run(name, func(t *testing.T) {
189+
m := tc.setup()
190+
m = tc.actions(m)
191+
tc.assertFn(t, m)
192+
})
193+
}
194+
}

0 commit comments

Comments
 (0)