diff --git a/docs/changes/unreleased/1073-crew-model-family.md b/docs/changes/unreleased/1073-crew-model-family.md new file mode 100644 index 0000000000..628fa6c6b2 --- /dev/null +++ b/docs/changes/unreleased/1073-crew-model-family.md @@ -0,0 +1,24 @@ +--- +kind: added +title: A model family toggle under the crew, so a preset can answer from open weights or from everything +pr: 1073 +surface: [chat, engine] +invalidates: + - "The three crew preset words (frugal, balanced, max) always resolved to open-weight models, and `crewModels` in `internal/config/crew.go` was the whole table of what they meant. There is now a second table `crewAllModels`, and `CrewModelsForSource(source, preset)` picks between them on the new `models.crew.source` setting (`open` default, `all` for closed and frontier). A profile that never answers the row still resolves the open table." + - "The careful seat was only ever required to be a different vendor from the worker by convention. It is now asserted by a test over both families: within each preset of `crewModels` and `crewAllModels`, the worker and high ids name different providers." + - "`models.crew` was the only crew setting. The crew row's derivation and `ApplyCrew` now also read `models.crew.source`, so the preset word and the five ids it summarizes can never be drawn from different families; flipping the family reads the crew as `custom` while the tier rows are written and match no preset in the new family; seats nobody pinned follow the family and keep the preset word." + - "The open table's frugal mastermind and its balanced careful and mastermind seats moved onto the roster chosen seat by seat, and the shipped default crew, which is exactly the balanced row, moved with it. Only the default moved; a profile that answered the crew keeps its rows." + - "A profile that applied the old `frugal` or the old `balanced` now matches no preset in the open table, because the roster moved: the crew word, the `/crew` highlight and the seat rung read `custom` until the preset is re-applied, which writes the new roster." + - "`CrewConfigured` counted only the tier rows, so a person who chose a family and never pinned a tier could still be handed a preset by the first-run setup. It now counts `models.crew.source` too: a chosen family is an answered crew." +--- + +The crew shipped open-weight models for a reason: a default nobody's pricing can +move under them. That reason still holds for the default, so the toggle defaults to +`open` and changes nothing for anyone who does not ask. It is for the person who +has decided the frontier models are worth it on a given machine, and wants the same +three preset words to mean them. + +The two families are a table apiece, not a filter over one, because the picks are +argued seat by seat rather than chosen by a rule about vendors or price. The all +table is new and mirrors the open one's three presets; the open table moved onto the +roster this work chose, which is the one edit here that changes a shipped default. diff --git a/internal/config/crew.go b/internal/config/crew.go index cbf7d5c9f9..0e9ed04c9b 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -19,9 +19,20 @@ import ( // THREE PRESETS AND NO MORE. A fourth would be a fourth thing to explain, and // the axis they move along has exactly three interesting points: everything // cheap, one model thinking over cheap ones working, everything capable and -// asked to think. Every model in every preset is open-source, which is the -// property that makes a shipped default defensible — nobody's default crew -// should be a bet on one vendor's pricing. +// asked to think. +// +// TWO FAMILIES BEHIND THE SAME THREE WORDS. Every preset exists twice: once in +// the open family, every seat an open-weight model, which is the property that +// makes a shipped default defensible, because nobody's default crew should be +// a bet on one vendor's pricing; and once in the all family, the same three +// words resolved over the whole catalog with closed and frontier models in it, +// for the person who has chosen to spend what those cost. Which family the +// words draw from is one row, [KeyCrewSource], and open is what it reads when +// nobody has answered it: the all family is a thing a person asks for, never +// one they get by saying nothing. Both the derived reading and the write +// resolve through the row ([CrewSourceAt]), so the word on the sheet can never +// mean one family while the five values it summarizes were drawn from the +// other. // // THE PRESET IS DERIVED AND NEVER STORED. [CrewAt] reads the five live tier // values and answers which preset they are, or "custom". A stored word would be @@ -56,6 +67,24 @@ var CrewPresets = []string{CrewFrugal, CrewBalanced, CrewMax} // that identity is asserted by a test rather than trusted. const DefaultCrew = CrewBalanced +// The two families the preset words can draw from. They are the values +// [KeyCrewSource] takes, spelled here once and read by the row, the resolver +// and the manual. +const ( + // CrewSourceOpen is the open-weight family and the shipped default. + CrewSourceOpen = "open" + // CrewSourceAll is the whole catalog, closed and frontier models included. + CrewSourceAll = "all" +) + +// CrewSources lists them, open first, which is also the default and the order +// the row widens in. +var CrewSources = []string{CrewSourceOpen, CrewSourceAll} + +// DefaultCrewSource is open, for the same reason the shipped crew is open +// weights: a family nobody chose is the one this build can defend. +const DefaultCrewSource = CrewSourceOpen + // crewModels is the whole table: one row per preset, one model per class. // // THE WORKER COLUMN IS THE DIAL. It climbs the open-weight pareto front one step @@ -70,23 +99,26 @@ const DefaultCrew = CrewBalanced // a dial. // // Every id is an open-weight model, picked off the catalog's own published -// scores against blended price on 2026-09-01 (settings.go's DefaultWorkerModel -// says how); the closed models that are cheaper on their own vendor's platform -// than through the router are deliberately not here. +// scores against blended price: 2026-09-01 for the roster as a whole, with the +// frugal mastermind and the balanced careful and mastermind re-picked on +// 2026-09-16 (the manual's open table lists every seat; settings.go's +// DefaultWorkerModel says how). No closed model is here: the open family is the +// shelf that has to stand on price alone, and the `all` family is where a closed +// model goes. var crewModels = map[string]map[string]string{ CrewFrugal: { ModelTierReflex: "mistralai/mistral-nemo", ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "deepseek/deepseek-v4-flash-0731", ModelTierHigh: "z-ai/glm-5.3-flash", - ModelTierMastermind: "z-ai/glm-5.3-flash", + ModelTierMastermind: "z-ai/glm-5.3", }, CrewBalanced: { ModelTierReflex: "mistralai/mistral-nemo", ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "z-ai/glm-5.3-flash", - ModelTierHigh: "qwen/qwen3.8-27b", - ModelTierMastermind: "z-ai/glm-5.3", + ModelTierHigh: "moonshotai/kimi-k3", + ModelTierMastermind: "moonshotai/kimi-k3", }, CrewMax: { ModelTierReflex: "mistralai/mistral-nemo", @@ -97,24 +129,97 @@ var crewModels = map[string]map[string]string{ }, } -// crewLines is the one line each preset says about itself. It is what /crew -// prints beside each option and what the settings chooser shows under it — the -// same words in both places, because they are one sentence about one thing. -var crewLines = map[string]string{ - CrewFrugal: "deepseek works, glm-flash thinks · pennies a day", - CrewBalanced: "glm-flash works, glm-5.3 thinks, qwen checks", - CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", +// crewAllModels is the same three presets answered from the whole catalog +// rather than its open-weight shelf, which is what the `all` family of +// [KeyCrewSource] draws from. The careful column is the same law here as +// there: a DIFFERENT VENDOR from the worker in every preset, and the reflex +// and low columns still never vary. The ids are locked the way the open ones +// are, off the catalog's own published scores against blended price, and +// closed models live here and only here. +// +// TWO COLUMNS SPEND DIFFERENTLY HERE. The worker column is [crewModels]'s dial +// but moves once, not per preset: gpt-5.6-sol holds frugal and balanced, and +// only max buys claude-fable-5.1, because on the catalog the coding ceiling is +// the expensive step and the two steps below it are the same model. What +// frugal-to-balanced buys here is the careful seat (gemini-3.8-flash to +// claude-opus-5) and a smarter mastermind, not a dearer worker. And max's +// mastermind sits on the worker's own model on purpose: fable-5.1 is the +// catalog's ceiling, so there is no better planner to buy above it. +var crewAllModels = map[string]map[string]string{ + CrewFrugal: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "google/gemini-3.8-flash", + ModelTierMastermind: "anthropic/claude-opus-5", + }, + CrewBalanced: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "anthropic/claude-opus-5", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + CrewMax: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "anthropic/claude-fable-5.1", + ModelTierHigh: "openai/gpt-6-astra", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, } -// CrewLine is one preset's own line, empty for a word that is not a preset. -func CrewLine(preset string) string { - return crewLines[strings.ToLower(strings.TrimSpace(preset))] +// crewTableFor is the family one source word names. It is total: `all` names +// the catalog-wide family, and every other reading (blank, misspelt, a word a +// later build retired) names the open one, because a family nobody asked for +// is the one this build can defend. +func crewTableFor(source string) map[string]map[string]string { + if normalCrewSource(source) == CrewSourceAll { + return crewAllModels + } + return crewModels } -// CrewModels is the five models one preset would set, by tier word. It returns -// a copy, because a caller printing the table must not be able to edit it. -func CrewModels(preset string) (map[string]string, bool) { - row, ok := crewModels[strings.ToLower(strings.TrimSpace(preset))] +// knownCrewSource folds a word and says whether it is one of the families this +// build knows. It is the ONE place the fold is spelled: [normalCrewSource], +// [ApplyCrewUnder] and [SetCrewSource] all go through it, so a family added to +// [CrewSources] is accepted everywhere at once. A reader folds a word it does +// not know to the default family; a writer refuses it. +func knownCrewSource(source string) (string, bool) { + source = strings.ToLower(strings.TrimSpace(source)) + for _, known := range CrewSources { + if source == known { + return known, true + } + } + return "", false +} + +// normalCrewSource folds a source word to one of the two this build knows, +// reading a word it does not know as the default family. +func normalCrewSource(source string) string { + if known, ok := knownCrewSource(source); ok { + return known + } + return DefaultCrewSource +} + +// CrewSourceAt is which family the preset words draw from on this profile, +// [DefaultCrewSource] when the row is absent. A word this build does not know +// reads as open, silently, the way a retired choice reads everywhere else on +// this sheet. +func CrewSourceAt(profileDir string) string { + if value, ok := persistedString(profileDir, KeyCrewSource); ok { + return normalCrewSource(value) + } + return DefaultCrewSource +} + +// CrewModelsForSource is the five models one preset would set under one +// family, by tier word, false for a word that is not a preset. It returns a +// copy for [CrewModels]'s reason. +func CrewModelsForSource(source, preset string) (map[string]string, bool) { + row, ok := crewTableFor(source)[strings.ToLower(strings.TrimSpace(preset))] if !ok { return nil, false } @@ -125,6 +230,46 @@ func CrewModels(preset string) (map[string]string, bool) { return out, true } +// crewLines is the one line each preset says about itself, IN THE FAMILY IT +// DRAWS FROM. It is what /crew prints beside each option and what the settings +// chooser shows under it, so it must name the models the preset actually picks +// in the family on screen: a line naming open models above frontier ids is the +// contradiction the chooser exists to prevent. +var crewLines = map[string]map[string]string{ + CrewSourceOpen: { + CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", + CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", + CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", + }, + CrewSourceAll: { + CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", + CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", + CrewMax: "fable works and thinks, astra checks", + }, +} + +// CrewLine is one preset's own line in the OPEN family, empty for a word that is +// not a preset. The family-aware spelling is [CrewLineFor]. +func CrewLine(preset string) string { + return CrewLineFor(CrewSourceOpen, preset) +} + +// CrewLineFor is one preset's own line in one family, empty for a word that is +// not a preset. A family this build does not know reads as open, the way the +// row does. +func CrewLineFor(source, preset string) string { + return crewLines[normalCrewSource(source)][strings.ToLower(strings.TrimSpace(preset))] +} + +// CrewModels is the five models one preset would set in the OPEN family, by +// tier word: the family a profile nobody has touched reads, and the spelling +// the callers hold. The family-aware spelling is +// [CrewModelsForSource]. It returns a copy, because a caller printing the +// table must not be able to edit it. +func CrewModels(preset string) (map[string]string, bool) { + return CrewModelsForSource(CrewSourceOpen, preset) +} + // CrewAt is the crew as the five live tier values make it: the preset they are, // or [CrewCustom]. // @@ -134,13 +279,20 @@ func CrewModels(preset string) (map[string]string, bool) { // person cleared on purpose reads empty, matches no preset, and turns the answer // to custom, which is the truth: "one of these follows the conversation" is not // any of the three. +// +// The comparison runs against the family [CrewSourceAt] names, so the reading +// moves with the row and never behind it: flip the family and a crew the old +// family wrote matches nothing, which reads as custom and is true, because +// five open ids are not any all-family preset either. func CrewAt(profileDir string) string { + family := CrewSourceAt(profileDir) live := make(map[string]string, len(ModelTiers)) for _, tier := range ModelTiers { - live[tier] = TierModelAt(profileDir, tier) + live[tier] = tierSeatUnder(profileDir, family, tier).Model } + table := crewTableFor(family) for _, preset := range CrewPresets { - if sameCrew(live, crewModels[preset]) { + if sameCrew(live, table[preset]) { return preset } } @@ -165,16 +317,52 @@ func sameCrew(live, preset map[string]string) bool { // old crew and two to the new, and the crew row would read "custom" about a // state nobody chose. It is also the only shape in which a reader that happens // to be resolving a role while somebody presses enter cannot see half a crew. +// +// The preset is resolved under the family [CrewSourceAt] names, so the row and +// the write cannot disagree about which table the word means: flip to `all`, +// press the crew again, and the five ids that land are the all-family ones. func ApplyCrew(profileDir, preset string) error { + // An empty family means KEEP THE ONE THE PROFILE HOLDS, so this path reads the + // profile once inside ApplyCrewUnder rather than once here and again there, and + // the write is exactly the five tiers. One resolve-and-write serves both callers + // rather than two that can drift apart. + return ApplyCrewUnder(profileDir, "", preset) +} + +// ApplyCrewUnder writes a FAMILY AND A PRESET AS ONE DECISION, IN ONE FILE +// WRITE. The family row and the five tier rows are one state, so writing them +// apart leaves a window in which a reader sees the family set to `all` while the +// rows still hold open ids, which is the half-written crew [ApplyCrew] forbids, read as +// `custom` about a state nobody chose. +// +// The family row rides along ONLY WHEN IT CHANGES, so an enter that keeps the +// family does not pin a setting the person never answered: the five tier rows are +// written and the family row stays unanswered, free to follow a later default +// family. +func ApplyCrewUnder(profileDir, source, preset string) error { + // An empty source keeps the family the profile holds, which is how [ApplyCrew] + // asks for the five tiers alone. The profile is read ONCE here, so there is no + // window between a check and the write in which the family could move. + persisted := CrewSourceAt(profileDir) + if strings.TrimSpace(source) == "" { + source = persisted + } + known, ok := knownCrewSource(source) + if !ok { + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + } preset = strings.ToLower(strings.TrimSpace(preset)) - models, ok := crewModels[preset] + models, ok := CrewModelsForSource(known, preset) if !ok { return fmt.Errorf("pick one of: %s", strings.Join(CrewPresets, ", ")) } - values := make(map[string]any, len(models)) + values := make(map[string]any, len(models)+1) for tier, model := range models { values[tierKeyFor(tier)] = model } + if known != persisted { + values[KeyCrewSource] = known + } return writeProfileValues(profileDir, values) } @@ -184,9 +372,21 @@ func writeCrew(profileDir, raw string) error { return ApplyCrew(profileDir, raw) } +// SetCrewSource writes the family row ALONE, in one file write. The settings +// row is its caller; the chooser commits the family and the preset together +// through [ApplyCrewUnder]. The word is refused the way every choice row refuses +// one, so a typo cannot land a family nothing reads. +func SetCrewSource(profileDir, source string) error { + known, ok := knownCrewSource(source) + if !ok { + return fmt.Errorf("pick one of: %s", strings.Join(CrewSources, ", ")) + } + return writeProfileValue(profileDir, KeyCrewSource, known) +} + // CrewSummary is the one line a crew change confirms itself with: // -// crew → balanced · brain glm-5.3 · hands glm-5.3-flash · checks qwen3.8-27b +// crew → balanced · brain kimi-k3 · hands glm-5.3-flash · checks kimi-k3 // // The three names are the classes a person actually asked about — what thinks, // what works, what checks — and HANDS IS THE WORKER: the seat that does the @@ -201,7 +401,7 @@ func CrewSummary(profileDir string) string { // CrewClasses is the three class names alone: // -// brain glm-5.3 · hands glm-5.3-flash · checks qwen3.8-27b +// brain kimi-k3 · hands glm-5.3-flash · checks kimi-k3 // // It is the tail of [CrewSummary] lifted out because a second surface prints the // crew now — /status, where the word already has a label of its own and "crew →" @@ -217,7 +417,7 @@ func CrewClasses(profileDir string) string { // CrewClassModels is the three ids [CrewClasses] names, in that order and // without the role words in front of them: // -// glm-5.3, glm-5.3-flash, qwen3.8-27b +// kimi-k3, glm-5.3-flash, kimi-k3 // // It exists because a surface drawing the crew line has to be able to say which // runs of it are the ANSWER — the ids a person typed /crew to change — and which diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index b5637ce56b..017a406845 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -45,14 +45,14 @@ func TestCrewPresetsNameTheApprovedModels(t *testing.T) { ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "deepseek/deepseek-v4-flash-0731", ModelTierHigh: "z-ai/glm-5.3-flash", - ModelTierMastermind: "z-ai/glm-5.3-flash", + ModelTierMastermind: "z-ai/glm-5.3", }, CrewBalanced: { ModelTierReflex: "mistralai/mistral-nemo", ModelTierLow: "deepseek/deepseek-v4-flash-0731", ModelTierWorker: "z-ai/glm-5.3-flash", - ModelTierHigh: "qwen/qwen3.8-27b", - ModelTierMastermind: "z-ai/glm-5.3", + ModelTierHigh: "moonshotai/kimi-k3", + ModelTierMastermind: "moonshotai/kimi-k3", }, CrewMax: { ModelTierReflex: "mistralai/mistral-nemo", @@ -211,6 +211,191 @@ func TestTheCrewRowRefusesAWordThatIsNotAPreset(t *testing.T) { } } +// ── the family the three words draw from ──────────────────────────────────── + +// THE CAREFUL SEAT IS ALWAYS A SECOND VENDOR, IN EVERY PRESET OF EVERY FAMILY. +// A check from the same vendor as the work it checks is a check that shares +// that vendor's blind spots, and the two tables are one law: a preset that +// moved to the all family and put the same vendor on worker and careful would +// break the property the open crew was built on while pretending to keep it. +func TestTheCarefulSeatIsAlwaysASecondVendorInEveryFamily(t *testing.T) { + vendor := func(id string) string { + if at := strings.Index(id, "/"); at > 0 { + return id[:at] + } + return id + } + for _, family := range CrewSources { + for _, preset := range CrewPresets { + models, ok := CrewModelsForSource(family, preset) + if !ok { + t.Fatalf("there is no %s preset in the %s family", preset, family) + } + if vendor(models[ModelTierWorker]) == vendor(models[ModelTierHigh]) { + t.Errorf("%s under %s has %q working and %q checking, and a check from "+ + "the same vendor catches what that vendor lets through", + preset, family, models[ModelTierWorker], models[ModelTierHigh]) + } + } + } +} + +// THE ALL FAMILY IS LOCKED, not derived: this is the page that says which ids +// the three words mean. A wrong id here is a person's bill, and a silent +// rewrite of the locked table would be a decision made by a diff. +func TestTheAllFamilyNamesTheLockedModels(t *testing.T) { + want := map[string]map[string]string{ + CrewFrugal: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "google/gemini-3.8-flash", + ModelTierMastermind: "anthropic/claude-opus-5", + }, + CrewBalanced: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "openai/gpt-5.6-sol", + ModelTierHigh: "anthropic/claude-opus-5", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + CrewMax: { + ModelTierReflex: "google/gemini-2.5-flash", + ModelTierLow: "deepseek/deepseek-v4-flash-0731", + ModelTierWorker: "anthropic/claude-fable-5.1", + ModelTierHigh: "openai/gpt-6-astra", + ModelTierMastermind: "anthropic/claude-fable-5.1", + }, + } + for preset, expected := range want { + got, ok := CrewModelsForSource(CrewSourceAll, preset) + if !ok { + t.Fatalf("there is no %s preset in the all family", preset) + } + for _, tier := range ModelTiers { + if got[tier] != expected[tier] { + t.Errorf("%s under all sets %s to %q, want %q", preset, tier, got[tier], expected[tier]) + } + if !strings.Contains(got[tier], "/") { + t.Errorf("%s under all sets %s to %q, which is not a provider-qualified id", preset, tier, got[tier]) + } + if err := ValidateTierValue(got[tier]); err != nil { + t.Errorf("%s under all sets %s to %q, which its own row would refuse: %v", preset, tier, got[tier], err) + } + } + } + // THE LINES ARE PART OF THE TABLE, and they are the words a person reads while + // deciding to spend frontier money: a line naming a model the preset does not + // pick is the contradiction the chooser exists to prevent. + wantLines := map[string]map[string]string{ + CrewSourceOpen: { + CrewFrugal: "deepseek works, glm-5.3 thinks · pennies a day", + CrewBalanced: "glm-flash works, kimi-k3 checks and thinks", + CrewMax: "glm-5.3 works, kimi-k3 thinks and checks", + }, + CrewSourceAll: { + CrewFrugal: "gpt-5.6-sol works, gemini-flash checks, opus thinks", + CrewBalanced: "gpt-5.6-sol works, opus checks, fable thinks", + CrewMax: "fable works and thinks, astra checks", + }, + } + for family, lines := range wantLines { + for preset, line := range lines { + if got := CrewLineFor(family, preset); got != line { + t.Errorf("the %s family's %s line is %q, want %q", family, preset, got, line) + } + } + } +} + +// A SOURCE NOBODY SET IS THE OPEN FAMILY. The all table may exist, but a profile +// that never answered the row resolves the open family: the shipped defaults, +// the open presets under the same words, and a write that lands the open ids. +// This is the default-preservation law for the FAMILY. An unknown word is held +// to the same law, silently, the way a retired choice reads everywhere else. +func TestASourceNobodyAnsweredReadsTheOpenFamily(t *testing.T) { + dir := t.TempDir() + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("an untouched profile reads the source as %q, want %q", got, DefaultCrewSource) + } + if got := CrewAt(dir); got != DefaultCrew { + t.Fatalf("an untouched profile reads the crew as %q, want %q", got, DefaultCrew) + } + if err := ApplyCrew(dir, CrewMax); err != nil { + t.Fatal(err) + } + want, _ := CrewModels(CrewMax) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != want[tier] { + t.Errorf("with no source set, max wrote %s to %q, want the open %q", tier, got, want[tier]) + } + } + // A WORD THIS BUILD DOES NOT KNOW READS AS OPEN, silently. Being told a + // preference from an older build is now an error is the one thing a + // retirement must never do, and a family is not a thing to guess either. + if err := writeProfileValue(dir, KeyCrewSource, "wide"); err != nil { + t.Fatal(err) + } + if got := CrewSourceAt(dir); got != CrewSourceOpen { + t.Fatalf("an unknown source word reads as %q, want %q", got, CrewSourceOpen) + } + if got := CrewAt(dir); got != CrewMax { + t.Fatalf("an unknown source word moved the crew to %q", got) + } +} + +// FLIPPING TO ALL MAKES THE SAME WORDS MEAN THE ALL FAMILY. Seats nobody pinned +// move at once, because an unwritten seat is the default crew resolved in the +// current family; the five ids already written are theirs, so a flip is a +// meaning change to them rather than a write, and they read custom against the +// new family until one keystroke re-applies them. +func TestFlippingToAllMakesTheWordsMeanTheAllFamily(t *testing.T) { + dir := t.TempDir() + rows := registry(t, dir) + if err := mustRow(t, rows, KeyCrewSource).Apply(CrewSourceAll); err != nil { + t.Fatalf("setting the family to all: %v", err) + } + if got := CrewSourceAt(dir); got != CrewSourceAll { + t.Fatalf("the source row reads %q after all was set", got) + } + // The crew row never writes the source: a stored tier id is not a family claim, + // and the family is not stored in the crew row either. + if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { + t.Fatalf("setting the crew to balanced: %v", err) + } + want, _ := CrewModelsForSource(CrewSourceAll, CrewBalanced) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != want[tier] { + t.Errorf("under all, balanced wrote %s to %q, want the all-family %q", tier, got, want[tier]) + } + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("the crew reads %q after balanced was applied under all", got) + } + + // And the same five ids are NOT balanced in the open family, which is the + // point of the toggle: the words changed family, and the reading follows. + if err := mustRow(t, registry(t, dir), KeyCrewSource).Apply(CrewSourceOpen); err != nil { + t.Fatal(err) + } + if got := CrewAt(dir); got != CrewCustom { + t.Fatalf("after flipping back to open, the all-family five read %q, want %q", got, CrewCustom) + } + // One keystroke heals it, and heals it open. + if err := mustRow(t, registry(t, dir), KeyCrew).Apply(CrewBalanced); err != nil { + t.Fatal(err) + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("the crew reads %q after balanced was applied under open again", got) + } + open, _ := CrewModels(CrewBalanced) + for _, tier := range ModelTiers { + if got := TierModelAt(dir, tier); got != open[tier] { + t.Errorf("after re-applying under open, %s reads %q, want the open %q", tier, got, open[tier]) + } + } +} + // ── the level on a tier value ─────────────────────────────────────────────── // A LEVEL IS ACCEPTED AND ANY OTHER SUFFIX IS REFUSED IN WORDS. @@ -303,3 +488,41 @@ func TestEverySettingsWriteBumpsTheGeneration(t *testing.T) { t.Fatalf("a refused write moved the generation to %d", got) } } + +// THE FAMILY ROW REFUSES A WORD THAT IS NOT A FAMILY, the way the crew row +// refuses a word that is not a preset, and a refused write leaves the row absent +// rather than half-written. +func TestSetCrewSourceRefusesAWordThatIsNotAFamily(t *testing.T) { + dir := t.TempDir() + if err := SetCrewSource(dir, "wide"); err == nil { + t.Fatal("SetCrewSource accepted a word that is not a family") + } + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("a refused write left the row reading %q", got) + } + if CrewConfigured(dir) { + t.Fatal("a refused write marked the crew as answered") + } +} + +// AN UNWRITTEN SEAT FOLLOWS THE FAMILY, which is what makes the hint true: a +// profile on the all family with no rows runs the frontier crew without anyone +// picking it, and the crew word already reads the default preset. +func TestAnUnwrittenSeatFollowsTheFamily(t *testing.T) { + dir := t.TempDir() + if err := SetCrewSource(dir, CrewSourceAll); err != nil { + t.Fatal(err) + } + want, ok := CrewModelsForSource(CrewSourceAll, DefaultCrew) + if !ok { + t.Fatal("there is no default preset in the all family") + } + for tier, model := range want { + if got := TierModelAt(dir, tier); got != model { + t.Fatalf("%s on an all profile with no row is %q, want the all default %q", tier, got, model) + } + } + if got := CrewAt(dir); got != CrewBalanced { + t.Fatalf("an all profile with no rows reads the crew as %q, want %q", got, CrewBalanced) + } +} diff --git a/internal/config/derivation_test.go b/internal/config/derivation_test.go index 8f1e3c9268..b7b679afda 100644 --- a/internal/config/derivation_test.go +++ b/internal/config/derivation_test.go @@ -250,6 +250,12 @@ var settingReaders = map[string]string{ // rather than the writer, because a row that could be set and never read // would be exactly the dial-wired-to-nothing this table exists to catch. KeyCrew: "CrewAt", + // The source row's own reader is CrewSourceAt, and the picker reaches it by + // that name (internal/tui3's crew.go), so the row names its reader rather + // than the crew derivation one hop away. The crew's writer resolves through + // the same reader, so the row and the five ids it produces cannot disagree + // about which family the preset words mean. + KeyCrewSource: "CrewSourceAt", // The reflex row names the ROLE that reaches it rather than TierKey, which // its two neighbours share: internal/reflex resolves [roles.RoleReflex] and // nothing else lands on this key, so naming the shared spelling a third diff --git a/internal/config/firstrun.go b/internal/config/firstrun.go index 34768619c1..bb96fa8cec 100644 --- a/internal/config/firstrun.go +++ b/internal/config/firstrun.go @@ -61,10 +61,14 @@ func APIKeyConfigured(profileDir string) bool { } // CrewConfigured is whether a person has ever answered the crew: any of the -// four tier rows is in the profile file. It is any rather than all because -// [ApplyCrew] writes the four together, and a person who pinned one tier by hand -// has an opinion the setup must not paper over with a preset. +// tier rows, or the family row above them, is in the profile file. It is any +// rather than all because [ApplyCrew] writes the tier rows together, and a +// person who pinned one tier by hand, or chose a family, has an opinion the +// setup must not paper over with a preset. func CrewConfigured(profileDir string) bool { + if _, ok := persistedString(profileDir, KeyCrewSource); ok { + return true + } for _, tier := range ModelTiers { if _, ok := persistedString(profileDir, tierKeyFor(tier)); ok { return true diff --git a/internal/config/firstrun_test.go b/internal/config/firstrun_test.go index 9233566be1..c8b8c749ed 100644 --- a/internal/config/firstrun_test.go +++ b/internal/config/firstrun_test.go @@ -148,3 +148,26 @@ func TestTheAPIKeyRowMasksReadsTheShellFirstAndWritesTheProfile(t *testing.T) { t.Fatal("a pinned row must refuse a write") } } + +// THE FAMILY IS AN OPINION TOO. A person who chose a family and never pinned a +// tier has still answered the crew, so the setup must not paper over it with a +// preset: the law the tier rows already carry, extended to the row above them. +// And a family nobody chose is the default, which is NOT an answer. +func TestTheFamilyRowCountsAsAnAnsweredCrew(t *testing.T) { + dir := t.TempDir() + if CrewConfigured(dir) { + t.Fatal("a profile nobody has touched must read as unanswered") + } + if got := CrewSourceAt(dir); got != DefaultCrewSource { + t.Fatalf("an untouched profile reads the family as %q, want the default", got) + } + if err := SetCrewSource(dir, CrewSourceAll); err != nil { + t.Fatal(err) + } + if !CrewConfigured(dir) { + t.Fatal("a profile whose family was chosen must read as answered") + } + if got := CrewSourceAt(dir); got != CrewSourceAll { + t.Fatalf("the family row reads %q after it was written", got) + } +} diff --git a/internal/config/seats.go b/internal/config/seats.go index 96ab4ed431..71b0e5a90a 100644 --- a/internal/config/seats.go +++ b/internal/config/seats.go @@ -229,11 +229,19 @@ func crewRow(profileDir, tier string) (model, from string, source SeatSource, cl // from the ladder — five extra file reads per row, and a cycle. A surface that // wants both facts asks for both. func TierSeatAt(profileDir, tier string) Seat { + return tierSeatUnder(profileDir, CrewSourceAt(profileDir), tier) +} + +// tierSeatUnder is [TierSeatAt] with the family already read, so a caller that +// walks every tier ([CrewAt]) reads the profile ONCE for the family instead of +// once per unwritten seat, and the five seats cannot be resolved under one +// family while the preset table is resolved under another. +func tierSeatUnder(profileDir, family, tier string) Seat { model, from, source, cleared := crewRow(profileDir, tier) if source == "" { source = SeatDefault if !cleared { - model = defaultTierModel(tier) + model = defaultTierModel(family, tier) } } return Seat{Role: tierSeatRole(tier), Model: model, Source: source, From: from} @@ -403,7 +411,7 @@ func (s Seat) Report() string { return withNotice(s.Line(), s.Notice()) } // Describe is one seat in a receipt's voice: // -// work qwen/qwen3.8-27b (crew frugal) +// work deepseek/deepseek-v4-flash-0731 (crew frugal) // plan follows the work model (default) // // THE EMPTINESS LAW, as the crew row already keeps it: an unfilled plan seat is @@ -433,7 +441,7 @@ type Seats struct { // Sentence is both seats, unlabelled, for a door whose opening lines have a // label column of their own: // -// work qwen/qwen3.8-27b (crew frugal) · plan qwen/qwen3.8-27b (crew frugal) +// work deepseek/deepseek-v4-flash-0731 (crew frugal) · plan z-ai/glm-5.3 (crew frugal) // // ONE SHAPE AND NOT TWO. It would read a little better to collapse a run whose // seats came from the same crew into one clause, and it would mean a script @@ -445,7 +453,7 @@ func (s Seats) Sentence() string { // Line is the one line a headless run opens with: // -// models: work qwen/qwen3.8-27b (crew frugal) · plan qwen/qwen3.8-27b (crew frugal) +// models: work deepseek/deepseek-v4-flash-0731 (crew frugal) · plan z-ai/glm-5.3 (crew frugal) func (s Seats) Line() string { return modelsLabel + s.Sentence() } // Notice is the inheritance line for whichever seat was filled by an older row, diff --git a/internal/config/seats_test.go b/internal/config/seats_test.go index 5ad4c13f77..d863b3e2ba 100644 --- a/internal/config/seats_test.go +++ b/internal/config/seats_test.go @@ -44,7 +44,7 @@ func TestTheSeatLadderAnswersInItsOwnOrder(t *testing.T) { name: "the crew answers when it is the only thing said", crew: CrewFrugal, work: "deepseek/deepseek-v4-flash-0731", workRung: "crew frugal", - plan: "z-ai/glm-5.3-flash", planRung: "crew frugal", + plan: "z-ai/glm-5.3", planRung: "crew frugal", }, { name: "the environment outranks the crew", @@ -98,7 +98,7 @@ func TestTheSeatLadderAnswersInItsOwnOrder(t *testing.T) { // it: the same row a task handed off in conversation rides. hand: map[string]string{ModelTierWorker: "vendor/my-own-worker"}, work: "vendor/my-own-worker", workRung: "crew custom", - plan: "z-ai/glm-5.3-flash", planRung: "crew custom", + plan: "z-ai/glm-5.3", planRung: "crew custom", }, { // A row cleared on purpose means "follow the conversation", and a diff --git a/internal/config/settings.go b/internal/config/settings.go index 48255ce915..6ad56c26ff 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -214,6 +214,19 @@ const ( // "balanced" over a hand-pinned tier would be lying in the one place a person // went to check. KeyCrew = "models.crew" + // KeyCrewSource is which family the crew words draw from: `open`, the + // open-weight table this build ships, or `all`, the same three words + // resolved over the whole catalog with closed and frontier models in it. + // IT IS A ROW RATHER THAN A SECOND VOCABULARY because the three words are + // the only thing anybody learns: the question a person arrives with is how + // much to spend, and which shelf the answer comes off is one more answer to + // the same question, not six new preset words. The row is read by the + // crew's own machinery (crew.go's [CrewSourceAt]) and never by a caller + // spelling the ids itself, so the family and the tables cannot disagree + // about what a preset means. It is PROFILE-ONLY with the tier rows, for the + // worker row's own reason: a repository that could answer it could send a + // visitor's work, and their credit, to a vendor they never chose. + KeyCrewSource = "models.crew.source" // KeyMouse is whether the surface reports the mouse at all. ON is the // default ([DefaultMouse]), because hover, click and the wheel are v3's own // language and the thing they cost is bought back by a key: an alt-screen @@ -996,8 +1009,8 @@ var ModelTiers = []string{ModelTierReflex, ModelTierLow, ModelTierWorker, ModelT // // The ids are OPEN-WEIGHT MODELS, chosen off the catalog's own published // scores (OpenRouter republishes Artificial Analysis's coding and agentic -// indexes on every row) against the blended price, on 2026-09-01. The low row is -// pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id +// indexes on every row) against the blended price (crew.go's crewModels comment +// owns the date). The low row is pinned to a DATED build on purpose: the bare `deepseek/deepseek-v4-flash` id // resolves to the April build, and the July build at the same price scores // thirteen coding points higher. const ( @@ -1010,13 +1023,13 @@ const ( // it without a vision detour. DefaultWorkerModel = "z-ai/glm-5.3-flash" // The careful tier is ALWAYS A DIFFERENT VENDOR FROM THE WORKER, in every - // preset, and always a model that sees images: a check from a second family - // catches what the first family's blind spots let through, and the vision + // preset, and always a model that sees images: a check from a second vendor + // catches what the first vendor's blind spots let through, and the vision // role rides this row. - DefaultHighModel = "qwen/qwen3.8-27b" + DefaultHighModel = "moonshotai/kimi-k3" // The mastermind names a capable planning model. Its generation behavior is // left to the provider unless an operator adds a level to the model id. - DefaultMastermindModel = "z-ai/glm-5.3" + DefaultMastermindModel = "moonshotai/kimi-k3" ) // DocumentEngines are the four rungs CODEAF_DOC_ENGINE accepts. @@ -2255,13 +2268,31 @@ func (s *Settings) build() []Setting { Setting{ Key: KeyCrew, Category: CategoryModels, Kind: SettingChoice, Label: "crew", Choices: CrewPresets, - Hint: "the five models codeaf works with, chosen as one: `frugal` is glm-5.3-flash " + - "thinking over deepseek-v4-flash working, `balanced` has glm-5.3 think while " + - "glm-5.3-flash works and qwen checks, `max` puts glm-5.3 to work with kimi-k3 " + - "thinking and checking. Change one of the five rows below and this reads `custom`.", + Hint: "the five models codeaf works with, chosen as one word. `frugal`, " + + "`balanced` and `max` each pick their own roster, listed by /crew and under " + + "the crew row, and which shelf they draw from is the `model family` row " + + "below. Change one of the five rows below and this reads `custom`.", read: func() string { return CrewAt(dir) }, write: func(raw string) error { return writeCrew(dir, raw) }, }, + // THE FAMILY THE THREE WORDS DRAW FROM, one row under the crew. It sits + // beside the crew row because it is the same decision read one level up: + // the crew row says which five models, and this says which shelf those + // five come off. Open is the default and the law the shipped crew rests + // on; `all` is the opt-in that spends what the frontier costs. + Setting{ + Key: KeyCrewSource, Category: CategoryModels, Kind: SettingChoice, + Label: "model family", Choices: CrewSources, + Hint: "which family the crew words draw from. `open` is the default: every " + + "seat an open-weight model, so a crew nobody chose is never a bet on one " + + "vendor's pricing. `all` reads the same three words, frugal, balanced and " + + "max, off the whole catalog, closed and frontier models included, and " + + "costs what those models cost. Seats nobody pinned move with the family at " + + "once, because an unwritten seat is the default crew; rows already written " + + "keep their ids until you pick the crew again.", + read: func() string { return CrewSourceAt(dir) }, + write: func(raw string) error { return SetCrewSource(dir, raw) }, + }, Setting{ Key: KeyTierReflexModel, Category: CategoryModels, Kind: SettingText, Label: "reflex", EmptyLabel: "follows the conversation", @@ -3753,8 +3784,25 @@ func tierKeyFor(tier string) string { } // defaultTierModel is what a tier answers on a profile that has never held its -// key. The five together are the balanced crew. -func defaultTierModel(tier string) string { +// key: the DEFAULT CREW, resolved in the family the profile chose. It is not the +// build's constant alone, because a profile that answered the family row and no +// tier row would otherwise run the open-weight crew under a frontier label: the +// family saying one thing and the ladder another. Under the shipped family the +// answer is the constant, which is what [DefaultWorkerModel] and its kin name, +// because the open table's default row and the builtin five name the same set by +// construction (crew_test.go pins it). +func defaultTierModel(family, tier string) string { + if table, ok := CrewModelsForSource(family, DefaultCrew); ok { + if model, held := table[tier]; held { + return model + } + } + return builtinTierModel(tier) +} + +// builtinTierModel is the build's own five. It is reachable only for a tier word +// no family table holds, because both tables answer every tier this build knows. +func builtinTierModel(tier string) string { switch tier { case ModelTierReflex: return DefaultReflexModel diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index d264149dab..1683a21d82 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -1422,10 +1422,11 @@ opens the six-seat reading, bottom-anchored like the model picker. From the top: ``` the five models codeaf uses on its own behalf — not the one you chat with you talk to · deepseek-v4-flash - frugal — deepseek works, glm-flash thinks · pennies a day - reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3-flash -› balanced — glm-flash works, glm-5.3 thinks, qwen checks - reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3-flash · careful work qwen/qwen3.8-27b · mastermind z-ai/glm-5.3 + family ‹ open models · all models › + frugal — deepseek works, glm-5.3 thinks · pennies a day + reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker deepseek/deepseek-v4-flash-0731 · careful work z-ai/glm-5.3-flash · mastermind z-ai/glm-5.3 +› balanced — glm-flash works, kimi-k3 checks and thinks + reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3-flash · careful work moonshotai/kimi-k3 · mastermind moonshotai/kimi-k3 max — glm-5.3 works, kimi-k3 thinks and checks reflex mistralai/mistral-nemo · small work deepseek/deepseek-v4-flash-0731 · worker z-ai/glm-5.3 · careful work moonshotai/kimi-k3 · mastermind moonshotai/kimi-k3 each of the five can be pinned on its own in /settings → Providers @@ -1433,7 +1434,8 @@ each of the five can be pinned on its own in /settings → Providers The first line says what the presets change and what they do not. The second is **seat one** — `you talk to · `, spelled as the legend above the box spells it — with no marker and -no highlight, because nothing in this chooser can move it. Then the three presets: the one +no highlight, because nothing in this chooser can move it. The third is the **family**: **←→** moves `family`, which says which pool the three presets below it draw from, open weights or the +whole catalog. Then the three presets: the one in force wears a highlighted ground, `›` is where **enter** is aimed and it opens on yours, ↑ / ctrl+p and ↓ / ctrl+n move, and **esc** closes without changing anything. The last line points at the settings row where one seat can be pinned by itself; the chooser does not diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 0e30e82c6e..e51f418e42 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -334,26 +334,55 @@ preset: | reflex | `mistralai/mistral-nemo` | | small work | `deepseek/deepseek-v4-flash-0731` | | worker | `z-ai/glm-5.3-flash` | -| careful work | `qwen/qwen3.8-27b` | -| mastermind | `z-ai/glm-5.3` | +| careful work | `moonshotai/kimi-k3` | +| mastermind | `moonshotai/kimi-k3` | They are all open-weight models, and none of them is the model you are talking to. A crew that followed your conversation would put the most expensive model in the build on the cheapest questions in it — a call made twice every turn on a frontier model is a bill nobody agreed to. Closed models that are cheaper on their own vendor's platform than through the -router are deliberately not in any preset; you can still pin one on any row. +router are not in any open-family preset; the `all` family is where they are, and you +can still pin one on any row. You can also connect that vendor yourself as a direct service; the [services page](services.md) explains its names, limits and missing Phase 1 cost record. -**Why these ids.** They were picked on 2026-09-01 off the catalog's own published scores — -OpenRouter republishes Artificial Analysis's coding and agentic indexes on every model row -— against blended price, open weights only. The worker seat is the dial: `glm-5.3-flash` +**The `model family` row** (`models.crew.source`, just under the crew row) decides which family +the three preset words draw from. `open` is the default and is the table above: open weights only, +so a crew nobody chose is never a bet on one vendor's pricing. `all` reads the same three words, +`frugal`, `balanced` and `max`, off the whole catalog instead, closed and frontier models included, +and costs what those models cost. Flip the row and the seats nobody pinned move with it at once, +because an unwritten seat is the default crew resolved in the family you are on; the rows already +on disk keep their ids until you pick the crew again, and the crew word reads `custom` while they +match no preset in the family you flipped to. + +Under `all`, the same three words resolve to these: + +| class | frugal | balanced | max | +| --- | --- | --- | --- | +| reflex | `google/gemini-2.5-flash` | `google/gemini-2.5-flash` | `google/gemini-2.5-flash` | +| small work | `deepseek/deepseek-v4-flash-0731` | `deepseek/deepseek-v4-flash-0731` | `deepseek/deepseek-v4-flash-0731` | +| worker | `openai/gpt-5.6-sol` | `openai/gpt-5.6-sol` | `anthropic/claude-fable-5.1` | +| careful work | `google/gemini-3.8-flash` | `anthropic/claude-opus-5` | `openai/gpt-6-astra` | +| mastermind | `anthropic/claude-opus-5` | `anthropic/claude-fable-5.1` | `anthropic/claude-fable-5.1` | + +Two of the columns spend differently here. The worker is the same model in `frugal` and +`balanced`, and only `max` buys the frontier coder, because on the catalog the coding ceiling is +the one expensive step. What `frugal` to `balanced` buys instead is the careful seat and a +smarter mastermind. The `max` mastermind sits on the worker's own model on purpose: it is the +catalog's ceiling, so there is no bigger planner to buy above it. + +**Why the open ids.** They were picked off the catalog's own published scores (OpenRouter +republishes Artificial Analysis's coding and agentic indexes on every model row) against +blended price, open weights only, and the `frugal` mastermind and the `balanced` careful and mastermind seats were +picked on 2026-09-16, the rest on 2026-09-01. The worker seat is the dial: `glm-5.3-flash` scores 58 on the agentic index and 72 on coding at about $0.12 per million tokens blended, one point under `glm-5.3` at a twentieth of its price, and it can see images. The -mastermind buys the thinking rung rather than a bigger model, because its calls are few. +mastermind is the one seat whose price may be the model rather than the rung: it answers a +few times a task, so `frugal` pays for `glm-5.3` while the work below it stays on +`glm-5.3-flash` and DeepSeek. The careful class is always a different vendor from the worker and always sees images: -`qwen/qwen3.8-27b` scores 68 on coding at $0.42/M input and $2.55/M output with a 1M-token -window. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose — +`moonshotai/kimi-k3` scores 76 on coding and is the open field's strongest checker, so +`balanced` and `max` put it on both the careful and the mastermind seat. The small-work row is pinned to the July build of DeepSeek V4 Flash on purpose: the bare `deepseek/deepseek-v4-flash` id resolves to the April build, and the July build at the same price scores thirteen coding points higher. @@ -369,12 +398,12 @@ say rather than the default. | reflex | `mistral-nemo` | `mistral-nemo` | `mistral-nemo` | | small work | `deepseek-v4-flash-0731` | `deepseek-v4-flash-0731` | `deepseek-v4-flash-0731` | | worker | `deepseek-v4-flash-0731` | `glm-5.3-flash` | `glm-5.3` | -| careful work | `glm-5.3-flash` | `qwen3.8-27b` | `kimi-k3` | -| mastermind | `glm-5.3-flash` | `glm-5.3` | `kimi-k3` | +| careful work | `glm-5.3-flash` | `kimi-k3` | `kimi-k3` | +| mastermind | `glm-5.3` | `kimi-k3` | `kimi-k3` | -- **frugal** — deepseek works, glm-flash thinks · pennies a day -- **balanced** — glm-flash works, glm-5.3 thinks, qwen checks -- **max** — glm-5.3 works, kimi-k3 thinks and checks +- **frugal**: deepseek works, glm-5.3 thinks · pennies a day +- **balanced**: glm-flash works, kimi-k3 checks and thinks +- **max**: glm-5.3 works, kimi-k3 thinks and checks The worker column climbs the open-weight front one step per preset, because it is the seat that pays most of a task's bill. The reflex and small-work columns never vary — they are the diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index 4247b3f1f6..aefbeca475 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -24,9 +24,10 @@ import ( // class models underneath — and the seat above them is the one thing on the // list no key can move ([crewPicker.rows] says why it is there anyway). // -// EVERY WRITE GOES THROUGH [config.ApplyCrew], the same function the panel's row -// writes through. A second writer here is how a command and a panel end up -// disagreeing about which crew is on. +// EVERY CREW WRITE GOES THROUGH config's own writers: a preset through +// [config.ApplyCrew], and the chooser's family-and-preset decision through +// [config.ApplyCrewUnder] in one file write. A second writer anywhere else is how +// a command and a panel end up disagreeing about which crew is on. // runCrew is /crew: the three presets with the current one marked, or one applied. func (a *app) runCrew(arg string) { @@ -37,7 +38,7 @@ func (a *app) runCrew(arg string) { arg = strings.ToLower(strings.TrimSpace(arg)) if arg == "" { a.closeLists() - a.crewPick.start(config.CrewAt(a.profileDir), a.crewInheritedLine()) + a.crewPick.start(config.CrewAt(a.profileDir), config.CrewSourceAt(a.profileDir), a.crewInheritedLine()) a.touch() return } @@ -64,6 +65,12 @@ func (a *app) applyCrew(preset string) { a.note("could not set the crew · " + err.Error()) return } + a.crewApplied() +} + +// crewApplied is the tail both crew writes share: the panel rebuild and the +// person-facing confirmation. +func (a *app) crewApplied() { // The panel may be holding rows read before this write, so it is rebuilt if // it is open. Everything else is live: the crew source the session resolves // through re-reads on its next call (cmd/codeaf's v3RolesSource). @@ -95,6 +102,19 @@ func (a *app) applyCrew(preset string) { a.noteFacts(config.CrewSummary(a.profileDir)+" · "+a.crewUnchangedClause(), facts...) } +// applyCrewUnder is the chooser's enter: the family and the preset are ONE +// decision, and [config.ApplyCrewUnder] lands them as one file write. Landing +// them apart would leave a window in which a reader sees the family set to `all` +// while the five rows still hold open ids, which is the half-written crew the +// chooser is the one place to promise against. +func (a *app) applyCrewUnder(source, preset string) { + if err := config.ApplyCrewUnder(a.profileDir, source, preset); err != nil { + a.note("could not set the crew · " + err.Error()) + return + } + a.crewApplied() +} + // crewUnchangedClause is the tail of every crew confirmation: the model the // conversation is still on, and the one command that moves it. // @@ -377,6 +397,24 @@ func (a *app) sayWorkSeat() { } } +// ── the family row: which pool the presets draw from ────────────────────────── +// +// models.crew.source says whether the three presets pick their five ids from +// open models or from all models. THE CONFIG PACKAGE OWNS THE ROW: +// [config.CrewSourceAt] reads it. [config.SetCrewSource] writes it alone for the +// settings row, and [config.ApplyCrewUnder] commits it with a preset as one write +// for this chooser. The two tables behind them answer it. +// +// THE SEGMENT DOES NOT NAME THE FAMILY. The chooser reads the row at its open +// ([app.runCrew]) and enter writes it through [config.ApplyCrewUnder], and the +// crew word the status segment shows resolves through the same row: it is read +// there, and it is not printed above the presets. + +// crewSourceLead is this row's one word in the chooser. The settings panel labels +// the same row `model family`; here it is abbreviated to fit one line, and both +// name the family the presets draw from. +const crewSourceLead = "family" + // crewPicker is the fixed, bottom-anchored chooser opened by bare /crew. Its // zero value is closed, like [picker], and its cursor is an index into // [config.CrewPresets]. @@ -384,6 +422,18 @@ type crewPicker struct { open bool cursor int current string + // source is the family the presets below draw from: open models or all + // models. IT IS STAGED AND NOT WRITTEN: ←→ moves the word, and enter + // writes it beside the preset as one decision ([app.applyCrewUnder]): flip the family, watch the + // preset rows take it, then commit. esc leaves the row exactly as it + // was. + source string + // persistedSource is the family the SAVED crew is in, read at the door beside + // current. The row in force is the one the profile holds IN THE FAMILY THE + // PROFILE HOLDS: once ←→ moves the staged family, no preset on screen is in + // force any more, and a row that kept the mark would name models the profile + // does not run. + persistedSource string // inherited is the line naming the seat this profile has no row for, or // empty when every row was written. IT IS READ AT THE DOOR AND NOT AT THE // DRAW: the rows are built every frame and the answer is three lines of a @@ -392,8 +442,11 @@ type crewPicker struct { inherited string } -func (p *crewPicker) start(current, inherited string) { - *p = crewPicker{open: true, current: current, inherited: inherited} +func (p *crewPicker) start(current, source, inherited string) { + // source is already a word this build knows: every caller hands it + // [config.CrewSourceAt]'s answer, which folds blank, unknown and retired words + // back to open. The fold lives there and nowhere else. + *p = crewPicker{open: true, current: current, persistedSource: source, inherited: inherited, source: source} for i, preset := range config.CrewPresets { if preset == current { p.cursor = i @@ -408,6 +461,19 @@ func (p *crewPicker) move(delta int) { p.cursor = (p.cursor + delta + len(config.CrewPresets)) % len(config.CrewPresets) } +// moveSource walks the two families, ←→ in [crewPickerKey]. It wraps rather +// than clamps for [move]'s own reason: with two options a clamp would leave +// one of the two keys a dead key on the word it started at. +func (p *crewPicker) moveSource(delta int) { + at := 0 + for i, source := range config.CrewSources { + if source == p.source { + at = i + } + } + p.source = config.CrewSources[(at+delta+len(config.CrewSources))%len(config.CrewSources)] +} + // The chooser's fixed lines around the three presets, spelled once so the // height and the rows cannot count them differently. const ( @@ -437,8 +503,8 @@ const ( crewInheritedMid = " seat is inherited from " crewInheritedTail = " — picking one writes it" // crewFrameRows is how many of the chooser's rows are not preset rows: the - // scope line, seat one, and the closing note. - crewFrameRows = 3 + // scope line, seat one, the family selector and the closing note. + crewFrameRows = 4 ) func (p *crewPicker) height() int { @@ -489,8 +555,30 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // so the chooser's height stays what [crewPicker.height] promised. out = append(out, "") } + // THE FAMILY IS A HEADER THE ←→ KEYS WALK, not a fourth row on the ↑↓ + // list: the presets are one axis and the family is a second one steering + // them, and a list that made ↑↓ pass through the family on the way from + // frugal to max would be moving an axis the person never aimed at. So the + // row sits directly above the presets it steers, it answers to ←→, and the + // two options are drawn beside each other so the move it offers is visible + // before it is taken. + // + // THE WORD THE NEXT ENTER WRITES IS THE WORD LIFTED, and the lifted word is + // the accent: it is the fact this row answers and the only fact on it. This + // row takes no `›` lead and no ground of its own, for a reason of its own + // rather than seat one's: it is not on the ↑↓ axis, so no cursor sits on it + // for a lead to mark, and the lifted word is its whole mark. + words := make([]string, 0, len(config.CrewSources)) + for _, source := range config.CrewSources { + if source == p.source { + words = append(words, pal.accent(source+" models")) + continue + } + words = append(words, pal.dim(source+" models")) + } + out = append(out, fit(" "+pal.dim(crewSourceLead+" ‹ ")+strings.Join(words, pal.dim(" · "))+pal.dim(" ›"), width)) for i, preset := range config.CrewPresets { - models, _ := config.CrewModels(preset) + models, _ := config.CrewModelsForSource(p.source, preset) parts := make([]string, 0, len(roles.Tiers)) for _, tier := range roles.Tiers { parts = append(parts, strings.TrimSpace(a.crewClassWord(tier))+" "+models[string(tier)]) @@ -505,7 +593,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string // first, the cursor's `›` disappeared the moment the cursor landed on the // preset already in force — which is the one row a person is most likely // to arrow onto, and the one moment they most need to know enter is aimed. - oncursor, current := i == p.cursor, preset == p.current + oncursor, current := i == p.cursor, preset == p.current && p.source == p.persistedSource hovered := hover == len(out) || hover == len(out)+1 lead := " " switch { @@ -514,7 +602,7 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string case hovered: lead = pal.accent("· ") } - label := preset + " — " + config.CrewLine(preset) + label := preset + " — " + config.CrewLineFor(p.source, preset) switch { case current: label = pal.accent(label) @@ -541,6 +629,8 @@ func (p *crewPicker) rows(width, n int, pal palette, hover int, a *app) []string } out = append(out, head, tail) } + // The custom line is about the SAVED crew, so it shows whichever family is + // staged: hiding it would drop a row [crewPicker.height] still counts. if p.current == config.CrewCustom { out = append(out, pal.dim(fit(crewCustomLine, width))) } @@ -579,12 +669,17 @@ func (a *app) crewPickerKey(msg tea.KeyPressMsg) { a.crewPick.close() case "enter": preset := config.CrewPresets[a.crewPick.cursor] + source := a.crewPick.source a.crewPick.close() - a.applyCrew(preset) + a.applyCrewUnder(source, preset) case "up", "ctrl+p": a.crewPick.move(-1) case "down", "ctrl+n": a.crewPick.move(1) + case "left": + a.crewPick.moveSource(-1) + case "right": + a.crewPick.moveSource(1) } a.touch() } @@ -599,7 +694,14 @@ func (a *app) crewPickerKey(msg tea.KeyPressMsg) { // surface. func (a *app) crewListing() string { current := config.CrewAt(a.profileDir) + source := config.CrewSourceAt(a.profileDir) var out strings.Builder + // THE WORDS ARE THE SAME IN EITHER FAMILY, so the listing names the pool + // whenever it is not the shipped one: an all listing that looked exactly like + // an open one is the ambiguity this whole feature is about. + if source != config.CrewSourceOpen { + out.WriteString(crewSourceLead + " · " + source + " models\n") + } for at, preset := range config.CrewPresets { if at > 0 { out.WriteString("\n") @@ -608,8 +710,8 @@ func (a *app) crewListing() string { if preset == current { lead = "· " } - out.WriteString(lead + preset + " — " + config.CrewLine(preset) + "\n") - models, _ := config.CrewModels(preset) + out.WriteString(lead + preset + " — " + config.CrewLineFor(source, preset) + "\n") + models, _ := config.CrewModelsForSource(source, preset) for _, tier := range roles.Tiers { out.WriteString(" " + a.crewClassWord(tier) + " " + models[string(tier)] + "\n") } diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index f0a223974b..7112fda523 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -1,6 +1,8 @@ package tui3 import ( + "encoding/json" + "os" "strings" "testing" @@ -296,6 +298,181 @@ func TestTheTierWordsAndTheClassKeysAreTheSameSet(t *testing.T) { } } +// ── THE FAMILY ROW: WHICH POOL THE PRESETS DRAW FROM ───────────────────── +// +// models.crew.source is one row, and the chooser is where it moves: a header +// the ←→ keys walk, above the presets it steers, staged until enter commits it +// beside the preset as one decision. + +// THE FAMILY IS SAID ABOVE THE PRESETS, with the keys that move it named in +// the hint under the ladder: a header row that takes ←→ while the presets +// below keep ↑↓, so the two axes stay two axes. +func TestTheCrewChooserNamesTheTwoFamiliesAboveThePresets(t *testing.T) { + a, _ := sheetApp(t) + a.width = 240 + a.slash("/crew") + + rows := a.overlayRows(a.width, a.overlayHeight()) + text := plain(strings.Join(rows, "\n")) + // The two families are one row, third on the sheet, sitting directly above + // the first preset after the scope line and seat one. + family := plain(rows[2]) + if !strings.Contains(family, "open models") || !strings.Contains(family, "all models") { + t.Fatalf("the two families are not one row above the presets:\n%s", text) + } + for i, preset := range config.CrewPresets { + if row := plain(rows[3+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { + t.Errorf("row %d is not %s's:\n%q", 3+2*i, preset, row) + } + } + // THE SHIPPED FAMILY IS THE WORD LIFTED, and it is the only one: the word + // the next enter writes is the fact the row answers, and a second lifted + // word would be a second answer on a row with one question. + if !strings.Contains(rows[2], a.pal.accent("open models")) { + t.Fatalf("the open family is not the word in force:\n%q", rows[2]) + } + if strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("both families are lifted at once:\n%q", rows[2]) + } + // AND ←→ MOVES THE WORD while the preset cursor keeps its place and its + // mark: the family is a second axis, not a row the ↑↓ list gained. + a.crewPickerKey(key("left")) + rows = a.overlayRows(a.width, a.overlayHeight()) + if !strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("← did not walk the family to all:\n%q", rows[2]) + } + // AND THE PRESETS BELOW IT ANSWER IN THAT FAMILY: a header saying all above + // rows still naming open models is the contradiction this chooser exists to + // prevent, and it is the one regression a header-only check would pass. + for i, preset := range config.CrewPresets { + if row := plain(rows[3+2*i]); !strings.Contains(row, config.CrewLineFor(config.CrewSourceAll, preset)) { + t.Errorf("row %d under the all family is not %s's all line:\n%q", 3+2*i, preset, row) + } + } + // AND THE IN-FORCE MARK DOES NOT MOVE WITH THE FAMILY. The row that wears the + // selected ground is the preset the profile holds IN THE FAMILY IT HOLDS, so + // once ←→ lifts the family no row on screen is in force: one that kept the + // mark would name models the profile does not run. + if strings.Contains(rows[3+2*1], a.pal.accent("balanced — "+config.CrewLineFor(config.CrewSourceAll, config.CrewBalanced))) { + t.Errorf("the balanced row is still marked in force after ←:\n%q", rows[3+2*1]) + } + if a.crewPick.cursor != 1 { + t.Fatalf("walking the family moved the preset cursor to row %d", a.crewPick.cursor) + } + if !strings.Contains(strings.Join(rows, "\n"), a.pal.accent("› ")) { + t.Fatalf("the preset cursor lost its mark to the family row:\n%s", plain(strings.Join(rows, "\n"))) + } +} + +// THE FAMILY IS READ FROM THE PROFILE, and the chooser opens on the word the +// person last committed to, not on the default. +func TestTheCrewChooserOpensOnThePersistedFamily(t *testing.T) { + a, dir := sheetApp(t) + seed := map[string]any{config.KeyCrewSource: config.CrewSourceAll} + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.BudgetConfigPath(dir), encoded, 0o600); err != nil { + t.Fatal(err) + } + + a.slash("/crew") + rows := a.overlayRows(a.width, a.overlayHeight()) + if !strings.Contains(rows[2], a.pal.accent("all models")) { + t.Fatalf("the chooser opened on a family other than the persisted one:\n%q", rows[2]) + } + // THE SEGMENT DOES NOT CARRY THE FAMILY, and the frame-disk law is why + // (the family section in crew.go says it once): it reads the crew word and + // nothing else, so a profile on the all family keeps the same segment shape. + if got := a.crewSegment(); got != "crew "+config.CrewBalanced { + t.Fatalf("the crew segment reads %q over an all profile", got) + } + // AND ESC LEAVES THE ROW ALONE: walking a family and walking away writes + // nothing, for the esc test's own reason. + a.crewPickerKey(key("right")) + a.crewPickerKey(key("esc")) + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { + t.Fatalf("esc wrote the family to %q", got) + } +} + +// CHOOSING A FAMILY AND A PRESET WRITES BOTH ROWS, as one decision and one file +// write: the family and the five ids land together through +// [config.ApplyCrewUnder]. +func TestChoosingAFamilyAndAPresetWritesTheSourceRow(t *testing.T) { + a, dir := sheetApp(t) + // A NEIGHBOUR THE WRITE MUST NOT TOUCH, because the family row lands + // through the same whole-file read-and-replace config's own writer uses. + seed := map[string]any{"test.neighbour": true} + encoded, err := json.Marshal(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.BudgetConfigPath(dir), encoded, 0o600); err != nil { + t.Fatal(err) + } + + a.slash("/crew") + a.crewPickerKey(key("left")) + a.crewPickerKey(key("enter")) + + if a.crewPick.open { + t.Fatal("enter left the chooser open") + } + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { + t.Fatalf("enter wrote the family %q, want all", got) + } + if got := config.CrewAt(dir); got != config.CrewBalanced { + t.Fatalf("enter applied %q, want balanced", got) + } + // The neighbour survived the one write. + data, err := os.ReadFile(config.BudgetConfigPath(dir)) + if err != nil { + t.Fatal(err) + } + held := make(map[string]json.RawMessage) + if err := json.Unmarshal(data, &held); err != nil { + t.Fatal(err) + } + if string(held["test.neighbour"]) != "true" { + t.Fatalf("the family write lost its neighbour: %s", data) + } + // AND THE SEGMENT KEEPS ITS SHAPE: it reads the crew word and not the family + // (the family section in crew.go says it once). + if got := a.crewSegment(); got != "crew "+config.CrewBalanced { + t.Fatalf("the crew segment reads %q after the write", got) + } +} + +// A ROW NOBODY WROTE READS AS THE SHIPPED FAMILY, and so does a word this build +// does not know: the chooser offers two options and a stray string off the disk +// is not a third. +func TestTheCrewSourceRowDefaultsToOpen(t *testing.T) { + dir := t.TempDir() + if got := config.CrewSourceAt(dir); got != config.CrewSourceOpen { + t.Fatalf("a profile with no row read %q", got) + } + for _, row := range []string{ + `{"models.crew.source": true}`, + `{"models.crew.source": "everyone"}`, + `{`, + } { + if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(row), 0o600); err != nil { + t.Fatal(err) + } + if got := config.CrewSourceAt(dir); got != config.CrewSourceOpen { + t.Fatalf("the row %s read %q, want open", row, got) + } + } + if err := os.WriteFile(config.BudgetConfigPath(dir), []byte(`{"models.crew.source": "all"}`), 0o600); err != nil { + t.Fatal(err) + } + if got := config.CrewSourceAt(dir); got != config.CrewSourceAll { + t.Fatalf("the all row read %q", got) + } +} + // ── THE CREW IS READABLE WHERE PEOPLE GO TO CHECK ─────────────────────────── // // /crew writes four class models and the session picks them up on its next @@ -515,10 +692,11 @@ func TestBareCrewReadsTheFiveSeats(t *testing.T) { if a.crewPick.cursor != 1 { t.Fatalf("the cursor opened on row %d, want balanced at 1", a.crewPick.cursor) } - // The presets follow, in their own order, after the two reading lines. + // The presets follow, in their own order, after the three reading lines: the + // scope line, seat one and the family selector. for i, preset := range config.CrewPresets { - if row := plain(rows[2+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { - t.Errorf("row %d is not %s's:\n%q", 2+2*i, preset, row) + if row := plain(rows[3+2*i]); !strings.Contains(row, preset+" — "+config.CrewLine(preset)) { + t.Errorf("row %d is not %s's:\n%q", 3+2*i, preset, row) } } // And enter still applies the preset under the cursor, seat one untouched. diff --git a/internal/tui3/firstrun.go b/internal/tui3/firstrun.go index c1b7575a87..0690112f19 100644 --- a/internal/tui3/firstrun.go +++ b/internal/tui3/firstrun.go @@ -112,6 +112,7 @@ type setupFlow struct { crewOpen bool crewAt int crewPick string + crewSource string reviewOpen bool example int seeded bool diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index c8102fd5ed..20f8df326c 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -495,9 +495,10 @@ func (a *app) commitSetupLimit() bool { // commitSetupCrew writes a crew, and MOSTLY DOES NOT. // // THE SETUP MAY NOT PAPER OVER AN OPINION. internal/config says why in its own -// words: [config.CrewConfigured] is true when ANY of the five tier rows is in the -// profile, "because a person who pinned one tier by hand has an opinion the setup -// must not paper over with a preset". So a preset is written on exactly two +// words: [config.CrewConfigured] is true when ANY of the tier rows, or the +// family row above them, is in the profile, "because a person who pinned one tier +// by hand, or chose a family, has an opinion the setup must not paper over with a +// preset". So a preset is written on exactly two // roads — the person chose one in the chooser on this screen, or the profile had // no crew of its own at all and the row they were shown is the shipped default, // which is the case the first-run flow exists for. @@ -695,6 +696,9 @@ func (a *app) startSetupControls() { s.closeChoosers() s.reviewOpen, s.detail = false, false s.crewPick = "" + // The family is read HERE, arriving on the screen, and not in the draw: this + // screen's rows are drawn every frame and a profile read belongs at a door. + s.crewSource = config.CrewSourceAt(a.profileDir) s.crewAt = a.setupCrewCursor() s.example = exampleForControl(controlLimit) // ARRIVING ON THE SCREEN IS THE FIRST OF THE TWO DELIBERATE ACTS, so the @@ -1331,7 +1335,7 @@ func (a *app) setupCrewDetail() string { // The three roles are spelled the way internal/config spells them for the // live reading below, because a person opening this twice must not be // shown one sentence in two shapes. - seats = crewPickSeats(a.setup.crewPick) + seats = crewPickSeats(a.setup.crewSource, a.setup.crewPick) } if seats == "" { seats = strings.TrimSpace(config.CrewClasses(a.profileDir)) @@ -1347,8 +1351,8 @@ func (a *app) setupCrewDetail() string { // itself rather than the profile it has not been written to yet. It answers "" // for a word that is not a preset, which is the emptiness law: there is no crew // to describe and no placeholder that would be true. -func crewPickSeats(preset string) string { - models, ok := config.CrewModels(preset) +func crewPickSeats(source, preset string) string { + models, ok := config.CrewModelsForSource(source, preset) if !ok { return "" } @@ -1490,6 +1494,10 @@ func crewChoiceWord(preset string) string { if word := crewChoiceWords[strings.ToLower(strings.TrimSpace(preset))]; word != "" { return word } + // The fallback is for a preset this screen has not been taught, and it is the + // open family's line: setupCrewRows runs inside the draw, so reading the profile + // here would put a profile read on the frame clock, and the seats this screen + // shows already take the family from a value read at the door. return config.CrewLine(preset) } diff --git a/internal/tui3/payload_test.go b/internal/tui3/payload_test.go index e61ce5da8e..1f66f63d78 100644 --- a/internal/tui3/payload_test.go +++ b/internal/tui3/payload_test.go @@ -250,7 +250,7 @@ func TestTheHintGrammarReadsEveryHintThisSurfaceWrites(t *testing.T) { {pickerKeysModelTab, []string{"tab", "enter", "esc"}}, {pickerKeysFold, []string{"enter", "←", "esc"}}, {pickerKeysFoldTab, []string{"enter", "tab", "esc"}}, - {"↑↓ · enter apply · esc", []string{"↑↓", "enter", "esc"}}, + {"↑↓ · ←→ family · enter apply · esc", []string{"↑↓", "←→", "enter", "esc"}}, {"enter open · esc", []string{"enter", "esc"}}, {"v select · a block · y yank · esc", []string{"v", "a", "y", "esc"}}, {"esc again to rewind", []string{"esc"}}, diff --git a/internal/tui3/render.go b/internal/tui3/render.go index 78762ae34e..12ad8346a5 100644 --- a/internal/tui3/render.go +++ b/internal/tui3/render.go @@ -3970,7 +3970,7 @@ func (a *app) hintWord() string { } return keys case a.crewPick.open: - return "↑↓ · enter apply · esc" + return "↑↓ · ←→ family · enter apply · esc" case a.effPick.open: // The chord is named beside the keys because this list is the only place // on the surface that can teach it: the chip it opens from prints a mark diff --git a/internal/tui3/settings.go b/internal/tui3/settings.go index 46c78d28c5..017bfa832e 100644 --- a/internal/tui3/settings.go +++ b/internal/tui3/settings.go @@ -370,6 +370,16 @@ var settingUI = map[string]settingMeta{ tab: tabProviders, label: "reflex", widget: widgetSelect, about: "near-free · reads every turn — memory, titles, safety", }, + + // The row is placed by build(), not by this map, and it sits directly under + // the crew word because it changes what that word means rather than pinning a + // model of its own: the three presets answer from open weights or from the + // whole catalog, and this is the switch. + config.KeyCrewSource: { + tab: tabProviders, label: "model family", widget: widgetCycle, + about: "which models the crew word draws from: open weights, or the whole " + + "catalog with closed and frontier models in it. Open is the default.", + }, config.KeyTierLowModel: { tab: tabProviders, label: "small work", widget: widgetSelect, about: "cheap · the small calls — names, digests, the safety gate", @@ -727,7 +737,7 @@ func init() { // same row, the same write, the same live seam onto [app.switchModel] — only // the word above the Models section changed. crew := settingUI[config.KeyCrew] - crew.about = crewAbout() + crew.about = crewAbout(config.CrewSourceOpen) settingUI[config.KeyCrew] = crew talk := settingUI[config.ModelSettingKey(talkSlot)] @@ -739,12 +749,14 @@ func init() { // crewAbout is the crew row's one line: the five rows it writes, then each preset // with its own sentence, then what makes the row read custom. The sentences are -// [config.CrewLine]'s, so the panel and /crew say the same words about the same -// thing (internal/config's crew.go holds the table). -func crewAbout() string { +// [config.CrewLineFor]'s IN THE FAMILY ON SCREEN, so the panel and /crew say the +// same words about the same thing and neither names an open model above frontier +// ids. The init-time value is the open family; [sheet.metaFor] re-says it from the +// profile, which is where a family that is not the default comes from. +func crewAbout(source string) string { said := make([]string, 0, len(config.CrewPresets)) for _, preset := range config.CrewPresets { - said = append(said, preset+" — "+config.CrewLine(preset)) + said = append(said, preset+" — "+config.CrewLineFor(source, preset)) } // The three options lead, because they are what the keypress chooses between // and the panel gives a row's line the width it has: what gets cut on a narrow @@ -1403,6 +1415,11 @@ func (s *sheet) metaFor(row config.Setting) (settingMeta, bool) { if ok && row.Key == config.KeySearchProvider { meta.about = config.SearchProviderHintAt(s.profileDir) } + // The crew row names three presets in whichever family the profile is on, so + // its sentence is read from the profile for the same reason. + if ok && row.Key == config.KeyCrew { + meta.about = crewAbout(config.CrewSourceAt(s.profileDir)) + } // AND THE `lane` ROW IS EXPLAINED BY THE ROUTING IN FORCE, because `auto` is // a different promise under `simple` than under the row codeaf ships with — // the same one door the picker's own `auto` row reads (palette.go's