cmd: complete slash commands before submitting (#17230)
This commit is contained in:
@@ -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
@@ -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 == "":
|
||||
|
||||
@@ -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")}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user