From c230fdca47d907f8925ed093c7ad46848129e621 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Mon, 2 Mar 2026 19:04:40 -0500 Subject: [PATCH 1/2] 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. --- cmd/shell/banner.go | 64 +++++++++++++ cmd/shell/input.go | 137 ++++++++++++++++++++++++++++ cmd/shell/input_test.go | 194 ++++++++++++++++++++++++++++++++++++++++ cmd/shell/shell.go | 113 +++++++++++++++++++++++ cmd/shell/shell_test.go | 181 +++++++++++++++++++++++++++++++++++++ 5 files changed, 689 insertions(+) create mode 100644 cmd/shell/banner.go create mode 100644 cmd/shell/input.go create mode 100644 cmd/shell/input_test.go create mode 100644 cmd/shell/shell.go create mode 100644 cmd/shell/shell_test.go diff --git a/cmd/shell/banner.go b/cmd/shell/banner.go new file mode 100644 index 00000000..9926fd10 --- /dev/null +++ b/cmd/shell/banner.go @@ -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) +} diff --git a/cmd/shell/input.go b/cmd/shell/input.go new file mode 100644 index 00000000..0ac156e4 --- /dev/null +++ b/cmd/shell/input.go @@ -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, nil + 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 +} diff --git a/cmd/shell/input_test.go b/cmd/shell/input_test.go new file mode 100644 index 00000000..207f141e --- /dev/null +++ b/cmd/shell/input_test.go @@ -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) + }) + } +} diff --git a/cmd/shell/shell.go b/cmd/shell/shell.go new file mode 100644 index 00000000..bf15846a --- /dev/null +++ b/cmd/shell/shell.go @@ -0,0 +1,113 @@ +// 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 ( + "context" + "fmt" + "strings" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/pkg/version" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/cobra" +) + +// BuildCommandTree is set by cmd.Init() to build a fresh command tree for each +// REPL iteration. This avoids a circular import between cmd/shell and cmd. +var BuildCommandTree func(context.Context, *shared.ClientFactory) *cobra.Command + +// readLineFunc is a package-level function variable for test overriding. +var readLineFunc = readLine + +// NewCommand creates the shell command. +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + return &cobra.Command{ + Use: "shell", + Short: "Start an interactive shell session", + Long: "Start an interactive shell where commands can be entered without the 'slack' prefix.", + PreRunE: func(cmd *cobra.Command, args []string) error { + if !clients.Config.WithExperimentOn(experiment.Charm) { + return fmt.Errorf("the shell command requires the charm experiment: --experiment=charm") + } + if !clients.IO.IsTTY() { + return fmt.Errorf("the shell command requires an interactive terminal (TTY)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return RunShell(cmd.Context(), clients) + }, + } +} + +// RunShell implements the interactive REPL loop. +func RunShell(ctx context.Context, clients *shared.ClientFactory) error { + out := clients.IO.WriteOut() + + ver := version.Get() + var history []string + for { + line, err := readLineFunc(clients, history, ver) + if err != nil { + break + } + + // Clear version after first prompt so banner only shows once + ver = "" + + line = strings.TrimSpace(line) + + if line == "" { + continue + } + + if line == "exit" || line == "quit" { + renderGoodbye(out) + return nil + } + + // Add to history (skip consecutive duplicates) + if len(history) == 0 || history[len(history)-1] != line { + history = append(history, line) + } + + if line == "shell" { + fmt.Fprintln(out, "Already in shell mode") + continue + } + + if line == "help" { + line = "--help" + } + + if BuildCommandTree == nil { + return fmt.Errorf("shell command tree builder is not initialized") + } + + args := strings.Fields(line) + root := BuildCommandTree(ctx, clients) + root.SetOut(out) + root.SetErr(clients.IO.WriteErr()) + root.SetIn(clients.IO.ReadIn()) + root.SetArgs(args) + if err := root.Execute(); err != nil { + fmt.Fprintf(clients.IO.WriteErr(), "%s\n", err.Error()) + } + } + + renderGoodbye(out) + return nil +} diff --git a/cmd/shell/shell_test.go b/cmd/shell/shell_test.go new file mode 100644 index 00000000..72c6f575 --- /dev/null +++ b/cmd/shell/shell_test.go @@ -0,0 +1,181 @@ +// 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 ( + "bytes" + "context" + "io" + "testing" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +// mockBuildCommandTree returns a command tree with a single "version" command +// that prints a known string, for testing command execution. +func mockBuildCommandTree(_ context.Context, clients *shared.ClientFactory) *cobra.Command { + root := &cobra.Command{Use: "slack", SilenceErrors: true, SilenceUsage: true} + root.AddCommand(&cobra.Command{ + Use: "version", + Short: "Print version", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Println("v0.0.0-test") + return nil + }, + }) + return root +} + +// mockReadLines returns a readLineFunc that yields lines sequentially, then io.EOF. +func mockReadLines(lines []string) func(*shared.ClientFactory, []string, string) (string, error) { + i := 0 + return func(_ *shared.ClientFactory, _ []string, _ string) (string, error) { + if i >= len(lines) { + return "", io.EOF + } + line := lines[i] + i++ + return line, nil + } +} + +func setupTest(isTTY bool, charmEnabled bool) (*shared.ClientsMock, *cobra.Command) { + cm := shared.NewClientsMock() + cm.AddDefaultMocks() + cm.IO.On("IsTTY").Unset() + cm.IO.On("IsTTY").Return(isTTY) + + clients := shared.NewClientFactory(cm.MockClientFactory()) + + if charmEnabled { + clients.Config.ExperimentsFlag = []string{string(experiment.Charm)} + clients.Config.LoadExperiments(context.Background(), func(_ context.Context, _ string, _ ...interface{}) {}) + } + + cmd := NewCommand(clients) + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + return cm, cmd +} + +func TestShellCommand(t *testing.T) { + // Save and restore BuildCommandTree and readLineFunc + origBuild := BuildCommandTree + origReadLine := readLineFunc + defer func() { + BuildCommandTree = origBuild + readLineFunc = origReadLine + }() + BuildCommandTree = mockBuildCommandTree + + tests := map[string]struct { + input []string + isTTY bool + charm bool + expectErr bool + errContains string + outContains string + outExcludes string + }{ + "requires charm experiment": { + input: []string{"exit"}, + isTTY: true, + charm: false, + expectErr: true, + errContains: "charm experiment", + }, + "requires TTY": { + input: []string{"exit"}, + isTTY: false, + charm: true, + expectErr: true, + errContains: "interactive terminal", + }, + "exit command exits cleanly": { + input: []string{"exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Goodbye", + }, + "quit command exits cleanly": { + input: []string{"quit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Goodbye", + }, + "shell recursion warning": { + input: []string{"shell", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "Already in shell mode", + }, + "empty input continues": { + input: []string{"", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + }, + "command execution": { + input: []string{"version", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "v0.0.0-test", + }, + "help maps to --help": { + input: []string{"help", "exit"}, + isTTY: true, + charm: true, + expectErr: false, + outContains: "slack", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + readLineFunc = mockReadLines(tc.input) + cm, cmd := setupTest(tc.isTTY, tc.charm) + stdout := &bytes.Buffer{} + cmd.SetOut(stdout) + + err := cmd.Execute() + + if tc.expectErr { + assert.Error(t, err) + if tc.errContains != "" { + assert.Contains(t, err.Error(), tc.errContains) + } + } else { + assert.NoError(t, err) + } + + output := cm.GetCombinedOutput() + stdout.String() + if tc.outContains != "" { + assert.Contains(t, output, tc.outContains) + } + if tc.outExcludes != "" { + assert.NotContains(t, output, tc.outExcludes) + } + }) + } +} From 7cfe63a8a282eb1b7f0a45698d434adfbf1e0627 Mon Sep 17 00:00:00 2001 From: Ale Mercado Date: Tue, 3 Mar 2026 15:43:45 -0500 Subject: [PATCH 2/2] fix: clear border lines on resize and reprint --- cmd/shell/input.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/shell/input.go b/cmd/shell/input.go index 0ac156e4..2d8606ae 100644 --- a/cmd/shell/input.go +++ b/cmd/shell/input.go @@ -77,7 +77,7 @@ func (m inputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width - return m, nil + return m, tea.ClearScreen case tea.KeyMsg: switch msg.Type { case tea.KeyEnter: