cmd: complete slash commands before submitting (#17230)

This commit is contained in:
Parth Sareen
2026-07-20 11:25:12 -07:00
committed by GitHub
parent 5ba17e6fdf
commit 9893d39218
3 changed files with 73 additions and 15 deletions
+3
View File
@@ -592,6 +592,9 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.insertInputNewline()
return m, nil
}
if m.applySlashCompletion() {
return m, nil
}
return m.handleSubmit()
case tea.KeyCtrlJ:
m.insertInputNewline()
+19 -10
View File
@@ -51,7 +51,6 @@ const (
)
var chatSlashCommands = []chatSlashCommand{
{name: "/clear", description: "clear this chat"},
{name: "/model", description: "switch models"},
{name: "/new", description: "start a new chat"},
{name: "/think", description: "set thinking mode"},
@@ -67,9 +66,6 @@ var chatSlashCommands = []chatSlashCommand{
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
m.syncInputPlaceholders()
input := strings.TrimSpace(string(m.input))
if selected, ok := m.selectedSlashCommand(); ok {
input = selected
}
if input == "" {
return *m, nil
}
@@ -91,16 +87,31 @@ func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
return m.submitInput(input)
}
func (m chatModel) selectedSlashCommand() (string, bool) {
func (m *chatModel) applySlashCompletion() bool {
input := strings.TrimSpace(string(m.input))
if !strings.HasPrefix(input, "/") {
return "", false
return false
}
if _, _, known := slashCommandInvocation(input); known {
return false
}
completions := m.slashCompletions()
if len(completions) == 0 || !completionIsSelectable(completions) {
return "", false
return false
}
return completions[clamp(m.complete, 0, len(completions)-1)].value, true
selected := completions[clamp(m.complete, 0, len(completions)-1)]
if selected.value == input {
return false
}
// Reset prompt-history state: Up/Down is shared between history recall and
// slash completion, and a recalled prompt may start with "/" and trigger
// completion. Keep the two in sync when we accept a completion.
m.resetPromptHistoryCursor()
m.input = []rune(selected.value)
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.complete = 0
return true
}
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
@@ -117,8 +128,6 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
case command == "/help":
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
return *m, nil
case command == "/clear" && args == "":
return m.resetChat("cleared")
case command == "/model":
return m.openModelPicker(args)
case command == "/think" && args == "":
+51 -5
View File
@@ -708,7 +708,7 @@ func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
}
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
t.Run(command, func(t *testing.T) {
m := chatModel{input: []rune(command)}
@@ -732,12 +732,12 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
}
view := stripANSI(m.View())
for _, want := range []string{"/clear", "/model", "/new", "/think", "/tools"} {
for _, want := range []string{"/model", "/new", "/think", "/tools", "/skills"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %s suggestion: %q", want, view)
}
}
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
if strings.Contains(view, removed) {
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
}
@@ -872,19 +872,65 @@ func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) {
}
}
func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) {
func TestChatEnterFillsSelectedSlashCommandBeforeSubmitting(t *testing.T) {
m := chatModel{input: []rune("/th")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("filling a slash command should not return a command")
}
if got := string(m.input); got != "/think" {
t.Fatalf("input = %q, want completed command", got)
}
if m.thinkPicker != nil {
t.Fatal("filling a slash command should not open its picker")
}
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("think command should not return a command")
}
if m.thinkPicker == nil {
t.Fatal("selected /think command should open picker")
t.Fatal("second enter should submit the completed /think command")
}
}
func TestChatEnterSubmitsExactSlashCommandAliases(t *testing.T) {
t.Run("help", func(t *testing.T) {
m := chatModel{input: []rune("/?")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("help alias should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "slash" {
t.Fatalf("entries = %#v, want help output", m.entries)
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
t.Run("exit", func(t *testing.T) {
m := chatModel{input: []rune("/exit")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd == nil {
t.Fatal("exit alias should return the quit command")
}
m = updated.(chatModel)
if !m.quitting {
t.Fatal("exit alias should quit without filling /bye first")
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
}
func TestChatSlashCommandsRunWhileModelResponds(t *testing.T) {
m := chatModel{running: true, input: []rune("/help")}