Compare commits

...
Author SHA1 Message Date
Gnanam 533b41495d feat(tui): fast-mode marker/hint and transient /fast toast
Surface which models have a fast variant: a dim "f" badge on the
active-model chip, the /model picker, and the /provider wizard, plus a
focused-row "fast: <name>" hint (the pickers are keyboard-driven, so the
selected row is the reveal). All read Registry.FastVariant, so coverage
tracks the catalog automatically.

Give /fast a distinctive voice and stop it cluttering the transcript:
reword its messages and show the result as a transient toast pill in the
row above the composer, auto-dismissed after 3s (seq-gated, same pattern
as the copy-status flash).
2026-07-04 19:10:03 +05:30
Gnanam bb66a44ab3 feat(modelregistry): add curated FastVariantID and memoize DefaultRegistry
Add a FastVariantID field to ModelEntry (mirroring UpgradeTargetID),
populated in the catalog for the four same-family base/fast pairs and
validated at registry build. Refactor the /fast helpers to derive both
directions from this field instead of a hardcoded table, and add a
HasFastVariant predicate for UI callers.

Memoize DefaultRegistry with sync.OnceValues: the default catalog is
static, every accessor returns clones, and a built registry is never
mutated, so per-frame TUI callers no longer re-clone entries and
recompile fuzzy-match regexes each render.
2026-07-04 19:10:03 +05:30
Gnanam 6f97c90316 feat(tui): add /fast command to toggle a model's fast variant
/fast [on|off] switches the session model between its base and a faster
same-family variant (e.g. Sonnet<->Haiku, GPT-4.1<->GPT-4.1-mini). State is
DERIVED from the model identity, never stored: the session is "in fast mode"
iff the current model has a base variant.

- modelregistry: a config-driven base<->fast mapping table (fastVariantPairs)
  plus Registry.FastVariant / BaseVariant, mirroring the existing UpgradeTarget
  pattern. Adding a pair is a one-line data change — no logic edit. Populated
  for the models with a meaningful fast variant (Sonnet/Haiku, GPT-4.1/mini,
  GPT-4o/mini, Gemini-2.5 Pro/Flash), guarded by tests that every id resolves
  and no id is both a base and a fast.
- tui: /fast registered in commandDefinitions (model group) and dispatched via
  a pure planFastCommand decision + handleFastCommand, which delegates the real
  switch to handleModelCommand (provider rebuild, compaction guard, persistence)
  rather than mutating state directly. Notices use the existing appendSystem
  mechanism.
- Tests: table-driven over every case — invalid arg, no model set, no-op both
  ways, no fast variant, switch to fast (⚡) / back to base, switch-error
  surfaced, and the variant helpers.

Gate: make build / go vet ./... / go test ./... -race / make lint all green.
2026-07-04 19:10:03 +05:30
13 changed files with 901 additions and 8 deletions

No files matched your search

