From 85bfddf1c44e72892275c05a4ad063ab9fa3aa2f Mon Sep 17 00:00:00 2001 From: kami Date: Sun, 21 Jun 2026 09:35:34 +0000 Subject: [PATCH] feat(tui-go): slash command menu in the chat input Typing `/` as the first char of a chat line opens the command palette inline (opencode-style), instead of typing a literal slash; mid-line `/` stays literal. Factors openPalette() (shared with the `p` key) and advertises `/ cmds` in the insert-mode footer when the line is empty. Co-Authored-By: Claude Opus 4.8 --- apps/tui-go/internal/app/slash_test.go | 45 ++++++++++++++++++++++++++ apps/tui-go/internal/app/update.go | 20 ++++++++++-- apps/tui-go/internal/app/view.go | 4 +++ 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 apps/tui-go/internal/app/slash_test.go diff --git a/apps/tui-go/internal/app/slash_test.go b/apps/tui-go/internal/app/slash_test.go new file mode 100644 index 00000000..fa7078e1 --- /dev/null +++ b/apps/tui-go/internal/app/slash_test.go @@ -0,0 +1,45 @@ +package app + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// `/` as the first char of a chat line opens the command palette inline (opencode-style), +// without typing a literal slash. +func TestSlashOpensPaletteOnEmptyLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "" + + next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayPalette { + t.Fatalf("'/' on an empty line should open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "" { + t.Fatalf("'/' should not be typed literally; buffer=%q", nm.inputBuffer) + } +} + +// Mid-line `/` is a literal slash (e.g. a path), not a command trigger. +func TestSlashLiteralMidLine(t *testing.T) { + m := NewModel(nil) + m.editMode = ModeInsert + m.inputMode = ModeRouter + m.inputBuffer = "path" + m.inputCursor = len("path") + + next, _ := m.handleInsertKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")}) + nm := next.(Model) + + if nm.overlay != OverlayNone { + t.Fatalf("mid-line '/' must stay literal, not open the palette; overlay=%d", nm.overlay) + } + if nm.inputBuffer != "path/" { + t.Fatalf("mid-line '/' should be typed; buffer=%q", nm.inputBuffer) + } +} diff --git a/apps/tui-go/internal/app/update.go b/apps/tui-go/internal/app/update.go index 5dc6e10f..d2c4c415 100644 --- a/apps/tui-go/internal/app/update.go +++ b/apps/tui-go/internal/app/update.go @@ -229,9 +229,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "k": m.navUp() case "p": - m.overlay = OverlayPalette - m.paletteFilter = "" - m.paletteIndex = 0 + m.openPalette() case "e": m.overlay = OverlayEventInspector m.overlayEventIdx = 0 @@ -408,12 +406,28 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.historyNext() } case tea.KeyRunes, tea.KeySpace: + // opencode-style slash commands: `/` as the first char of a chat line opens the + // command palette inline instead of typing a literal slash. Mid-line `/` is literal. + if m.inputMode == ModeRouter && m.inputBuffer == "" && string(k.Runes) == "/" { + m.editMode = ModeNormal + m.clearInput() + m.openPalette() + return m, nil + } m.appendRunes(string(k.Runes)) m.syncFilter() } return m, nil } +// openPalette opens the command palette with a cleared filter. Shared by the `p` +// normal-mode key and the inline `/` trigger in the chat input. +func (m *Model) openPalette() { + m.overlay = OverlayPalette + m.paletteFilter = "" + m.paletteIndex = 0 +} + // beginIntent stashes the chosen workflow and switches to a text-entry prompt for the // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no // intent (fixed-task workflows). Esc cancels. diff --git a/apps/tui-go/internal/app/view.go b/apps/tui-go/internal/app/view.go index b48eb377..1c4b4451 100644 --- a/apps/tui-go/internal/app/view.go +++ b/apps/tui-go/internal/app/view.go @@ -221,6 +221,10 @@ func (m Model) renderFooter() string { switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} + // The `/` command menu only triggers on an empty chat line (mid-line `/` is literal). + if m.inputMode == ModeRouter && m.inputBuffer == "" { + hints = append(hints, hint("/", "cmds")) + } case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}