From fe27014d33a5ed51984cebe32d9be110aa864bf0 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 16 Jul 2026 00:20:24 +0200 Subject: [PATCH 1/2] feat: use shared fzf model search Extract model-choice filtering and ranking into a shared package backed by the existing fzf matcher. Search across the display name, provider, model ID, and reference while preserving the configured order for an empty query. Update the full TUI model picker to use the shared search so other interfaces can provide identical matching and ranking without duplicating the implementation. Signed-off-by: Djordje Lukic --- pkg/modelpicker/search.go | 60 +++++++++++++++++++++++++++++ pkg/modelpicker/search_test.go | 30 +++++++++++++++ pkg/tui/dialog/model_picker.go | 22 ++++------- pkg/tui/dialog/model_picker_test.go | 15 ++++---- 4 files changed, 105 insertions(+), 22 deletions(-) create mode 100644 pkg/modelpicker/search.go create mode 100644 pkg/modelpicker/search_test.go diff --git a/pkg/modelpicker/search.go b/pkg/modelpicker/search.go new file mode 100644 index 000000000..72d33b81f --- /dev/null +++ b/pkg/modelpicker/search.go @@ -0,0 +1,60 @@ +package modelpicker + +import ( + "sort" + "strings" + + "github.com/junegunn/fzf/src/algo" + "github.com/junegunn/fzf/src/util" + + "github.com/docker/docker-agent/pkg/runtime" +) + +type scoredChoice struct { + choice runtime.ModelChoice + score int +} + +// Score returns fzf's match score for a model choice and query. +func Score(choice runtime.ModelChoice, query string) (int, bool) { + query = strings.TrimSpace(query) + if query == "" { + return 0, true + } + + chars := util.ToChars([]byte(choice.Name + " " + choice.Provider + " " + choice.Model + " " + choice.Ref)) + result, _ := algo.FuzzyMatchV1( + false, + false, + true, + &chars, + []rune(strings.ToLower(query)), + true, + nil, + ) + return result.Score, result.Start >= 0 +} + +// Filter returns matching choices ranked by fzf score. An empty query keeps +// the input order. +func Filter(choices []runtime.ModelChoice, query string) []runtime.ModelChoice { + if strings.TrimSpace(query) == "" { + return append([]runtime.ModelChoice(nil), choices...) + } + + matches := make([]scoredChoice, 0, len(choices)) + for _, choice := range choices { + if score, ok := Score(choice, query); ok { + matches = append(matches, scoredChoice{choice: choice, score: score}) + } + } + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + + result := make([]runtime.ModelChoice, len(matches)) + for i, match := range matches { + result[i] = match.choice + } + return result +} diff --git a/pkg/modelpicker/search_test.go b/pkg/modelpicker/search_test.go new file mode 100644 index 000000000..0692904a6 --- /dev/null +++ b/pkg/modelpicker/search_test.go @@ -0,0 +1,30 @@ +package modelpicker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/runtime" +) + +func TestFilterUsesFuzzyModelMetadataSearch(t *testing.T) { + t.Parallel() + + choices := []runtime.ModelChoice{ + {Name: "Claude Sonnet", Ref: "sonnet", Provider: "anthropic", Model: "claude-sonnet-4-6"}, + {Name: "GPT Five", Ref: "gpt", Provider: "openai", Model: "gpt-5"}, + } + + matches := Filter(choices, "cldsn46") + require.Len(t, matches, 1) + assert.Equal(t, "sonnet", matches[0].Ref) +} + +func TestFilterEmptyQueryPreservesOrder(t *testing.T) { + t.Parallel() + + choices := []runtime.ModelChoice{{Ref: "second"}, {Ref: "first"}} + assert.Equal(t, choices, Filter(choices, "")) +} diff --git a/pkg/tui/dialog/model_picker.go b/pkg/tui/dialog/model_picker.go index fc8793c59..5ec10fe74 100644 --- a/pkg/tui/dialog/model_picker.go +++ b/pkg/tui/dialog/model_picker.go @@ -14,6 +14,7 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/docker/docker-agent/pkg/modelpicker" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tui/components/toolcommon" "github.com/docker/docker-agent/pkg/tui/core" @@ -324,22 +325,13 @@ func (d *modelPickerDialog) filterModels() { // If query contains "/", show "Custom" option as well as matches isCustomQuery := strings.Contains(query, "/") - d.filtered = d.filtered[:0] - for _, model := range d.models { - if query == "" { - d.filtered = append(d.filtered, model) - continue - } - - // Match against name, provider, and model - searchText := strings.ToLower(model.Name + " " + model.Provider + " " + model.Model) - if strings.Contains(searchText, query) { - d.filtered = append(d.filtered, model) - } - } + d.filtered = modelpicker.Filter(d.models, query) - // If query looks like a custom model spec and we have no exact match, show it as an option - if isCustomQuery && len(d.filtered) == 0 { + // Keep an explicit provider/model option alongside fuzzy matches unless + // the query already identifies an available choice. + if isCustomQuery && !slices.ContainsFunc(d.models, func(model runtime.ModelChoice) bool { + return strings.EqualFold(model.Ref, query) || strings.EqualFold(model.Provider+"/"+model.Model, query) + }) { d.filtered = append(d.filtered, runtime.ModelChoice{ Name: "Custom: " + query, Ref: query, diff --git a/pkg/tui/dialog/model_picker_test.go b/pkg/tui/dialog/model_picker_test.go index ae3a41c4b..9c3c622fe 100644 --- a/pkg/tui/dialog/model_picker_test.go +++ b/pkg/tui/dialog/model_picker_test.go @@ -80,8 +80,8 @@ func TestModelPickerFiltering(t *testing.T) { d.Update(tea.KeyPressMsg{Text: string(ch)}) } - // Should now only show openai model - require.Len(t, d.filtered, 1, "should have 1 model after filtering for 'openai'") + // The exact provider match should rank above looser fuzzy matches. + require.NotEmpty(t, d.filtered) require.Equal(t, "openai_model", d.filtered[0].Name) // Selection should be reset to 0 @@ -92,7 +92,7 @@ func TestModelPickerCustomModel(t *testing.T) { t.Parallel() models := []runtime.ModelChoice{ - {Name: "default_model", Ref: "default_model", Provider: "openai", Model: "gpt-4o", IsDefault: true}, + {Name: "default_model", Ref: "openai/gpt-4o", Provider: "openai", Model: "gpt-4o", IsDefault: true}, } dialog := NewModelPickerDialog(models) @@ -105,10 +105,11 @@ func TestModelPickerCustomModel(t *testing.T) { d.Update(tea.KeyPressMsg{Text: string(ch)}) } - // Should show the custom model option since nothing matches - require.Len(t, d.filtered, 1, "should have 1 item (custom option)") - require.Equal(t, "Custom: openai/gpt-4", d.filtered[0].Name) - require.Equal(t, "openai/gpt-4", d.filtered[0].Ref) + // Keep fuzzy matches and append the explicit custom model option. + require.GreaterOrEqual(t, len(d.filtered), 2) + custom := d.filtered[len(d.filtered)-1] + require.Equal(t, "Custom: openai/gpt-4", custom.Name) + require.Equal(t, "openai/gpt-4", custom.Ref) } func TestModelPickerRefreshShortcut(t *testing.T) { From 381267ff72563f3b004ca0c33d97efeed3c5f509 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 16 Jul 2026 00:20:31 +0200 Subject: [PATCH 2/2] feat: add model switching to lean TUI Add the /model command to the lean TUI with direct provider/model selection, default-model reset, and an interactive scoped completion list. Use the shared fzf model scorer so searching by display name, provider, model ID, or reference behaves consistently with the full TUI. Restore regular slash-command completions when the model picker is dismissed. Signed-off-by: Djordje Lukic --- pkg/leantui/commands.go | 1 + pkg/leantui/ui/autocomplete.go | 56 ++++++++++++++++++---- pkg/leantui/ui/autocomplete_test.go | 33 +++++++++++++ pkg/leantui/ui/commands.go | 44 ++++++++++++++++-- pkg/leantui/update.go | 72 +++++++++++++++++++++++++++-- pkg/leantui/update_test.go | 65 +++++++++++++++++++++++--- 6 files changed, 248 insertions(+), 23 deletions(-) diff --git a/pkg/leantui/commands.go b/pkg/leantui/commands.go index d23ba4480..359614af6 100644 --- a/pkg/leantui/commands.go +++ b/pkg/leantui/commands.go @@ -7,6 +7,7 @@ func builtinCommands() []ui.Command { return []ui.Command{ {Name: "new", Desc: "Start a new session", Kind: ui.CmdBuiltin}, {Name: "compact", Desc: "Summarize and compact the conversation", Kind: ui.CmdBuiltin}, + {Name: "model", Desc: "Change the model for the current agent", Kind: ui.CmdBuiltin}, {Name: "effort", Desc: "Set the model's reasoning effort (usage: /effort )", Kind: ui.CmdBuiltin}, {Name: "clear", Desc: "Clear the screen", Kind: ui.CmdBuiltin}, {Name: "help", Desc: "Show keyboard shortcuts and commands", Kind: ui.CmdBuiltin}, diff --git a/pkg/leantui/ui/autocomplete.go b/pkg/leantui/ui/autocomplete.go index f5c489d18..e598aaa15 100644 --- a/pkg/leantui/ui/autocomplete.go +++ b/pkg/leantui/ui/autocomplete.go @@ -4,13 +4,14 @@ import "strings" const autocompleteMaxRows = 8 -// Autocomplete drives the slash-command popup. It is active whenever the input -// is a partial command: a single token starting with "/" and no spaces yet. +// Autocomplete drives the slash-command popup for commands and scoped arguments. type Autocomplete struct { - all []Command - matches []Command - selected int - Active bool + base []Command + all []Command + matches []Command + selected int + scopePrefix string + Active bool } func NewAutocomplete() *Autocomplete { @@ -18,19 +19,41 @@ func NewAutocomplete() *Autocomplete { } func (a *Autocomplete) SetCommands(cmds []Command) { + a.base = cmds a.all = cmds + a.scopePrefix = "" +} + +// SetScopedCommands temporarily replaces the command list with completions for +// an argument-taking command. prefix excludes the leading slash and includes +// any desired separator, for example "model ". +func (a *Autocomplete) SetScopedCommands(prefix string, cmds []Command) { + a.all = cmds + a.scopePrefix = prefix + a.selected = 0 } // Sync recomputes the popup state from the current editor text. It returns true // while the popup is showing. func (a *Autocomplete) Sync(input string) bool { - if !strings.HasPrefix(input, "/") || strings.ContainsAny(input, " \n") { + prefix := "/" + a.scopePrefix + var query string + switch { + case a.scopePrefix != "" && strings.HasPrefix(input, prefix) && !strings.Contains(input, "\n"): + query = strings.TrimPrefix(input, prefix) + case strings.HasPrefix(input, "/") && !strings.ContainsAny(input, " \n"): + query = input[1:] + default: a.Active = false a.matches = nil a.selected = 0 return false } - a.matches = FilterCommands(a.all, input[1:]) + if a.scopePrefix == "" { + a.matches = FilterCommands(a.all, query) + } else { + a.matches = FilterScopedCommands(a.all, query) + } a.Active = len(a.matches) > 0 if a.selected >= len(a.matches) { a.selected = len(a.matches) - 1 @@ -70,6 +93,19 @@ func (a *Autocomplete) Dismiss() { a.Active = false a.matches = nil a.selected = 0 + if a.scopePrefix != "" { + a.all = a.base + a.scopePrefix = "" + } +} + +// Completion returns the editor text for a selected completion. +func (a *Autocomplete) Completion(cmd Command) string { + value := cmd.Value + if value == "" { + value = cmd.Name + } + return "/" + a.scopePrefix + value } // Render returns the popup rows (top to bottom) for the given width. @@ -86,7 +122,7 @@ func (a *Autocomplete) Render(width int) []string { nameWidth := 0 for _, c := range a.matches { - if w := len(c.Name) + 1; w > nameWidth { + if w := len(a.scopePrefix) + len(c.Name) + 1; w > nameWidth { nameWidth = w } } @@ -95,7 +131,7 @@ func (a *Autocomplete) Render(width int) []string { var rows []string for i := start; i < end; i++ { c := a.matches[i] - name := PadRight("/"+c.Name, nameWidth) + name := PadRight("/"+a.scopePrefix+c.Name, nameWidth) line := " " + name + " " + c.Desc line = Truncate(line, width) if i == a.selected { diff --git a/pkg/leantui/ui/autocomplete_test.go b/pkg/leantui/ui/autocomplete_test.go index af5f7a118..6c1f3ff44 100644 --- a/pkg/leantui/ui/autocomplete_test.go +++ b/pkg/leantui/ui/autocomplete_test.go @@ -5,6 +5,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/modelpicker" + "github.com/docker/docker-agent/pkg/runtime" ) func testCommands() []Command { @@ -31,6 +34,36 @@ func TestAutocompleteActivation(t *testing.T) { assert.False(t, a.Sync("/zzzzz")) // no matches } +func TestAutocompleteScopedCommands(t *testing.T) { + t.Parallel() + a := NewAutocomplete() + a.SetCommands(testCommands()) + gpt := runtime.ModelChoice{Name: "GPT Five", Ref: "openai/gpt-5", Provider: "openai", Model: "gpt-5"} + sonnet := runtime.ModelChoice{Name: "Claude Sonnet", Ref: "anthropic/claude-sonnet-4-6", Provider: "anthropic", Model: "claude-sonnet-4-6"} + a.SetScopedCommands("model ", []Command{ + {Name: gpt.Ref, MatchScore: func(query string) (int, bool) { return modelpicker.Score(gpt, query) }, Kind: CmdBuiltin}, + {Name: sonnet.Ref, MatchScore: func(query string) (int, bool) { return modelpicker.Score(sonnet, query) }, Kind: CmdBuiltin}, + }) + + require.True(t, a.Sync("/model open")) + cmd, ok := a.Current() + require.True(t, ok) + assert.Equal(t, "openai/gpt-5", cmd.Name) + assert.Equal(t, "/model openai/gpt-5", a.Completion(cmd)) + + require.True(t, a.Sync("/model five")) + cmd, ok = a.Current() + require.True(t, ok) + assert.Equal(t, "openai/gpt-5", cmd.Name) + + a.Dismiss() + assert.False(t, a.Sync("/model open")) + require.True(t, a.Sync("/ne")) + cmd, ok = a.Current() + require.True(t, ok) + assert.Equal(t, "new", cmd.Name) +} + func TestAutocompleteNavigation(t *testing.T) { t.Parallel() a := NewAutocomplete() diff --git a/pkg/leantui/ui/commands.go b/pkg/leantui/ui/commands.go index da7fd4132..dadc866ad 100644 --- a/pkg/leantui/ui/commands.go +++ b/pkg/leantui/ui/commands.go @@ -15,18 +15,54 @@ const ( ) type Command struct { - Name string - Desc string - Kind CommandKind + Name string + Desc string + Value string + MatchScore func(query string) (int, bool) + Kind CommandKind } // FilterCommands returns the commands whose name has the given prefix, built-in // commands first, then agent commands, each group alphabetically sorted. func FilterCommands(all []Command, prefix string) []Command { prefix = strings.ToLower(prefix) + return filterCommands(all, func(c Command) bool { + return strings.HasPrefix(strings.ToLower(c.Name), prefix) + }) +} + +// FilterScopedCommands ranks argument completions with their configured matcher. +func FilterScopedCommands(all []Command, query string) []Command { + query = strings.TrimSpace(query) + type scoredCommand struct { + command Command + score int + } + matches := make([]scoredCommand, 0, len(all)) + for _, command := range all { + score, ok := 0, query == "" + if command.MatchScore != nil { + score, ok = command.MatchScore(query) + } + if ok { + matches = append(matches, scoredCommand{command: command, score: score}) + } + } + sort.SliceStable(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + + result := make([]Command, len(matches)) + for i, match := range matches { + result[i] = match.command + } + return result +} + +func filterCommands(all []Command, match func(Command) bool) []Command { var out []Command for _, c := range all { - if strings.HasPrefix(strings.ToLower(c.Name), prefix) { + if match(c) { out = append(out, c) } } diff --git a/pkg/leantui/update.go b/pkg/leantui/update.go index 1f7b3edc9..d2ac8eee0 100644 --- a/pkg/leantui/update.go +++ b/pkg/leantui/update.go @@ -11,6 +11,7 @@ import ( "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/leantui/ui" + "github.com/docker/docker-agent/pkg/modelpicker" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/tui/messages" ) @@ -104,8 +105,9 @@ func (m *model) handleInterrupt() { func (m *model) handleEnter(ctx context.Context) { if m.screen.Autocomplete.Active { if cmd, ok := m.screen.Autocomplete.Current(); ok { + completion := m.screen.Autocomplete.Completion(cmd) m.screen.Autocomplete.Dismiss() - m.submitEditor(ctx, "/"+cmd.Name) + m.submitEditor(ctx, completion) return } } @@ -117,7 +119,7 @@ func (m *model) handleTab() { return } if cmd, ok := m.screen.Autocomplete.Current(); ok { - m.screen.Editor.SetText("/" + cmd.Name + " ") + m.screen.Editor.SetText(m.screen.Autocomplete.Completion(cmd) + " ") m.screen.Autocomplete.Sync(m.screen.Editor.Text()) } } @@ -244,6 +246,9 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod m.addUserEcho(text) m.startCompact(ctx, rest) return true + case "model": + m.handleModelCommand(ctx, rest) + return true case "effort": m.handleSetThinkingLevel(ctx, rest) return true @@ -268,6 +273,61 @@ func (m *model) handleSlash(ctx context.Context, text string, mode busySubmitMod return false } +func (m *model) handleModelCommand(ctx context.Context, modelRef string) { + if m.app == nil || !m.app.SupportsModelSwitching() { + m.addNotice("", "Model switching is not supported with this runtime", ui.StMuted()) + return + } + + if modelRef == "" { + models := m.app.AvailableModels(ctx) + if len(models) == 0 { + m.addNotice("", "No models available for selection", ui.StMuted()) + return + } + cmds := make([]ui.Command, 0, len(models)) + for _, choice := range models { + desc := choice.Name + if choice.IsCurrent { + desc = strings.TrimSpace(desc + " (current)") + } else if choice.IsDefault { + desc = strings.TrimSpace(desc + " (default)") + } + value := choice.Ref + if choice.IsDefault { + value = "default" + } + cmds = append(cmds, ui.Command{ + Name: choice.Ref, + Desc: desc, + Value: value, + MatchScore: func(query string) (int, bool) { + return modelpicker.Score(choice, query) + }, + Kind: ui.CmdBuiltin, + }) + } + m.screen.Autocomplete.SetScopedCommands("model ", cmds) + m.screen.Editor.SetText("/model ") + m.screen.Autocomplete.Sync(m.screen.Editor.Text()) + return + } + + if modelRef == "default" { + modelRef = "" + } + if err := m.app.SetCurrentAgentModel(ctx, modelRef); err != nil { + m.addNotice("✗ ", "Failed to change model: "+err.Error(), ui.StError()) + return + } + m.refreshCommands(ctx) + if modelRef == "" { + m.addNotice("", "Model reset to default", ui.StMuted()) + return + } + m.addNotice("", "Model changed to "+modelRef, ui.StMuted()) +} + func (m *model) dispatchUserMessage(ctx context.Context, display, content string, mode busySubmitMode) { if m.app.IsReadOnly() { m.addUserEcho(display) @@ -351,7 +411,12 @@ func (m *model) startSkillFork(ctx context.Context, name, task string) { } func (m *model) refreshCommands(ctx context.Context) { - cmds := builtinCommands() + cmds := make([]ui.Command, 0) + for _, cmd := range builtinCommands() { + if !m.disabledCommands[cmd.Name] { + cmds = append(cmds, cmd) + } + } for name, c := range m.app.CurrentAgentCommands(ctx) { if m.disabledCommands[name] { continue @@ -469,6 +534,7 @@ func (m *model) commitHelp() { ui.StBold().Render("Commands"), ui.StMuted().Render(" /new start a new session"), ui.StMuted().Render(" /compact summarize and compact the conversation"), + ui.StMuted().Render(" /model change the model for the current agent"), ui.StMuted().Render(" /effort set the model's reasoning effort (e.g. /effort high)"), ui.StMuted().Render(" /clear clear the screen"), ui.StMuted().Render(" /help show this help"), diff --git a/pkg/leantui/update_test.go b/pkg/leantui/update_test.go index 9a589c21c..5c0f4a3e2 100644 --- a/pkg/leantui/update_test.go +++ b/pkg/leantui/update_test.go @@ -27,6 +27,9 @@ type cycleThinkingRuntime struct { setLevel effort.Level steered []runtime.QueuedMessage steerErr error + models []runtime.ModelChoice + modelRef string + modelErr error } func (r *cycleThinkingRuntime) CurrentAgentInfo(context.Context) runtime.CurrentAgentInfo { @@ -99,7 +102,12 @@ func (r *cycleThinkingRuntime) QueueStatus() runtime.QueueStatus func (r *cycleThinkingRuntime) TogglePause(context.Context) (bool, error) { return false, nil } -func (r *cycleThinkingRuntime) SetAgentModel(context.Context, string, string) error { return nil } + +func (r *cycleThinkingRuntime) SetAgentModel(_ context.Context, _, modelRef string) error { + r.modelRef = modelRef + return r.modelErr +} + func (r *cycleThinkingRuntime) CycleAgentThinkingLevel(context.Context, string) (effort.Level, error) { r.cycleCalls++ if r.err != nil { @@ -116,11 +124,14 @@ func (r *cycleThinkingRuntime) SetAgentThinkingLevel(_ context.Context, _ string r.setLevel = level return level, nil } -func (r *cycleThinkingRuntime) AvailableModels(context.Context) []runtime.ModelChoice { return nil } -func (r *cycleThinkingRuntime) SupportsModelSwitching() bool { return r.supports } -func (r *cycleThinkingRuntime) OnToolsChanged(func(runtime.Event)) {} -func (r *cycleThinkingRuntime) OnBackgroundEvent(func(runtime.Event)) {} -func (r *cycleThinkingRuntime) OnElicitationRequest(func(runtime.Event)) {} + +func (r *cycleThinkingRuntime) AvailableModels(context.Context) []runtime.ModelChoice { + return r.models +} +func (r *cycleThinkingRuntime) SupportsModelSwitching() bool { return r.supports } +func (r *cycleThinkingRuntime) OnToolsChanged(func(runtime.Event)) {} +func (r *cycleThinkingRuntime) OnBackgroundEvent(func(runtime.Event)) {} +func (r *cycleThinkingRuntime) OnElicitationRequest(func(runtime.Event)) {} var _ runtime.Runtime = (*cycleThinkingRuntime)(nil) @@ -176,6 +187,48 @@ func TestEffortCommandRejectsUnknownLevel(t *testing.T) { assert.Equal(t, 1, m.screen.Transcript.BlockCount()) } +func TestModelCommandSwitchesModel(t *testing.T) { + t.Parallel() + rt := &cycleThinkingRuntime{supports: true} + m := bareModel(24) + m.app = app.New(t.Context(), rt, session.New()) + + assert.True(t, m.handleSlash(t.Context(), "/model openai/gpt-5", busySubmitSteer)) + + assert.Equal(t, "openai/gpt-5", rt.modelRef) + assert.Equal(t, 1, m.screen.Transcript.BlockCount()) +} + +func TestModelCommandOpensModelAutocomplete(t *testing.T) { + t.Parallel() + rt := &cycleThinkingRuntime{ + supports: true, + models: []runtime.ModelChoice{ + {Name: "GPT 5", Ref: "openai/gpt-5", Provider: "openai", Model: "gpt-5", IsCurrent: true, IsDefault: true}, + {Name: "Sonnet", Ref: "anthropic/claude-sonnet-4-6", Provider: "anthropic", Model: "claude-sonnet-4-6"}, + }, + } + m := bareModel(24) + m.app = app.New(t.Context(), rt, session.New()) + + assert.True(t, m.handleSlash(t.Context(), "/model", busySubmitSteer)) + + assert.Equal(t, "/model ", m.screen.Editor.Text()) + assert.True(t, m.screen.Autocomplete.Active) + cmd, ok := m.screen.Autocomplete.Current() + if assert.True(t, ok) { + assert.Equal(t, "openai/gpt-5", cmd.Name) + assert.Equal(t, "/model default", m.screen.Autocomplete.Completion(cmd)) + } + + assert.True(t, m.screen.Autocomplete.Sync("/model gpt 5")) + cmd, ok = m.screen.Autocomplete.Current() + if assert.True(t, ok) { + assert.Equal(t, "openai/gpt-5", cmd.Name) + assert.Equal(t, "/model default", m.screen.Autocomplete.Completion(cmd)) + } +} + func TestEditorSubmitWhileBusySteersAndRendersAtStreamEnd(t *testing.T) { t.Parallel() rt := &cycleThinkingRuntime{}