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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user