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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 09:35:34 +00:00
parent 9143d554f1
commit 85bfddf1c4
3 changed files with 66 additions and 3 deletions
+45
View File
@@ -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)
}
}
+17 -3
View File
@@ -229,9 +229,7 @@ func (m Model) handleNormalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case "k": case "k":
m.navUp() m.navUp()
case "p": case "p":
m.overlay = OverlayPalette m.openPalette()
m.paletteFilter = ""
m.paletteIndex = 0
case "e": case "e":
m.overlay = OverlayEventInspector m.overlay = OverlayEventInspector
m.overlayEventIdx = 0 m.overlayEventIdx = 0
@@ -408,12 +406,28 @@ func (m Model) handleInsertKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.historyNext() m.historyNext()
} }
case tea.KeyRunes, tea.KeySpace: 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.appendRunes(string(k.Runes))
m.syncFilter() m.syncFilter()
} }
return m, nil 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 // 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 // freeform request. Submitting sends StartSession(id, intent); an empty line starts with no
// intent (fixed-task workflows). Esc cancels. // intent (fixed-task workflows). Esc cancels.
+4
View File
@@ -221,6 +221,10 @@ func (m Model) renderFooter() string {
switch { switch {
case m.editMode == ModeInsert: case m.editMode == ModeInsert:
hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} 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: case m.displayState() == StateApproval:
// Approval actions live in the band itself; the footer offers navigation only. // Approval actions live in the band itself; the footer offers navigation only.
hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")}