Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/changes/unreleased/1900-chat-model-rotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
kind: added
title: switch between saved chat models directly beside the composer
pr: 1900
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.
---
7 changes: 7 additions & 0 deletions docs/design/icons/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 7 additions & 6 deletions internal/config/derivation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions internal/config/modelrotation.go
Original file line number Diff line number Diff line change
@@ -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), ", "))
}
33 changes: 33 additions & 0 deletions internal/config/modelrotation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 7 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions internal/config/testdata/profile-keys.ledger
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions internal/manual/chat/quick-switch-models.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions internal/tui3/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 13 additions & 4 deletions internal/tui3/foot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
}

Expand Down
5 changes: 5 additions & 0 deletions internal/tui3/hover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
}
Expand Down
7 changes: 7 additions & 0 deletions internal/tui3/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.at(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
Expand Down
11 changes: 8 additions & 3 deletions internal/tui3/lanes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
99 changes: 99 additions & 0 deletions internal/tui3/modelrotation.go
Original file line number Diff line number Diff line change
@@ -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.at(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()
}
}
Loading
Loading