+26 -1
View File
@@ -3,6 +3,7 @@ package modelregistry
import (
"fmt"
"strings"
"sync"
)
const DefaultModelID = "gpt-4.1"
@@ -42,8 +43,20 @@ type ListOptions struct {
Capability ModelCapability
}
func DefaultRegistry() (Registry, error) {
// defaultRegistry builds the default registry exactly once and caches the result.
// The default catalog is static, every accessor returns clones, and a built
// Registry is never mutated (concurrent reads of its maps are safe), so a single
// shared instance is correct — and it avoids re-cloning all entries and
// recompiling every fuzzy-match regex on the per-frame TUI paths (title chip,
// context gauge, pickers) that call DefaultRegistry each render.
var defaultRegistry = sync.OnceValues(func() (Registry, error) {
return NewRegistry(DefaultModelEntries())
})
// DefaultRegistry returns the shared, immutable default model registry, building
// it on first use.
func DefaultRegistry() (Registry, error) {
return defaultRegistry()
}
func DefaultModelEntries() []ModelEntry {
@@ -76,11 +89,13 @@ func decorateModelDepth(entries []ModelEntry) {
patterns []string
deprecation *DeprecationRule
upgradeTarget string
fastVariant string
}{
"claude-sonnet-4.5": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
upgradeTarget: "claude-opus-4.1",
fastVariant: "claude-haiku-4.5",
},
"claude-opus-4.1": {
defaultEffort: ReasoningEffortHigh,
@@ -94,6 +109,7 @@ func decorateModelDepth(entries []ModelEntry) {
"gemini-2.5-pro": {
defaultEffort: ReasoningEffortMedium,
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
fastVariant: "gemini-2.5-flash",
},
"gemini-2.5-flash": {
defaultEffort: ReasoningEffortLow,
@@ -102,6 +118,12 @@ func decorateModelDepth(entries []ModelEntry) {
"gemini-2.5-flash-lite": {
upgradeTarget: "gemini-2.5-flash",
},
"gpt-4.1": {
fastVariant: "gpt-4.1-mini",
},
"gpt-4o": {
fastVariant: "gpt-4o-mini",
},
"gpt-4.1-mini": {
upgradeTarget: "gpt-4.1",
},
@@ -143,6 +165,9 @@ func decorateModelDepth(entries []ModelEntry) {
if extra.upgradeTarget != "" {
entries[index].UpgradeTargetID = extra.upgradeTarget
}
if extra.fastVariant != "" {
entries[index].FastVariantID = extra.fastVariant
}
}
}
+20 -1
View File
@@ -110,7 +110,14 @@ type ModelEntry struct {
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
// models). Resolved and availability-checked by Registry.UpgradeTarget.
UpgradeTargetID string
Description string
// FastVariantID, when set, names a genuinely faster same-family model this one
// can drop down to (e.g. claude-sonnet-4.5 -> claude-haiku-4.5). Empty means no
// fast variant. It is the single source of truth for the /fast command and the
// "f" fast-mode marker: Registry.FastVariant walks it forward and BaseVariant
// walks it in reverse, so a model is "in fast mode" iff another model names it
// here. Resolved and availability-checked like UpgradeTargetID.
FastVariantID string
Description string
}
// DeprecationRule describes how a deprecated model is phased out and what to use
@@ -368,6 +375,18 @@ func NewRegistry(entries []ModelEntry) (Registry, error) {
return Registry{}, fmt.Errorf("model %q upgrade target %q does not resolve to a known model", entry.ID, targetID)
}
}
// A non-empty FastVariantID must resolve to a known model, mirroring the
// UpgradeTargetID check — otherwise /fast and the "f" fast-mode marker would
// silently vanish at runtime on a catalog typo instead of failing loudly here.
for _, entry := range registry.models {
fastID := strings.TrimSpace(entry.FastVariantID)
if fastID == "" {
continue
}
if _, ok := registry.Get(fastID); !ok {
return Registry{}, fmt.Errorf("model %q fast variant %q does not resolve to a known model", entry.ID, fastID)
}
}
return registry, nil
}
+75
View File
@@ -0,0 +1,75 @@
package modelregistry
import "strings"
// The base<->fast model mapping is data, not logic: it lives entirely in each
// ModelEntry's FastVariantID field (populated in the catalog, validated in
// NewRegistry). Adding a pair is a one-line catalog change — never a code change
// here. Both directions derive from that one field: FastVariant walks a model to
// the fast id it names, BaseVariant scans for the model that names id as its fast
// variant. This is why "fast mode" is encoded by model identity and needs no flag.
//
// Only models with an unambiguous 1:1 fast counterpart carry a FastVariantID. A
// model with no clean counterpart (e.g. a top-tier Opus, where both Sonnet and
// Haiku would be candidates) is intentionally left unset, so /fast reports "no
// fast mode available" for it rather than guessing.
// FastVariant resolves id to its configured faster same-family counterpart. It
// resolves id to a concrete entry, reads that entry's FastVariantID, and returns
// the target only when it resolves and is not deprecated. Returns (_, false) when
// id is unknown, has no fast variant, or the variant is unavailable/deprecated.
// Mirrors Registry.UpgradeTarget.
func (registry Registry) FastVariant(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
fastID := strings.TrimSpace(source.FastVariantID)
if fastID == "" {
return ModelEntry{}, false
}
target, ok := registry.Resolve(fastID)
if !ok || target.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
return target, true
}
// BaseVariant resolves id to the base model that declares id as its fast variant
// — the reverse of FastVariant. A non-empty result therefore means id IS a fast
// model: this is exactly how the /fast command derives "currently in fast mode"
// without storing any flag. Same resolution and availability rules as FastVariant.
func (registry Registry) BaseVariant(id string) (ModelEntry, bool) {
source, ok := registry.Resolve(id)
if !ok {
return ModelEntry{}, false
}
for _, entry := range registry.models {
fastID := strings.TrimSpace(entry.FastVariantID)
if fastID == "" {
continue
}
target, ok := registry.Resolve(fastID)
if !ok || target.ID != source.ID {
continue
}
if entry.Status == ModelStatusDeprecated {
return ModelEntry{}, false
}
base, ok := registry.Resolve(entry.ID)
if !ok {
return ModelEntry{}, false
}
return base, true
}
return ModelEntry{}, false
}
// HasFastVariant reports whether id has an available fast variant — i.e. whether
// `/fast on` would switch to a faster model from here. It is the predicate the
// TUI uses to show the "f" fast-mode marker on a model (in the model/provider
// pickers and the active-model indicator).
func (registry Registry) HasFastVariant(id string) bool {
_, ok := registry.FastVariant(id)
return ok
}
+145
View File
@@ -0,0 +1,145 @@
package modelregistry
import "testing"
func testRegistry(t *testing.T) Registry {
t.Helper()
registry, err := DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry() error = %v", err)
}
return registry
}
// expectedFastPairs is the known-good base->fast mapping. It is a literal
// regression guard: if the catalog silently drops or mis-wires a FastVariantID,
// the forward/reverse assertions below fail loudly rather than making /fast a
// quiet no-op. Keep in sync with the FastVariantID fields in catalog.go.
var expectedFastPairs = map[string]string{
"claude-sonnet-4.5": "claude-haiku-4.5",
"gpt-4.1": "gpt-4.1-mini",
"gpt-4o": "gpt-4o-mini",
"gemini-2.5-pro": "gemini-2.5-flash",
}
// Every FastVariantID declared in the catalog must resolve to a real, non-self,
// non-deprecated model. NewRegistry already rejects an unresolvable id at build
// time; this additionally guards the runtime availability contract that /fast and
// the "f" marker depend on.
func TestCatalogFastVariantsResolve(t *testing.T) {
registry := testRegistry(t)
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
if entry.FastVariantID == "" {
continue
}
target, ok := registry.Resolve(entry.FastVariantID)
if !ok {
t.Errorf("model %q fast variant %q does not resolve", entry.ID, entry.FastVariantID)
continue
}
if target.ID == entry.ID {
t.Errorf("model %q names itself as its fast variant", entry.ID)
}
if target.Status == ModelStatusDeprecated {
t.Errorf("model %q fast variant %q is deprecated", entry.ID, entry.FastVariantID)
}
}
}
// No model may be both a base and a fast, or the two directions become ambiguous
// (a model would be "currently fast" AND advertise a fast variant). Derived from
// the registry so a bad catalog edit — e.g. chaining mini->nano — is caught.
func TestFastVariantPairsUnambiguous(t *testing.T) {
registry := testRegistry(t)
role := map[string]string{} // canonical id -> "base" | "fast"
assign := func(id, r string) {
entry, ok := registry.Resolve(id)
if !ok {
return
}
if prev, seen := role[entry.ID]; seen && prev != r {
t.Fatalf("model %q is declared as both a base and a fast variant", entry.ID)
}
role[entry.ID] = r
}
for _, entry := range registry.List(ListOptions{IncludeDeprecated: true}) {
if entry.FastVariantID == "" {
continue
}
assign(entry.ID, "base")
assign(entry.FastVariantID, "fast")
}
}
// The known pairs must be present and wired in both directions, and neither end
// may leak into the other role (a base is not itself fast; a fast has no fast).
func TestFastVariantAndBaseVariant(t *testing.T) {
registry := testRegistry(t)
for base, fast := range expectedFastPairs {
// base -> fast (forward), and the fast id has NO fast variant of its own.
if got, ok := registry.FastVariant(base); !ok {
t.Errorf("FastVariant(%q) = not found, want the fast variant", base)
} else if canon, _ := registry.Resolve(fast); got.ID != canon.ID {
t.Errorf("FastVariant(%q).ID = %q, want %q", base, got.ID, canon.ID)
}
if _, ok := registry.FastVariant(fast); ok {
t.Errorf("FastVariant(%q) resolved, but a fast model should have no fast variant", fast)
}
// fast -> base (reverse), and the base id is NOT itself a fast variant.
if got, ok := registry.BaseVariant(fast); !ok {
t.Errorf("BaseVariant(%q) = not found, want the base variant", fast)
} else if canon, _ := registry.Resolve(base); got.ID != canon.ID {
t.Errorf("BaseVariant(%q).ID = %q, want %q", fast, got.ID, canon.ID)
}
if _, ok := registry.BaseVariant(base); ok {
t.Errorf("BaseVariant(%q) resolved, but a base model is not a fast variant", base)
}
}
}
// HasFastVariant is the TUI marker predicate: true only for base models with an
// available fast variant, false for the fast models themselves, unpaired models,
// and unknown ids.
func TestHasFastVariant(t *testing.T) {
registry := testRegistry(t)
for base, fast := range expectedFastPairs {
if !registry.HasFastVariant(base) {
t.Errorf("HasFastVariant(%q) = false, want true", base)
}
if registry.HasFastVariant(fast) {
t.Errorf("HasFastVariant(%q) = true, want false (a fast model has no fast variant)", fast)
}
}
for _, id := range []string{"claude-opus-4.1", "definitely-not-a-real-model", ""} {
if registry.HasFastVariant(id) {
t.Errorf("HasFastVariant(%q) = true, want false", id)
}
}
}
func TestFastVariantAbsentAndUnknown(t *testing.T) {
registry := testRegistry(t)
// A real model with no configured fast/base variant reports none in both
// directions (a valid, handled case — /fast will say "no fast mode available").
const noVariant = "claude-opus-4.1"
if _, ok := registry.Resolve(noVariant); !ok {
t.Skipf("%q not in registry; skipping no-variant case", noVariant)
}
if _, ok := registry.FastVariant(noVariant); ok {
t.Errorf("FastVariant(%q) resolved, want none", noVariant)
}
if _, ok := registry.BaseVariant(noVariant); ok {
t.Errorf("BaseVariant(%q) resolved, want none", noVariant)
}
// An unknown id resolves to nothing in either direction.
if _, ok := registry.FastVariant("definitely-not-a-real-model"); ok {
t.Error("FastVariant(unknown) resolved, want none")
}
if _, ok := registry.BaseVariant("definitely-not-a-real-model"); ok {
t.Error("BaseVariant(unknown) resolved, want none")
}
}
+8
View File
@@ -42,6 +42,7 @@ const (
commandSelfCorrect
commandTurns
commandNew
commandFast
commandUnknown
)
@@ -235,6 +236,13 @@ var commandDefinitions = []commandDefinition{
description: "Show or set reasoning effort for supported models.",
kind: commandEffort,
},
{
name: "/fast",
usage: "/fast [on|off]",
group: commandGroupModel,
description: "Enable fast mode for the current model (/fast off to disable).",
kind: commandFast,
},
{
name: "/style",
usage: "/style [list|balanced|concise|explanatory|review]",
+111
View File
@@ -0,0 +1,111 @@
package tui
import (
"fmt"
"strings"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// fastPlan is the pure decision `/fast` makes before touching any state: either
// a terminal message to show the user, or a target model to switch to.
type fastPlan struct {
message string // terminal notice; shown as-is when targetID is empty
targetID string // model to switch to ("" means no switch — show message)
toFast bool // the target is a fast model (drives the ⚡ prefix)
}
// planFastCommand resolves what `/fast [on|off]` should do for the current model
// using only the registry — no side effects. State is DERIVED from the model
// identity: the session is "in fast mode" iff the current model has a base
// variant. Returns a terminal message, or a target model to switch to.
func planFastCommand(registry modelregistry.Registry, currentModel, arg string) fastPlan {
// 1. Parse the argument (case-insensitive): no arg / "on" => enable fast,
// "off" => return to base, anything else => usage error.
enable := true
switch strings.ToLower(strings.TrimSpace(arg)) {
case "", "on":
enable = true
case "off":
enable = false
default:
return fastPlan{message: fmt.Sprintf("⚡ /fast takes on or off, not %q", strings.TrimSpace(arg))}
}
// 2. Read the current model.
current := strings.TrimSpace(currentModel)
if current == "" {
return fastPlan{message: "⚡ Pick a model first · /model"}
}
short := modelShortName(registry, current)
// 3. Derived state: in fast mode iff the current model has a base variant.
_, isFast := registry.BaseVariant(current)
// 4. No-op cases — already in the requested state.
if enable && isFast {
return fastPlan{message: "⚡ Already in the fast lane · " + short}
}
if !enable && !isFast {
return fastPlan{message: "Already on base · " + short}
}
// 5. Resolve the target variant.
var target modelregistry.ModelEntry
var ok bool
if enable {
target, ok = registry.FastVariant(current)
} else {
target, ok = registry.BaseVariant(current)
}
if !ok {
return fastPlan{message: "⚡ No fast lane for " + short}
}
// Switching TO a fast model iff the target itself has a base variant.
_, toFast := registry.BaseVariant(target.ID)
return fastPlan{targetID: target.ID, toFast: toFast}
}
// handleFastCommand implements `/fast [on|off]`: toggle the session model between
// its base and fast variant within the same family. The actual switch is
// delegated to handleModelCommand — the same path /model uses — so provider
// rebuild, the compaction-before-switch guard, and persistence all apply; this
// never mutates model state directly.
func (m model) handleFastCommand(arg string) (model, string) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return m, "Failed to load the model registry: " + err.Error()
}
plan := planFastCommand(registry, m.modelName, arg)
if plan.targetID == "" {
return m, plan.message
}
var switchText string
m, switchText = m.handleModelCommand(plan.targetID)
if m.modelName != plan.targetID {
// The switch did not complete — either an error, or a "compact first"
// guard prompt from handleModelCommand. Surface its own message so the
// user sees the real reason instead of a misleading "Switched to ...".
return m, switchText
}
short := modelShortName(registry, plan.targetID)
if plan.toFast {
return m, "⚡ Fast lane on · " + short
}
return m, "Fast lane off · " + short
}
// modelShortName returns a model's short display name, falling back to the id
// when the model is unknown or has no display name.
func modelShortName(registry modelregistry.Registry, id string) string {
if entry, ok := registry.Get(id); ok {
if name := strings.TrimSpace(entry.DisplayName); name != "" {
return name
}
}
return strings.TrimSpace(id)
}
+200
View File
@@ -0,0 +1,200 @@
package tui
import (
"context"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func fastTestRegistry(t *testing.T) modelregistry.Registry {
t.Helper()
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry() error = %v", err)
}
return registry
}
// planFastCommand is the pure decision layer — every argument/state case is
// exercised here without touching the switch machinery.
func TestPlanFastCommand(t *testing.T) {
registry := fastTestRegistry(t)
cases := []struct {
name string
model string
arg string
wantMessage string // substring expected when the plan is terminal
wantTarget string // model id expected when the plan switches
wantToFast bool
}{
{name: "invalid arg", model: "gpt-4.1", arg: "turbo", wantMessage: `/fast takes on or off, not "turbo"`},
{name: "no model set", model: "", arg: "on", wantMessage: "Pick a model first"},
{name: "enable while already fast", model: "gpt-4.1-mini", arg: "on", wantMessage: "Already in the fast lane"},
{name: "disable while already base", model: "gpt-4.1", arg: "off", wantMessage: "Already on base"},
{name: "enable with no fast variant", model: "claude-opus-4.1", arg: "on", wantMessage: "No fast lane"},
{name: "enable (no arg) resolves fast", model: "gpt-4.1", arg: "", wantTarget: "gpt-4.1-mini", wantToFast: true},
{name: "enable (on) resolves fast", model: "gpt-4.1", arg: "ON", wantTarget: "gpt-4.1-mini", wantToFast: true},
{name: "disable resolves base", model: "gpt-4.1-mini", arg: "off", wantTarget: "gpt-4.1", wantToFast: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plan := planFastCommand(registry, tc.model, tc.arg)
if tc.wantTarget != "" {
canon, ok := registry.Resolve(tc.wantTarget)
if !ok {
t.Fatalf("test fixture %q does not resolve", tc.wantTarget)
}
if plan.targetID != canon.ID {
t.Fatalf("targetID = %q, want %q", plan.targetID, canon.ID)
}
if plan.toFast != tc.wantToFast {
t.Fatalf("toFast = %v, want %v", plan.toFast, tc.wantToFast)
}
if plan.message != "" {
t.Fatalf("switching plan should carry no message, got %q", plan.message)
}
return
}
if plan.targetID != "" {
t.Fatalf("expected a terminal message, got target %q", plan.targetID)
}
if !strings.Contains(plan.message, tc.wantMessage) {
t.Fatalf("message = %q, want it to contain %q", plan.message, tc.wantMessage)
}
})
}
}
// handleFastCommand's terminal cases return the plan message without switching,
// so a bare model exercises them.
func TestHandleFastCommandTerminalCases(t *testing.T) {
cases := []struct {
name string
model string
arg string
wantContains string
}{
{"invalid arg", "gpt-4.1", "sideways", `/fast takes on or off, not "sideways"`},
{"no model", "", "on", "Pick a model first"},
{"already fast", "gpt-4.1-mini", "on", "Already in the fast lane"},
{"already base", "gpt-4.1", "off", "Already on base"},
{"no fast variant", "claude-opus-4.1", "on", "No fast lane"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := model{modelName: tc.model}
got, notice := m.handleFastCommand(tc.arg)
if got.modelName != tc.model {
t.Fatalf("model must not change on a terminal case: %q -> %q", tc.model, got.modelName)
}
if !strings.Contains(notice, tc.wantContains) {
t.Fatalf("notice = %q, want it to contain %q", notice, tc.wantContains)
}
})
}
}
func newFastTestModel(t *testing.T, modelID string) model {
t.Helper()
configPath := filepath.Join(t.TempDir(), "zero.json")
profile := config.ProviderProfile{
Name: "openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: modelID,
}
if _, err := config.UpsertProvider(configPath, profile, true); err != nil {
t.Fatalf("write user config: %v", err)
}
return newModel(context.Background(), Options{
UserConfigPath: configPath,
ProviderName: "openai",
ModelName: modelID,
ProviderProfile: profile,
Provider: &fakeProvider{},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
}
// Enabling switches base -> fast with a ⚡ notice; disabling switches back to the
// base with a plain notice. Uses the same switch path /model uses.
func TestHandleFastCommandSwitchesToFastAndBack(t *testing.T) {
registry := fastTestRegistry(t)
m := newFastTestModel(t, "gpt-4.1")
m, notice := m.handleFastCommand("on")
if m.modelName != "gpt-4.1-mini" {
t.Fatalf("enable: modelName = %q, want gpt-4.1-mini", m.modelName)
}
if want := "⚡ Fast lane on · " + modelShortName(registry, "gpt-4.1-mini"); notice != want {
t.Fatalf("enable notice = %q, want exactly %q (bolt + fast-lane-on + target short name)", notice, want)
}
m, notice = m.handleFastCommand("off")
if m.modelName != "gpt-4.1" {
t.Fatalf("disable: modelName = %q, want gpt-4.1", m.modelName)
}
if want := "Fast lane off · " + modelShortName(registry, "gpt-4.1"); notice != want {
t.Fatalf("disable notice = %q, want exactly %q (plain fast-lane-off, no bolt)", notice, want)
}
}
// The no-op and no-variant notices must carry their full spec decoration — the ⚡
// prefix and the (<short>) name — so a regression dropping either fails loudly
// rather than passing a bare substring assertion.
func TestFastCommandNoOpAndNoVariantMessagesAreDecorated(t *testing.T) {
registry := fastTestRegistry(t)
plan := planFastCommand(registry, "gpt-4.1-mini", "on")
if want := "⚡ Already in the fast lane · " + modelShortName(registry, "gpt-4.1-mini"); plan.message != want {
t.Fatalf("already-fast message = %q, want %q", plan.message, want)
}
plan = planFastCommand(registry, "gpt-4.1", "off")
if want := "Already on base · " + modelShortName(registry, "gpt-4.1"); plan.message != want {
t.Fatalf("already-base message = %q, want %q", plan.message, want)
}
// An unknown / off-catalog model id also reaches the no-variant branch, where
// <short> falls back to the raw id.
plan = planFastCommand(registry, "some-custom-live-provider-model", "on")
if want := "⚡ No fast lane for some-custom-live-provider-model"; plan.message != want {
t.Fatalf("no-variant (unknown id) message = %q, want %q", plan.message, want)
}
}
// When the underlying switch fails, /fast surfaces the error and leaves the
// model unchanged (here: a valid base model but no provider profile to rebuild).
func TestHandleFastCommandSurfacesSwitchError(t *testing.T) {
m := model{modelName: "gpt-4.1"} // no providerProfile / newProvider wired
got, notice := m.handleFastCommand("on")
if got.modelName != "gpt-4.1" {
t.Fatalf("model must be unchanged on switch failure, got %q", got.modelName)
}
if strings.HasPrefix(notice, "⚡ Fast lane on ") || strings.HasPrefix(notice, "Fast lane off ") {
t.Fatalf("a failed switch must not claim success, got %q", notice)
}
if strings.TrimSpace(notice) == "" {
t.Fatal("a failed switch must surface a reason")
}
}
func TestModelShortName(t *testing.T) {
registry := fastTestRegistry(t)
if got := modelShortName(registry, "gpt-4.1"); strings.TrimSpace(got) == "" {
t.Fatal("a known model should resolve to a non-empty short name")
}
if got := modelShortName(registry, "totally-unknown-model-xyz"); got != "totally-unknown-model-xyz" {
t.Fatalf("an unknown model short name = %q, want the raw id", got)
}
}
+87
View File
@@ -0,0 +1,87 @@
package tui
import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// fastMarkerGlyph is the compact "f" badge shown next to a model that has an
// available fast variant — i.e. one where `/fast on` would switch down to a
// faster same-family model. It marks the BASE model, never the fast one.
const fastMarkerGlyph = "f"
// fastVariantEntryFor resolves the fast variant of an arbitrary model id, if any.
// Like modelContextWindow, it reads the default registry on demand rather than
// caching derived state, so a marker can never go stale after a model switch.
// DefaultRegistry is memoized, so even the per-frame callers (the active-model
// chip renders every frame; the pickers render per keystroke) pay only a map
// lookup here, not a registry rebuild.
func fastVariantEntryFor(id string) (modelregistry.ModelEntry, bool) {
id = strings.TrimSpace(id)
if id == "" {
return modelregistry.ModelEntry{}, false
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return modelregistry.ModelEntry{}, false
}
return registry.FastVariant(id)
}
// currentModelFastVariant resolves the fast variant of the session's active model.
func (m model) currentModelFastVariant() (modelregistry.ModelEntry, bool) {
return fastVariantEntryFor(m.modelName)
}
// renderFastBadge renders the "f" fast-mode marker in the given (surface-aware)
// style, prefixed with a space so it reads as a distinct badge rather than a
// trailing letter of the adjacent model name.
func renderFastBadge(style lipgloss.Style) string {
return " " + style.Render(fastMarkerGlyph)
}
// fastVariantHint is the one-line reveal shown against a focused/selected
// fast-capable model. The TUI has no floating-tooltip primitive, so the "hover"
// affordance is this inline detail on the active row: it names the target model
// and the command that gets there.
func fastVariantHint(variantName string) string {
variantName = strings.TrimSpace(variantName)
if variantName == "" {
return ""
}
return "⚡ fast: " + variantName + " · /fast on"
}
// fastToastDuration is how long a /fast toast stays up before auto-dismissing.
const fastToastDuration = 3 * time.Second
// fastToastExpiredMsg clears a fast-lane toast after fastToastDuration. It is
// seq-gated so a newer toast is never dismissed by an older timer.
type fastToastExpiredMsg struct{ seq int }
// showFastToast raises a transient fast-lane notice in the row above the composer
// and schedules its dismissal. It replaces the persistent transcript line the
// /fast command used to append, so repeated toggles never pile up in history.
func (m model) showFastToast(text string) (model, tea.Cmd) {
text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " "))
if text == "" {
return m, nil
}
m.fastToastSeq++
m.fastToast = text
seq := m.fastToastSeq
return m, tea.Tick(fastToastDuration, func(time.Time) tea.Msg {
return fastToastExpiredMsg{seq: seq}
})
}
// renderFastToast styles the toast as a filled brand pill so it reads as a
// transient popup rather than a permanent status line.
func renderFastToast(text string) string {
return zeroTheme.badge.Render(" " + strings.TrimSpace(text) + " ")
}
+151
View File
@@ -0,0 +1,151 @@
package tui
import (
"strings"
"testing"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/modelregistry"
)
// hasFastBadge reports whether a rendered (styled) line ends with the " f"
// fast-mode badge, ignoring trailing layout padding. Labels used in these tests
// deliberately contain no trailing " f" so the badge is unambiguous.
func hasFastBadge(rendered string) bool {
return strings.HasSuffix(strings.TrimRight(ansi.Strip(rendered), " "), " "+fastMarkerGlyph)
}
func TestFastVariantHint(t *testing.T) {
if got := fastVariantHint("Claude Haiku 4.5"); got != "⚡ fast: Claude Haiku 4.5 · /fast on" {
t.Errorf("fastVariantHint = %q", got)
}
if got := fastVariantHint(" "); got != "" {
t.Errorf("fastVariantHint(blank) = %q, want empty", got)
}
}
func TestRenderFastBadge(t *testing.T) {
if got := ansi.Strip(renderFastBadge(zeroTheme.accent)); got != " "+fastMarkerGlyph {
t.Errorf("renderFastBadge visible text = %q, want %q", got, " "+fastMarkerGlyph)
}
}
// The active-model chip carries the "f" badge only when the current model has a
// fast variant — base models yes, fast models and unpaired top-tier models no.
func TestTitleModelSegmentFastBadge(t *testing.T) {
cases := []struct {
model string
want bool
}{
{"gpt-4.1", true}, // base with a fast variant
{"gpt-4.1-mini", false}, // the fast variant itself has none
{"claude-opus-4.1", false}, // top-tier, intentionally unpaired
{"definitely-not-real", false},
{"", false},
}
for _, tc := range cases {
seg := model{modelName: tc.model}.titleModelSegment()
if got := hasFastBadge(seg); got != tc.want {
t.Errorf("titleModelSegment(%q) badge = %v, want %v (visible %q)", tc.model, got, tc.want, ansi.Strip(seg))
}
}
}
// A /model picker row shows the badge iff its FastVariant field is populated,
// and the field is populated only for base models by decorateFastVariantItems.
func TestRenderModelPickerRowFastBadge(t *testing.T) {
withFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
if !hasFastBadge(withFast) {
t.Errorf("fast-capable row missing badge: %q", ansi.Strip(withFast))
}
withoutFast := renderModelPickerRow(60, false, pickerItem{Label: "gpt-4.1", Value: "gpt-4.1"})
if hasFastBadge(withoutFast) {
t.Errorf("row without FastVariant should have no badge: %q", ansi.Strip(withoutFast))
}
}
func TestModelPickerItemDetailIncludesFastHint(t *testing.T) {
detail := modelPickerItemDetail(pickerItem{Label: "GPT-4.1", Value: "gpt-4.1", FastVariant: "GPT-4.1 mini"})
if !strings.Contains(detail, "⚡ fast: GPT-4.1 mini · /fast on") {
t.Errorf("detail missing fast hint: %q", detail)
}
plain := modelPickerItemDetail(pickerItem{Label: "Claude Opus 4.1", Value: "claude-opus-4.1"})
if strings.Contains(plain, "fast:") {
t.Errorf("non-fast detail should have no hint: %q", plain)
}
}
// decorateFastVariantItems stamps the fast variant's display name onto base-model
// rows only, using the real registry — the single source of truth for the badge.
func TestDecorateFastVariantItems(t *testing.T) {
registry, err := modelregistry.DefaultRegistry()
if err != nil {
t.Fatalf("DefaultRegistry: %v", err)
}
items := []pickerItem{
{Value: "gpt-4.1"}, // base -> GPT-4.1 mini
{Value: "gpt-4.1-mini"}, // fast, no variant
{Value: "claude-opus-4.1"}, // unpaired
{Value: "some-custom-model"}, // not in registry
{Value: ""},
}
decorateFastVariantItems(registry, items)
if items[0].FastVariant != "GPT-4.1 mini" {
t.Errorf("gpt-4.1 FastVariant = %q, want %q", items[0].FastVariant, "GPT-4.1 mini")
}
for _, i := range []int{1, 2, 3, 4} {
if items[i].FastVariant != "" {
t.Errorf("items[%d].FastVariant = %q, want empty", i, items[i].FastVariant)
}
}
}
func TestShowFastToast(t *testing.T) {
// A non-empty message raises the toast, bumps the seq, and schedules dismissal.
m, cmd := model{}.showFastToast("⚡ Fast lane on · GPT-4.1 mini")
if m.fastToast != "⚡ Fast lane on · GPT-4.1 mini" {
t.Fatalf("fastToast = %q", m.fastToast)
}
if m.fastToastSeq != 1 {
t.Fatalf("fastToastSeq = %d, want 1", m.fastToastSeq)
}
if cmd == nil {
t.Fatal("showFastToast must return a dismissal tick cmd")
}
// A multi-line switch-guard message is flattened so the toast stays one line
// (the footer slot is fixed-height).
m2, _ := model{}.showFastToast("Model\nCannot switch while a run is active.")
if strings.Contains(m2.fastToast, "\n") {
t.Fatalf("toast must be flattened to one line, got %q", m2.fastToast)
}
// Blank text is a no-op: no toast, no timer.
m3, cmd3 := model{}.showFastToast(" ")
if m3.fastToast != "" || cmd3 != nil {
t.Fatalf("blank text should not raise a toast, got %q / cmd!=nil=%v", m3.fastToast, cmd3 != nil)
}
}
func TestRenderFastToastShowsMessage(t *testing.T) {
got := ansi.Strip(renderFastToast("⚡ No fast lane for gpt-5.5"))
if !strings.Contains(got, "⚡ No fast lane for gpt-5.5") {
t.Fatalf("rendered toast = %q, want it to contain the message", got)
}
}
func TestProviderWizardModelFastBadgeAndHint(t *testing.T) {
wizard := &providerWizardState{selectedModel: 0}
withFast := wizard.renderSelectableModel(60, 0, providerWizardModel{ID: "gpt-4.1"})
if !hasFastBadge(withFast) {
t.Errorf("provider wizard base-model row missing badge: %q", ansi.Strip(withFast))
}
plain := wizard.renderSelectableModel(60, 1, providerWizardModel{ID: "claude-opus-4.1"})
if hasFastBadge(plain) {
t.Errorf("provider wizard unpaired-model row should have no badge: %q", ansi.Strip(plain))
}
if detail := providerWizardModelDetail(providerWizardModel{ID: "gpt-4.1"}); !strings.Contains(detail, "⚡ fast:") {
t.Errorf("provider wizard detail missing fast hint: %q", detail)
}
}
+22 -4
View File
@@ -326,9 +326,14 @@ type model struct {
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
// (see wantsMouseCapture) since idle cursor movement carries no button.
hover hoverTarget
copyStatus string
copyStatusSeq int
hover hoverTarget
copyStatus string
copyStatusSeq int
// fastToast is a transient /fast notice shown in the row above the composer for
// fastToastDuration, then auto-cleared (seq-gated). It keeps ephemeral fast-lane
// feedback out of the persistent transcript.
fastToast string
fastToastSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
@@ -1015,6 +1020,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.copyStatus = ""
}
return m, nil
case fastToastExpiredMsg:
if msg.seq == m.fastToastSeq {
m.fastToast = ""
}
return m, nil
case exitConfirmExpiredMsg:
if msg.seq == m.exitConfirmSeq {
m.exitConfirmActive = false
@@ -2347,7 +2357,9 @@ func (m model) footerView(width int) string {
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
// cue on the right when scrolled up. Always one line (blank when nothing shows),
// so the footer height is unchanged.
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
if toast := strings.TrimSpace(m.fastToast); toast != "" {
footer.WriteString(rightAlignedLine(renderFastToast(toast), width))
} else if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
@@ -3814,6 +3826,12 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m, text = m.handleEffortCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandFast:
var text string
m, text = m.handleFastCommand(command.text)
// Fast-lane feedback is ephemeral: show it as a transient toast above the
// composer (auto-dismissed) instead of a permanent transcript line.
return m.showFastToast(text)
case commandStyle:
text := ""
m, text = m.handleStyleCommand(command.text)
+30
View File
@@ -43,6 +43,10 @@ type pickerItem struct {
Remote bool
Local bool
Favorite bool
// FastVariant is the display name of this model's faster same-family variant
// (empty when it has none). Set only for /model rows; drives the "f" fast-mode
// badge and the focused-row "⚡ fast: … · /fast on" hint.
FastVariant string
}
// commandPicker is a generic single-select overlay reused by /model and /effort
@@ -155,9 +159,35 @@ func (m model) newModelPicker() *commandPicker {
if len(items) == 0 {
return nil
}
decorateFastVariantItems(registry, items)
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
}
// decorateFastVariantItems stamps each /model row with the display name of its
// fast variant (if any), in one pass over the assembled list rather than in every
// item builder. Rows whose model id is not in the curated registry (custom or
// live-discovered) simply keep an empty FastVariant and show no badge.
func decorateFastVariantItems(registry modelregistry.Registry, items []pickerItem) {
for index := range items {
id := strings.TrimSpace(items[index].Value)
if id == "" {
continue
}
if fast, ok := registry.FastVariant(id); ok {
items[index].FastVariant = fastVariantDisplayName(fast)
}
}
}
// fastVariantDisplayName is the user-facing name of a fast variant, preferring
// its display name and falling back to the id.
func fastVariantDisplayName(entry modelregistry.ModelEntry) string {
if name := strings.TrimSpace(entry.DisplayName); name != "" {
return name
}
return strings.TrimSpace(entry.ID)
}
// modelPickerProviders returns the providers to list in /model: all saved
// providers, falling back to the active profile when none were threaded in.
func (m model) modelPickerProviders() []config.ProviderProfile {
+6
View File
@@ -1567,6 +1567,9 @@ func (wizard *providerWizardState) renderSelectableModel(width int, index int, m
marker = surface(zeroTheme.accent).Render("❯ ")
}
left := marker + surface(zeroTheme.ink).Render(model.displayLabel())
if _, ok := fastVariantEntryFor(model.ID); ok {
left += renderFastBadge(surface(zeroTheme.accent))
}
return fitStyledLine(left, width)
}
@@ -1578,6 +1581,9 @@ func providerWizardModelDetail(model providerWizardModel) string {
if meta := strings.TrimSpace(model.Meta); meta != "" {
parts = append(parts, meta)
}
if fast, ok := fastVariantEntryFor(model.ID); ok {
parts = append(parts, fastVariantHint(fastVariantDisplayName(fast)))
}
return strings.Join(parts, " · ")
}
+20 -2
View File
@@ -153,16 +153,23 @@ func (m model) titlePRSegment() string {
func (m model) titleModelSegment() string {
provider := strings.TrimSpace(m.providerDisplayName())
model := strings.TrimSpace(m.modelName)
var segment string
switch {
case provider == "" && model == "":
return zeroTheme.muted.Render("no provider")
case model == "":
return zeroTheme.ink.Render(provider)
case provider == "":
return zeroTheme.ink.Render(model)
segment = zeroTheme.ink.Render(model)
default:
return zeroTheme.ink.Render(provider + "/" + model)
segment = zeroTheme.ink.Render(provider + "/" + model)
}
// Mark the active model with a quiet "f" when a fast variant is available, so
// the user knows /fast is on offer here without opening a picker.
if _, ok := m.currentModelFastVariant(); ok {
segment += renderFastBadge(zeroTheme.accent)
}
return segment
}
func (m model) composerDividerLine(width int) string {
@@ -866,6 +873,9 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int {
if item.Favorite {
labelWidth += lipgloss.Width("* ")
}
if strings.TrimSpace(item.FastVariant) != "" {
labelWidth += lipgloss.Width(renderFastBadge(zeroTheme.accent))
}
target = maxInt(target, lipgloss.Width("❯ ")+labelWidth)
if detail := modelPickerItemDetail(item); detail != "" {
target = maxInt(target, lipgloss.Width(" "+detail))
@@ -902,6 +912,11 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string {
prefix = "* "
}
left := marker + surface(zeroTheme.ink).Render(prefix+label)
// A quiet "f" badge marks rows whose model has a fast variant; the focused-row
// detail line spells out the target and command.
if strings.TrimSpace(item.FastVariant) != "" {
left += renderFastBadge(surface(zeroTheme.accent))
}
// The provider is shown as a section header above each group, so rows no longer
// repeat it as a right-aligned tag (matches a grouped provider+model list).
return fillPaletteLine(left, width, surface)
@@ -917,6 +932,9 @@ func modelPickerItemDetail(item pickerItem) string {
if meta := strings.TrimSpace(item.Meta); meta != "" {
parts = append(parts, meta)
}
if hint := fastVariantHint(item.FastVariant); hint != "" {
parts = append(parts, hint)
}
return strings.Join(parts, " · ")
}