From 99d94ce99516dddb2bd0ac1a3dc80127ecd3fedd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:30:35 -0400 Subject: [PATCH 1/8] Add direct chat model rotation and ordered model settings --- .../unreleased/1-chat-model-rotation.md | 15 ++ docs/design/icons/DESIGN.md | 7 + internal/config/modelrotation.go | 17 ++ internal/config/modelrotation_test.go | 33 ++++ internal/config/settings.go | 7 + internal/manual/chat/models-and-cost.md | 23 +++ internal/tui3/app.go | 7 + internal/tui3/commands.go | 1 + internal/tui3/foot.go | 17 +- internal/tui3/hover.go | 5 + internal/tui3/input.go | 7 + internal/tui3/lanes_test.go | 11 +- internal/tui3/modelrotation.go | 99 ++++++++++ internal/tui3/modelrotation_test.go | 170 ++++++++++++++++++ internal/tui3/render.go | 17 +- internal/tui3/settings.go | 26 ++- internal/tui3/settings_live.go | 1 + 17 files changed, 454 insertions(+), 9 deletions(-) create mode 100644 docs/changes/unreleased/1-chat-model-rotation.md create mode 100644 internal/config/modelrotation.go create mode 100644 internal/config/modelrotation_test.go create mode 100644 internal/tui3/modelrotation.go create mode 100644 internal/tui3/modelrotation_test.go diff --git a/docs/changes/unreleased/1-chat-model-rotation.md b/docs/changes/unreleased/1-chat-model-rotation.md new file mode 100644 index 0000000000..1c2ce03597 --- /dev/null +++ b/docs/changes/unreleased/1-chat-model-rotation.md @@ -0,0 +1,15 @@ +--- +kind: added +title: switch between saved chat models directly beside the composer +pr: 1 +surface: [chat] +invalidates: + - >- + Switching chat models required opening the model picker or typing a model + command. A saved rotation now adds an exchange control beside the chat model; + a click or f3 advances it without disturbing the draft. + - >- + Settings could select one chat model but could not hold a quick-switch list. + Models now has an ordered multi-selection row; entries persist across launches + and never change task models. +--- diff --git a/docs/design/icons/DESIGN.md b/docs/design/icons/DESIGN.md index 66a9fb46aa..67dd522836 100644 --- a/docs/design/icons/DESIGN.md +++ b/docs/design/icons/DESIGN.md @@ -298,3 +298,10 @@ own page, a run held at its spend gate drew the banned `⏸`, and the fold mark `GTeamDot` marks a team identity in the Teams sidebar: `●` in the plain tier, `nf-fa-circle` in the Nerd Font tier, and `o` in ASCII. All teams uses white ink. The navigation tree separates this circle from work-state dots by position. + +### Chat model exchange + +The quick-switch control beside the chat model reuses `GActionTransfer`: an +exchange between model choices. It retains the vocabulary's plain `⇄`, Nerd Font +`nf-fa-exchange`, and ASCII `&` spellings. The padded control has its own pointer +span, separate from the model name that opens the catalog. diff --git a/internal/config/modelrotation.go b/internal/config/modelrotation.go new file mode 100644 index 0000000000..77cdd4610b --- /dev/null +++ b/internal/config/modelrotation.go @@ -0,0 +1,17 @@ +package config + +import "strings" + +// KeyChatModelRotation holds an ordered preference, separate from automatic fallbacks. +const KeyChatModelRotation = "models.quick_switch" + +// ChatModelRotationAt reads the saved order without introducing catalog defaults. +func ChatModelRotationAt(dir string) []string { + raw, _ := persistedString(dir, KeyChatModelRotation) + return ParseModelFallbacks(raw) +} + +// WriteChatModelRotation uses the profile writer so unrelated preferences survive. +func WriteChatModelRotation(dir, raw string) error { + return writeProfileValue(dir, KeyChatModelRotation, strings.Join(ParseModelFallbacks(raw), ", ")) +} diff --git a/internal/config/modelrotation_test.go b/internal/config/modelrotation_test.go new file mode 100644 index 0000000000..b514d3cef7 --- /dev/null +++ b/internal/config/modelrotation_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "reflect" + "testing" +) + +func TestChatModelRotationPersistsOrderAndPreservesOtherSettings(t *testing.T) { + dir := t.TempDir() + if err := WriteChatModel(dir, "vendor/current"); err != nil { + t.Fatal(err) + } + registry := NewSettings(SettingsOptions{ProfileDir: dir}) + row, ok := registry.Row(KeyChatModelRotation) + if !ok { + t.Fatal("missing rotation setting") + } + if err := row.Apply("vendor/b, vendor/a, vendor/b, , vendor/c"); err != nil { + t.Fatal(err) + } + if got := ChatModelRotationAt(dir); !reflect.DeepEqual(got, []string{"vendor/b", "vendor/a", "vendor/c"}) { + t.Fatalf("order = %v", got) + } + if got := ChatModelAt(dir); got != "vendor/current" { + t.Fatalf("setting rotation changed chat: %s", got) + } + if err := row.Apply(""); err != nil { + t.Fatal(err) + } + if got := ChatModelRotationAt(dir); len(got) != 0 { + t.Fatalf("cleared list = %v", got) + } +} diff --git a/internal/config/settings.go b/internal/config/settings.go index 232d69df86..c5e54b9abb 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -2420,6 +2420,13 @@ func (s *Settings) build() []Setting { read: func() string { return LivePinsAt(dir) }, write: func(raw string) error { return writeModelRoles(dir, raw) }, }, + Setting{ + Key: KeyChatModelRotation, Category: CategoryModels, Kind: SettingText, + Label: "quick switch models", EmptyLabel: "choose models", + Hint: "your chat model rotation, in click order. Choose models in settings, or write comma-separated model ids. The switch beside your chat model advances through this list; it does not change task models.", + read: func() string { return strings.Join(ChatModelRotationAt(dir), ", ") }, + write: func(raw string) error { return WriteChatModelRotation(dir, raw) }, + }, Setting{ Key: KeyModelFallbacks, Category: CategoryModels, Kind: SettingText, Label: "fallback models", EmptyLabel: "nearest in the catalog", diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 5de3eba558..2535d52217 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4293,3 +4293,26 @@ you moved the profile. Run `codeaf logs` to read recent calls, `codeaf logs --fo to follow them, or `codeaf logs --path` to print the exact path. Each call records its model, timing, token use and cost. Background process output is separate: open `/processes` in the conversation and choose a process to read its log. + +## How do I switch between my usual chat models without opening the model picker + +Click the exchange mark beside the chat model above your message box, or press +**f3**. It advances through **quick switch models** in Settings → Models, in the order you chose. The model name changes immediately; your draft, +its cursor and your conversation stay in place. Task models do not change. +For three or more saved models, the position (for example `1/3`) appears beside +the mark. The mark follows the Display icon setting: plain exchange arrows, a Nerd Font +exchange icon, or `&` in ASCII. Hover names the next model and the f3 shortcut. + +With fewer than two saved models the mark is hidden; f3 opens **quick switch +models** in settings. Enter opens the catalog there. Type to filter, then press enter to +add or remove a model. The **Click order:** line shows the saved rotation. +Selections save immediately; esc finishes choosing. Removing a model and adding +it again moves it to the end. Choosing the rotation does not change the model +of the current chat. The list survives restarting codeaf. + +A model selected outside the rotation is allowed: the next switch returns to +the first saved model. A switch uses the same live model change as `/model`; +when a response is running, it can redirect the next request in that response. +It does not promise to leave an in-flight response on its original model. +The model name itself still opens the full picker. The quick-switch control +belongs to a chat, not to home, settings, or a task room. diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a35fa80aca..49b33440a6 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1353,6 +1353,9 @@ type app struct { // now (foot.go's [app.seamIdentity]). modelSpan above is the room chip's // door on the status row; the two are never both drawn. seamModelSpan hudSpan + // The rotation is cached at launch and settings saves; rendering never reads disk. + modelRotation []string + seamModelCycleSpan hudSpan // seamProjectSpan is the current workspace path at the rule’s right edge. seamProjectSpan hudSpan // seamEffortSpan is the thinking rung's own columns on that same line, drawn @@ -3011,6 +3014,7 @@ func newApp(ctx context.Context, opts Options) *app { draftFile: opts.DraftFile, artifacts: opts.ArtifactsIndex, ctxWindow: opts.ContextWindow, + modelRotation: config.ChatModelRotationAt(opts.ProfileDir), profileDir: opts.ProfileDir, routing: config.RoutingAt(opts.ProfileDir), oneModel: opts.OneModel, @@ -4721,6 +4725,9 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { // AND THE MODEL'S NAME IS THE FOURTH, at the left end of the same // legend: the conversation's model is written on the seam and pressing // it opens the picker (foot.go's [app.legendModelPress]). + if a.legendModelCyclePress(msg.Mouse().X, msg.Mouse().Y) { + return a, nil + } if a.legendModelPress(msg.Mouse().X, msg.Mouse().Y) { return a, nil } diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index eeb3e7bd93..3e0df38387 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -1108,6 +1108,7 @@ func helpText(file string, chords chordSpelling) string { // every chat; the second needs a team with a manager, and the rows say so // rather than leaving a person to find out by pressing them elsewhere. helpKeyRow(chords.say(trafficKey), "show or hide the right column · Tasks or Traffic"), + helpKeyRow(modelCycleKey, "next quick switch chat model · choose the rotation in Settings → Models"), helpKeyRow(chords.say(teamManagerKey), "in a team with a manager: go to the manager"), helpKeyRow(reopenTabChord, "reopen the last closed tab · when the terminal sends this distinct chord"), helpKeyRow(chords.say(railHoldChord), "the task roster · ↑↓ move · ←→ tasks/traffic · enter opens · esc back"), diff --git a/internal/tui3/foot.go b/internal/tui3/foot.go index 49237ca7cc..5cc18d520a 100644 --- a/internal/tui3/foot.go +++ b/internal/tui3/foot.go @@ -491,6 +491,7 @@ func (t seamTry) on(tier seamTier) bool { // per frame so the ladder below is only ever choosing between them. type seamPieces struct { host, name, model, rung, gate, project string + exchange string // rider is the WHOLE rider — ` (relace)`, ` (parasail · rescued)`, or a // rescue's own ` · slow · trying coreweave…` — and fit is the door that // answers it for a given room, or says nothing ([app.modelRiderAt]). The @@ -523,7 +524,7 @@ func (a *app) seamPieces(width int) seamPieces { // AND A PINNED LANE RIDES THE ID AS `@lane` ([app.modelWord]), which is the // one place the pin is written on the chrome: the status row and the phone // deck take the same word from the same function. - pieces := seamPieces{host: a.host, model: a.modelWord(), + pieces := seamPieces{host: a.host, model: a.modelWord(), exchange: a.modelCycleSuffix(), project: a.seamProjectWord()} // AND IN A MANAGED TEAM THE SLOT SAYS WHERE THE WORDS GO, once there are // words: the empty box says it as its placeholder (teamrailpointer.go's @@ -633,7 +634,7 @@ func (p seamPieces) lay(try seamTry, room int) (string, hudSpan, hudSpan, hudSpa if try.name { name = p.name if try.cut { - rest := dotted(p.host, dotted(seamModelEffort(model+rider, rung), gate)) + rest := dotted(p.host, dotted(seamModelEffort(model+rider, rung)+p.exchange, gate)) left := room - ansi.StringWidth(rest) if rest != "" { left -= ansi.StringWidth(legendJoin) @@ -646,17 +647,25 @@ func (p seamPieces) lay(try seamTry, room int) (string, hudSpan, hudSpan, hudSpa } head := dotted(p.host, name) if try.rider == riderFit && model != "" { - bare := dotted(head, dotted(seamModelEffort(model, rung), gate)) + bare := dotted(head, dotted(seamModelEffort(model, rung)+p.exchange, gate)) if ansi.StringWidth(bare) > room { return "", none, none, none, false } rider = p.fit(room - ansi.StringWidth(bare)) } - cluster := dotted(head, dotted(seamModelEffort(model+rider, rung), gate)) + exchange := "" + if model != "" { + exchange = p.exchange + } + cluster := dotted(head, dotted(seamModelEffort(model+rider, rung)+exchange, gate)) if ansi.StringWidth(cluster) > room { return "", none, none, none, false } named, dial, chip := seamSpans(head, model, rider, rung, gate) + if chip.pressable() { + chip.from += ansi.StringWidth(exchange) + chip.to += ansi.StringWidth(exchange) + } return cluster, named, dial, chip, true } diff --git a/internal/tui3/hover.go b/internal/tui3/hover.go index d7668f064b..40e011a654 100644 --- a/internal/tui3/hover.go +++ b/internal/tui3/hover.go @@ -183,6 +183,8 @@ const ( // where the press would do nothing, the render records no span and this // answers nothing (room.go's [app.roomModelMovable]). hoverStatusModel + // hoverModelCycle is separate from the model picker door. + hoverModelCycle // hoverSeamProject is the project path on the message-box seam. hoverSeamProject // hoverEffort is the THINKING RUNG on the seam, the cell drawn immediately @@ -663,6 +665,9 @@ func (a *app) hoverTarget(x, y int) hoverAt { if !a.seamCarriesTelemetry() && a.seamProjectSpan.holds(x) { return hoverAt{kind: hoverSeamProject} } + if a.seamModelCycleSpan.holds(x) { + return hoverAt{kind: hoverModelCycle} + } if a.seamModelSpan.holds(x) { return hoverAt{kind: hoverStatusModel} } diff --git a/internal/tui3/input.go b/internal/tui3/input.go index ef20c7e79c..6f06bbaf85 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -698,6 +698,13 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return cmd } + // The rotation key is below every modal so it never changes a model while + // a person is answering a different question in a panel. + if msg.String() == modelCycleKey && a.page == pageNone && !a.roomOpen() && !a.linear { + a.cycleChatModel() + return nil + } + // The welcome box reads two keys and gives every other one back (welcome.go): // ↑/↓ walk the recent sessions, enter opens the one they picked, and // anything else is the person starting work, which puts the box away for diff --git a/internal/tui3/lanes_test.go b/internal/tui3/lanes_test.go index 814737b857..d85a5a89ee 100644 --- a/internal/tui3/lanes_test.go +++ b/internal/tui3/lanes_test.go @@ -695,9 +695,14 @@ func TestEnterOnALaneInTheSettingsPickerPins(t *testing.T) { } // The row and the model row's tail are two readings of one fact, and both // of them say so on the very next frame. - if !sheetHas(a, "pinned: Cloudflare") || !sheetHas(a, "· pinned: cloudflare") { - t.Fatalf("the panel did not re-read the pin it just wrote:\n%s", - strings.Join(sheetLabels(a), "\n")) + if !sheetHas(a, "· pinned: cloudflare") { + t.Fatal("the model row did not re-read the pin it just wrote") + } + // The quick-switch row adds a line above Advanced; walk to the lane row + // rather than assuming both readings fit in this terminal at once. + cursorTo(t, a, config.LaneSettingKey(talkSlot)) + if !sheetHas(a, "pinned: Cloudflare") { + t.Fatalf("the lane row did not re-read the pin it just wrote:\n%s", strings.Join(sheetLabels(a), "\n")) } // AND THE TAIL SAYS NOTHING ABOUT THE BASE WHILE THE BASE TAKES THE CHOICE. // The emptiness law: an unremarkable fact adds no words (issue #433). diff --git a/internal/tui3/modelrotation.go b/internal/tui3/modelrotation.go new file mode 100644 index 0000000000..b389ee9d6f --- /dev/null +++ b/internal/tui3/modelrotation.go @@ -0,0 +1,99 @@ +package tui3 + +import ( + "slices" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +const modelCycleKey = "f3" + +// nextRotationModel keeps the person's order stable, including when a picker +// selection temporarily takes the conversation outside that order. +func (a *app) nextRotationModel() string { + if len(a.modelRotation) < 2 { + return "" + } + at := slices.Index(a.modelRotation, a.model) + return a.modelRotation[(at+1)%len(a.modelRotation)] +} + +// modelCycleSuffix belongs only to the conversation; a task keeps its own door. +// Fewer than two models offer no exchange, so the button is absent. +func (a *app) modelCycleSuffix() string { + if a.page != pageNone || a.roomOpen() || a.linear || len(a.modelRotation) < 2 { + return "" + } + position := "" + if at := slices.Index(a.modelRotation, a.model); len(a.modelRotation) > 2 && at >= 0 { + position = strconv.Itoa(at+1) + "/" + strconv.Itoa(len(a.modelRotation)) + " " + } + return " " + position + a.icon(tokens.GActionTransfer) +} + +func (a *app) legendModelCyclePress(x, y int) bool { + if a.copy.on || a.pick.open || a.modelCycleSuffix() == "" { + return false + } + mark, ok := a.chromeAt(y) + if !ok || mark.kind != chromeLegend || !a.seamModelCycleSpan.holds(x) { + return false + } + a.cycleChatModel() + return true +} + +// toggleRotationModel writes on each explicit selection. Merely browsing never +// changes the current chat model, and removing then adding moves an entry last. +func (a *app) toggleRotationModel(sel *sheetSelect) { + model, ok := sel.choice() + if !ok { + return + } + row, ok := a.sheet.registry.Row(config.KeyChatModelRotation) + if !ok { + return + } + models := config.ChatModelRotationAt(a.profileDir) + if at := slices.Index(models, model); at >= 0 { + models = slices.Delete(models, at, at+1) + } else { + models = append(models, model) + } + meta, _ := settingMetaFor(row) + a.applySetting(sheetItem{row: row, meta: meta}, strings.Join(models, ", ")) +} + +// openModelRotationSettings takes an unconfigured switch straight to its row. +func (a *app) openModelRotationSettings() { + a.openSettings() + a.sheet.query.reset() + for i, tab := range settingTabs { + if tab == tabProviders { + a.sheet.tab = i + break + } + } + a.sheet.build() + for i, item := range a.sheet.items { + if item.row.Key == config.KeyChatModelRotation { + a.sheet.cursor = i + break + } + } +} + +func (a *app) cycleChatModel() { + if next := a.nextRotationModel(); next != "" { + if warning := a.nonChatWarning(next); warning != "" { + a.note(warning) + return + } + a.switchModel(next, a.windowFor(next)) + } else { + a.openModelRotationSettings() + } +} diff --git a/internal/tui3/modelrotation_test.go b/internal/tui3/modelrotation_test.go new file mode 100644 index 0000000000..7bf0ab2aec --- /dev/null +++ b/internal/tui3/modelrotation_test.go @@ -0,0 +1,170 @@ +package tui3 + +import ( + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +func rotationApp(t *testing.T) (*app, string) { + t.Helper() + a, dir := sheetApp(t) + a.modelRotation = []string{a.model, "vendor/second", "vendor/third"} + a.models = func() []Model { + return []Model{{ID: a.modelRotation[0], ContextLength: 128000}, {ID: "vendor/second", ContextLength: 256000}, {ID: "vendor/third", ContextLength: 64000}} + } + a.saveModel = func(id string) error { return config.WriteChatModel(dir, id) } + return a, dir +} + +func TestModelRotationClickChangesAgentWindowAndKeepsDraft(t *testing.T) { + a, dir := rotationApp(t) + a.input.setText("keep this draft") + a.input.cursor = 5 + for _, want := range []string{"vendor/second", "vendor/third", "openai/gpt-4.1-mini"} { + line := seamLine(t, a) + span := a.seamModelCycleSpan + if !span.pressable() || !strings.Contains(line, a.icon(tokens.GActionTransfer)) { + t.Fatalf("missing switch: %s", line) + } + if a.seamModelSpan.to > span.from || a.seamEffortSpan.to > span.from && a.seamEffortSpan.pressable() { + t.Fatal("doors overlap") + } + drive(t, a, tea.MouseClickMsg{X: span.to - 1, Y: seamRowY(a), Button: tea.MouseLeft}) + if a.model != want || a.agent.Model() != want || config.ChatModelAt(dir) != want { + t.Fatalf("surface/agent/saved = %s/%s/%s", a.model, a.agent.Model(), config.ChatModelAt(dir)) + } + if a.input.String() != "keep this draft" || a.input.cursor != 5 || a.pick.open { + t.Fatal("switch disturbed draft or opened picker") + } + if got := seamLine(t, a); !strings.Contains(got, want) { + t.Fatalf("stale TUI: %s", got) + } + } + a.switchModel("vendor/outside", 0) + drive(t, a, tea.KeyPressMsg{Code: tea.KeyF3}) + if a.model != a.modelRotation[0] { + t.Fatal("outside model did not return to first entry") + } +} + +func TestModelRotationSettingsChoosesOrderedModelsWithoutSwitchingChat(t *testing.T) { + a, dir := rotationApp(t) + a.openModelRotationSettings() + cursorTo(t, a, config.KeyChatModelRotation) + drive(t, a, key("enter")) + if a.sheet.sel == nil { + t.Fatal("rotation did not open catalog") + } + original := a.model + for _, id := range []string{"vendor/third", "vendor/second", "vendor/third", "vendor/third"} { + a.sheet.sel.pick.filter.setText(id) + a.sheet.sel.pick.rank() + drive(t, a, key("enter")) + if a.sheet.sel == nil { + t.Fatal("choice closed before rotation was ready") + } + } + want := []string{"vendor/second", "vendor/third"} + if got := config.ChatModelRotationAt(dir); !reflect.DeepEqual(got, want) { + t.Fatalf("saved = %v", got) + } + if !reflect.DeepEqual(a.modelRotation, want) || a.model != original { + t.Fatal("preferences not live or chat changed during setup") + } + drive(t, a, key("esc")) + a.closeSettings() + drive(t, a, tea.KeyPressMsg{Code: tea.KeyF3}) + if a.model != "vendor/second" { + t.Fatal("saved rotation not used") + } +} + +func TestModelRotationSetupFallbackAndGlyphWidths(t *testing.T) { + for _, tier := range []tokens.GlyphSet{tokens.Plain, tokens.NerdFont, tokens.ASCII} { + for _, width := range []int{44, 80, 120, 200} { + a, _ := rotationApp(t) + a.width = width + a.pal.icons = tier + a.pal.ascii = tier == tokens.ASCII + line := seamLine(t, a) + if len([]rune(line)) > width+5 { + t.Fatalf("switch overflow at %d: %s", width, line) + } + if a.seamModelCycleSpan.pressable() && !strings.Contains(line, "1/3") { + t.Fatalf("rotation position missing: %s", line) + } + a.modelRotation = nil + a.touch() + a.width = width + a.pal.icons = tier + a.pal.ascii = tier == tokens.ASCII + _ = seamLine(t, a) + span := a.seamModelCycleSpan + if span.pressable() { + t.Fatal("unconfigured rotation drew a switch") + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyF3}) + if !a.at(pageSettings) { + t.Fatal("F3 did not open setup") + } + a.page = pageSettings + if a.modelCycleSuffix() != "" { + t.Fatal("switch leaked into settings") + } + } + } +} + +func TestModelRotationClickReachesTheRealSessionHTTPBackend(t *testing.T) { + server := sourcestub.New("vendor/first", "vendor/second") + defer server.Close() + dir := t.TempDir() + source := testDefaultService("test-key-not-a-secret") + source.Source.Address, source.Address = server.URL(), server.URL() + sources := modelsource.NewSet(source) + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: "vendor/first", Sources: sources}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + a := modelServiceTestAppWithAgent(t, dir, agent.Model(), sources, []Model{{ID: "vendor/first", ContextLength: 128000}, {ID: "vendor/second", ContextLength: 256000}}, agent) + a.welcome = welcome{spent: true} + a.modelRotation = []string{"vendor/first", "vendor/second"} + drainModelServiceTurn(t, agent, "first") + _ = seamLine(t, a) + span := a.seamModelCycleSpan + drive(t, a, tea.MouseClickMsg{X: span.to - 1, Y: seamRowY(a), Button: tea.MouseLeft}) + drainModelServiceTurn(t, agent, "second") + calls := completionRequests(server) + if len(calls) != 2 || completionModel(t, calls[0]) != "vendor/first" || completionModel(t, calls[1]) != "vendor/second" { + t.Fatalf("HTTP backend calls: %+v", calls) + } + if a.ctxWindow != 256000 { + t.Fatalf("context window=%d", a.ctxWindow) + } + if got := seamLine(t, a); !strings.Contains(got, "vendor/second") { + t.Fatalf("TUI stale after backend switch: %s", got) + } +} + +func TestModelRotationNeverAppearsInATaskRoomOrChangesItsModel(t *testing.T) { + a, _ := roomModelApp(t, "vendor/task") + original := a.model + a.modelRotation = []string{original, "vendor/other"} + if a.modelCycleSuffix() != "" { + t.Fatal("chat rotation appeared in task room") + } + drive(t, a, tea.KeyPressMsg{Code: tea.KeyF3}) + if a.model != original || a.roomModelWord() != "vendor/task" { + t.Fatal("chat rotation changed a task room") + } +} diff --git a/internal/tui3/render.go b/internal/tui3/render.go index ce23bafd2d..3178f225c1 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3441,6 +3441,7 @@ func (a *app) legend(width int) string { // not cleared down there). a.homeDoor = hudSpan{} a.seamProjectSpan = hudSpan{} + a.seamModelCycleSpan = hudSpan{} a.seamModelSpan, a.seamEffortSpan, a.seamApprovalSpan = hudSpan{}, hudSpan{}, hudSpan{} a.doors = a.doors[:0] a.moneySpan, a.moneyRow = hudSpan{}, 0 @@ -3473,9 +3474,10 @@ func (a *app) legend(width int) string { // // IN A ROOM THE THREE SPANS ARE THE NODE'S DOORS (roomseam.go), recorded // on the same bargain and pressed through the same three functions. - seam, dial, gate := hudSpan{}, hudSpan{}, hudSpan{} + seam, dial, gate, cycle := hudSpan{}, hudSpan{}, hudSpan{}, hudSpan{} lift := func(text string) string { return paintSpans(text, paint, + spanLift{span: cycle, lift: a.pal.underline, on: a.hot.kind == hoverModelCycle}, spanLift{span: seam, lift: func(text string) string { return seamModelPaint(a.pal, text, a.hoveringStatusModel()) }, on: true}, @@ -3492,7 +3494,15 @@ func (a *app) legend(width int) string { if !legendFits(left, rung.width, width) { return "", false } + cycle = hudSpan{} + if suffix := a.modelCycleSuffix(); suffix != "" && span.pressable() { + if at := strings.Index(left, suffix); at >= 0 { + start := ansi.StringWidth(left[:at]) + cycle = hudSpan{from: start, to: start + ansi.StringWidth(suffix)} + } + } seam, dial, gate = span, dialSpan, gateSpan + a.seamModelCycleSpan = shiftIntoBorder(cycle) a.seamModelSpan, a.seamEffortSpan, a.seamApprovalSpan = shiftIntoBorder(span), shiftIntoBorder(dialSpan), shiftIntoBorder(gateSpan) right, painted := rung.plain, "" var ledger []hudPart @@ -3984,6 +3994,11 @@ const hopDoorWord = hopOpenKey + " chats" // this slot exists to prevent, and it was the default state of it. func (a *app) hintWord() string { switch { + case a.hot.kind == hoverModelCycle: + if next := a.nextRotationModel(); next != "" { + return "next model: " + next + " · " + modelCycleKey + } + return "choose quick switch models · " + modelCycleKey case a.released: // IT LEADS BECAUSE THE HANDOVER IS READ FIRST ([app.key] takes the pointer // back above every overlay), and because it is the one state on this slot diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index d9b9389243..6ef5774142 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -150,6 +150,10 @@ type settingMeta struct { // dollar figure and it lives under Session, because the question it answers is // "what may THIS conversation do". var settingUI = map[string]settingMeta{ + config.KeyChatModelRotation: { + tab: tabProviders, label: "quick switch models", widget: widgetSelect, + about: "choose your chat models in rotation order. Enter adds or removes a model; remove and add again to move it to the end. The switch beside your chat model uses this list.", + }, // ── Session ───────────────────────────────────────────────────────────── config.KeyToolApprovalMode: { tab: tabSafety, label: "ask before running", widget: widgetCycle, @@ -750,6 +754,7 @@ func modelsSectionOrder() []string { // for one answer and two rows that can disagree on screen. order := []string{ config.ModelSettingKey(talkSlot), + config.KeyChatModelRotation, config.LaneSettingKey(talkSlot), config.KeyLaneGuard, config.KeyRouting, @@ -3012,6 +3017,10 @@ func (a *app) sheetSelectKey(msg tea.KeyPressMsg) { // ENTER CONFIRMS AND CLOSES THE MODEL LIST, as it does in the conversation, // so the settings row immediately shows the model that was chosen. case "enter": + if sel.key == config.KeyChatModelRotation { + a.toggleRotationModel(sel) + return + } // ENTER INSIDE AN OPEN FOLD IS A LANE AND NOT A SLOT. if lane, onLane := sel.pick.laneUnder(); onLane { a.applyLaneFromSheet(sel, lane) @@ -3789,7 +3798,19 @@ func (s *sheet) selectLines(width, room int, pal palette, level func(string) str // add to its top: a row at [tierPhone] is two lines, and "line i is hit // top+i" would put every click one row further down the list than the one // that was pressed (palette.go's [picker.rowsOwned]). - out, owner := sel.pick.rowsOwned(width, room, pal, -1, level) + var prefix []string + if sel.key == config.KeyChatModelRotation { + row, _ := s.registry.Row(sel.key) + prefix = append(prefix, pal.dim(fit("Click order: "+row.Value(), width))) + if len(config.ParseModelFallbacks(rowText(row))) < 2 { + prefix = append(prefix, pal.dim(fit("Choose at least two models to enable quick switch.", width))) + } + } + out, owner := sel.pick.rowsOwned(width, max(0, room-len(prefix)), pal, -1, level) + if len(prefix) > 0 { + out = append(prefix, out...) + owner = append([]int{-1}, owner...) + } for len(out) < room { out = append(out, "") owner = append(owner, -1) @@ -3856,6 +3877,9 @@ func (s *sheet) keysLine() string { case s.edit != nil: return "enter save · esc cancel" case s.sel != nil: + if s.sel.key == config.KeyChatModelRotation { + return "↑↓ move · enter add/remove · esc done · type to filter" + } // THE LEGEND SAYS `→ providers` ONLY WHERE `→` OPENS THEM — on a row that // has a provider row behind it. Offering the key on the drawing slot would // be the foot of the screen promising a gesture that does nothing. diff --git a/internal/tui3/settings_live.go b/internal/tui3/settings_live.go index ae25593b69..595611ea9e 100644 --- a/internal/tui3/settings_live.go +++ b/internal/tui3/settings_live.go @@ -6,6 +6,7 @@ import "github.com/Agent-Field/codeaf/internal/config" // turn. Keep temporary layout choices (such as the task column) and the engine's // approval posture out: neither is a profile-only display preference. func (a *app) refreshProfileUI() { + a.modelRotation = config.ChatModelRotationAt(a.profileDir) a.mouse = config.MouseEnabledAt(a.profileDir) a.timestamps = config.TimestampsAt(a.profileDir) a.workMode = config.WorkAt(a.profileDir) From ce672ea3c6ce867e1cec07d05b4edac998952334 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:31:05 -0400 Subject: [PATCH 2/8] Keep model and effort emphasis while hovering the switch --- internal/tui3/render.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 3178f225c1..6d3c200b1b 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3477,11 +3477,11 @@ func (a *app) legend(width int) string { seam, dial, gate, cycle := hudSpan{}, hudSpan{}, hudSpan{}, hudSpan{} lift := func(text string) string { return paintSpans(text, paint, - spanLift{span: cycle, lift: a.pal.underline, on: a.hot.kind == hoverModelCycle}, spanLift{span: seam, lift: func(text string) string { return seamModelPaint(a.pal, text, a.hoveringStatusModel()) }, on: true}, spanLift{span: dial, lift: a.paintEffortChip, on: a.effortSeamLit()}, + spanLift{span: cycle, lift: a.pal.underline, on: a.hot.kind == hoverModelCycle}, spanLift{span: gate, lift: a.paintApprovalChip, on: a.approvalSeamLit()}) } // seat is one attempt: FIT IS DECIDED ON THE PLAIN WIDTHS BEFORE ANYTHING @@ -3531,6 +3531,7 @@ func (a *app) legend(width int) string { } line, at, ok := a.legendLinePainted(left, right, painted, width, lift) if !ok { + a.seamModelCycleSpan = hudSpan{} a.seamModelSpan, a.seamEffortSpan, a.seamApprovalSpan = hudSpan{}, hudSpan{}, hudSpan{} return "", false } From 97c31c8d4041ac0ae1fc90d48ac28a5a4be49ea2 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:32:52 -0400 Subject: [PATCH 3/8] Document quick switching and preserve manual search retrieval --- internal/manual/chat/adaptive-runs.md | 3 ++- internal/manual/chat/models-and-cost.md | 22 ------------------- internal/manual/chat/quick-switch-models.md | 24 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 internal/manual/chat/quick-switch-models.md diff --git a/internal/manual/chat/adaptive-runs.md b/internal/manual/chat/adaptive-runs.md index ea6cdfe967..6ca3d51fa1 100644 --- a/internal/manual/chat/adaptive-runs.md +++ b/internal/manual/chat/adaptive-runs.md @@ -1793,7 +1793,8 @@ did get done is still readable. ## Where did my run's rows go — the run disappeared from the task column when I switched away or reopened the conversation -**The rows come back. Both ways.** +**The rows come back. Both ways.** If your run's rows go missing after a switch, +return to that conversation; reopening it restores the run's rows too. **Switching away and coming back** — to home, to another conversation, and back again — redraws a live run whole: the run's own row, every node under it, and each one in the state diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 2535d52217..bc7cf01622 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4294,25 +4294,3 @@ to follow them, or `codeaf logs --path` to print the exact path. Each call recor its model, timing, token use and cost. Background process output is separate: open `/processes` in the conversation and choose a process to read its log. -## How do I switch between my usual chat models without opening the model picker - -Click the exchange mark beside the chat model above your message box, or press -**f3**. It advances through **quick switch models** in Settings → Models, in the order you chose. The model name changes immediately; your draft, -its cursor and your conversation stay in place. Task models do not change. -For three or more saved models, the position (for example `1/3`) appears beside -the mark. The mark follows the Display icon setting: plain exchange arrows, a Nerd Font -exchange icon, or `&` in ASCII. Hover names the next model and the f3 shortcut. - -With fewer than two saved models the mark is hidden; f3 opens **quick switch -models** in settings. Enter opens the catalog there. Type to filter, then press enter to -add or remove a model. The **Click order:** line shows the saved rotation. -Selections save immediately; esc finishes choosing. Removing a model and adding -it again moves it to the end. Choosing the rotation does not change the model -of the current chat. The list survives restarting codeaf. - -A model selected outside the rotation is allowed: the next switch returns to -the first saved model. A switch uses the same live model change as `/model`; -when a response is running, it can redirect the next request in that response. -It does not promise to leave an in-flight response on its original model. -The model name itself still opens the full picker. The quick-switch control -belongs to a chat, not to home, settings, or a task room. diff --git a/internal/manual/chat/quick-switch-models.md b/internal/manual/chat/quick-switch-models.md new file mode 100644 index 0000000000..0c3392687f --- /dev/null +++ b/internal/manual/chat/quick-switch-models.md @@ -0,0 +1,24 @@ +# Quick switch chat models + +## How do I switch between my usual chat models without opening the model picker + +Click the exchange mark beside the chat model above your message box, or press +**f3**. It advances through **quick switch models** in Settings → Models, in the order you chose. The model name changes immediately; your draft, +its cursor and your conversation stay in place. Task models do not change. +For three or more saved models, the position (for example `1/3`) appears beside +the mark. The mark follows the Display icon setting: plain exchange arrows, a Nerd Font +exchange icon, or `&` in ASCII. Hover names the next model and the f3 shortcut. + +With fewer than two saved models the mark is hidden; f3 opens **quick switch +models** in settings. Enter opens the catalog there. Type to filter, then press enter to +add or remove a model. The **Click order:** line shows the saved rotation. +Selections save immediately; esc finishes choosing. Removing a model and adding +it again moves it to the end. Choosing the rotation does not change the model +of the current chat. The list survives restarting codeaf. + +A model selected outside the rotation is allowed: the next switch returns to +the first saved model. A switch uses the same live model change as `/model`; +when a response is running, it can redirect the next request in that response. +It does not promise to leave an in-flight response on its original model. +The model name itself still opens the full picker. The quick-switch control +belongs to a chat, not to home, settings, or a task room. From 06a0609b663608f5bf4485bb30ad25705162b4aa Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:34:20 -0400 Subject: [PATCH 4/8] Respect page routing and keep setup pointer rows aligned --- internal/tui3/input.go | 2 +- internal/tui3/modelrotation.go | 2 +- internal/tui3/modelrotation_test.go | 21 +++++++++++++++++++++ internal/tui3/settings.go | 6 +++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 6f06bbaf85..260a727456 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -700,7 +700,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // The rotation key is below every modal so it never changes a model while // a person is answering a different question in a panel. - if msg.String() == modelCycleKey && a.page == pageNone && !a.roomOpen() && !a.linear { + if msg.String() == modelCycleKey && a.at(pageNone) && !a.roomOpen() && !a.linear { a.cycleChatModel() return nil } diff --git a/internal/tui3/modelrotation.go b/internal/tui3/modelrotation.go index b389ee9d6f..855b97fa5a 100644 --- a/internal/tui3/modelrotation.go +++ b/internal/tui3/modelrotation.go @@ -24,7 +24,7 @@ func (a *app) nextRotationModel() string { // modelCycleSuffix belongs only to the conversation; a task keeps its own door. // Fewer than two models offer no exchange, so the button is absent. func (a *app) modelCycleSuffix() string { - if a.page != pageNone || a.roomOpen() || a.linear || len(a.modelRotation) < 2 { + if !a.at(pageNone) || a.roomOpen() || a.linear || len(a.modelRotation) < 2 { return "" } position := "" diff --git a/internal/tui3/modelrotation_test.go b/internal/tui3/modelrotation_test.go index 7bf0ab2aec..71542d1f7f 100644 --- a/internal/tui3/modelrotation_test.go +++ b/internal/tui3/modelrotation_test.go @@ -168,3 +168,24 @@ func TestModelRotationNeverAppearsInATaskRoomOrChangesItsModel(t *testing.T) { t.Fatal("chat rotation changed a task room") } } + +func TestModelRotationSetupRowsKeepTheirPointerOwners(t *testing.T) { + a, _ := rotationApp(t) + a.openModelRotationSettings() + drive(t, a, key("enter")) + for _, saved := range []string{"", "vendor/second", "vendor/second, vendor/third"} { + row, _ := a.sheet.registry.Row(config.KeyChatModelRotation) + if err := row.Apply(saved); err != nil { + t.Fatal(err) + } + lines, owners := a.sheet.selectLines(90, 15, a.pal, a.reasoningFor) + if len(lines) != len(owners) || len(lines) != 15 { + t.Fatalf("lines=%d owners=%d", len(lines), len(owners)) + } + for i, line := range lines { + if strings.Contains(plain(line), "Choose at least two") && owners[i] != -1 { + t.Fatal("setup guidance became a selectable model") + } + } + } +} diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 6ef5774142..14111ea654 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -3809,7 +3809,11 @@ func (s *sheet) selectLines(width, room int, pal palette, level func(string) str out, owner := sel.pick.rowsOwned(width, max(0, room-len(prefix)), pal, -1, level) if len(prefix) > 0 { out = append(prefix, out...) - owner = append([]int{-1}, owner...) + prefixOwners := make([]int, len(prefix)) + for i := range prefixOwners { + prefixOwners[i] = -1 + } + owner = append(prefixOwners, owner...) } for len(out) < room { out = append(out, "") From bbdfcfd5deec620322d41887d8977773b0d41d48 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:35:51 -0400 Subject: [PATCH 5/8] Register the rotation preference and its live reader --- internal/config/derivation_test.go | 13 +++++++------ internal/config/testdata/profile-keys.ledger | 1 + 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 1b40f861c1..2bf5aebb9a 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -115,12 +115,13 @@ func TestEveryRowIsGroupedNamedAndReadable(t *testing.T) { // wave apiece — so what is named here is the thing the reader touches, not the // spelling of the key. var settingReaders = map[string]string{ - KeyWork: "workMode", - KeyIcons: "iconMode", - KeyDailyBudget: "DailyBudgetUSD", - KeyPlanConsent: "PlanConsentUSD", - KeyDocumentEngine: "DocumentEngine", - KeyVisionModel: "VisionModel", + KeyChatModelRotation: "ChatModelRotationAt", + KeyWork: "workMode", + KeyIcons: "iconMode", + KeyDailyBudget: "DailyBudgetUSD", + KeyPlanConsent: "PlanConsentUSD", + KeyDocumentEngine: "DocumentEngine", + KeyVisionModel: "VisionModel", // The model-name row names the one resolver every door turns it into a // name through: the leaf loops' doors hand [AssistedByModelAt]'s answer to // the attribution line. diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 25fb63b8aa..52c4df3a8a 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -57,6 +57,7 @@ models.crew.source models.crew.task_cap models.fallbacks models.pool.public_key +models.quick_switch models.roles models.tiers.high models.tiers.low From df018fbd80e68702bb00ab0a0614c9fa46acf18e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:36:13 -0400 Subject: [PATCH 6/8] Assert task model identity rather than its display label --- internal/tui3/modelrotation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tui3/modelrotation_test.go b/internal/tui3/modelrotation_test.go index 71542d1f7f..61247c78cd 100644 --- a/internal/tui3/modelrotation_test.go +++ b/internal/tui3/modelrotation_test.go @@ -164,7 +164,7 @@ func TestModelRotationNeverAppearsInATaskRoomOrChangesItsModel(t *testing.T) { t.Fatal("chat rotation appeared in task room") } drive(t, a, tea.KeyPressMsg{Code: tea.KeyF3}) - if a.model != original || a.roomModelWord() != "vendor/task" { + if a.model != original || a.tasks[9].model != "vendor/task" { t.Fatal("chat rotation changed a task room") } } From 5b013f832116130ee5d8b221111a5ee8087f7997 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:37:54 -0400 Subject: [PATCH 7/8] Keep the original model manual unchanged --- internal/manual/chat/models-and-cost.md | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bc7cf01622..5de3eba558 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4293,4 +4293,3 @@ you moved the profile. Run `codeaf logs` to read recent calls, `codeaf logs --fo to follow them, or `codeaf logs --path` to print the exact path. Each call records its model, timing, token use and cost. Background process output is separate: open `/processes` in the conversation and choose a process to read its log. - From e34387f627c66919dfa51b98eccbd528b70e0b06 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 22 Oct 2026 17:40:25 -0400 Subject: [PATCH 8/8] Associate the model rotation change entry with PR 1900 --- .../{1-chat-model-rotation.md => 1900-chat-model-rotation.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1-chat-model-rotation.md => 1900-chat-model-rotation.md} (98%) diff --git a/docs/changes/unreleased/1-chat-model-rotation.md b/docs/changes/unreleased/1900-chat-model-rotation.md similarity index 98% rename from docs/changes/unreleased/1-chat-model-rotation.md rename to docs/changes/unreleased/1900-chat-model-rotation.md index 1c2ce03597..a5c4732007 100644 --- a/docs/changes/unreleased/1-chat-model-rotation.md +++ b/docs/changes/unreleased/1900-chat-model-rotation.md @@ -1,7 +1,7 @@ --- kind: added title: switch between saved chat models directly beside the composer -pr: 1 +pr: 1900 surface: [chat] invalidates: - >-