mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-12 08:41:32 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533b41495d | ||
|
|
bb66a44ab3 | ||
|
|
6f97c90316 |
No files matched your search
@@ -3,6 +3,7 @@ package modelregistry
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultModelID = "gpt-4.1"
|
const DefaultModelID = "gpt-4.1"
|
||||||
@@ -42,8 +43,20 @@ type ListOptions struct {
|
|||||||
Capability ModelCapability
|
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())
|
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 {
|
func DefaultModelEntries() []ModelEntry {
|
||||||
@@ -76,11 +89,13 @@ func decorateModelDepth(entries []ModelEntry) {
|
|||||||
patterns []string
|
patterns []string
|
||||||
deprecation *DeprecationRule
|
deprecation *DeprecationRule
|
||||||
upgradeTarget string
|
upgradeTarget string
|
||||||
|
fastVariant string
|
||||||
}{
|
}{
|
||||||
"claude-sonnet-4.5": {
|
"claude-sonnet-4.5": {
|
||||||
defaultEffort: ReasoningEffortMedium,
|
defaultEffort: ReasoningEffortMedium,
|
||||||
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
|
patterns: []string{`(?i)^sonnet[^a-z0-9]*4[.\s]?5$`},
|
||||||
upgradeTarget: "claude-opus-4.1",
|
upgradeTarget: "claude-opus-4.1",
|
||||||
|
fastVariant: "claude-haiku-4.5",
|
||||||
},
|
},
|
||||||
"claude-opus-4.1": {
|
"claude-opus-4.1": {
|
||||||
defaultEffort: ReasoningEffortHigh,
|
defaultEffort: ReasoningEffortHigh,
|
||||||
@@ -94,6 +109,7 @@ func decorateModelDepth(entries []ModelEntry) {
|
|||||||
"gemini-2.5-pro": {
|
"gemini-2.5-pro": {
|
||||||
defaultEffort: ReasoningEffortMedium,
|
defaultEffort: ReasoningEffortMedium,
|
||||||
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
|
patterns: []string{`(?i)^gemini[^a-z0-9]*pro$`},
|
||||||
|
fastVariant: "gemini-2.5-flash",
|
||||||
},
|
},
|
||||||
"gemini-2.5-flash": {
|
"gemini-2.5-flash": {
|
||||||
defaultEffort: ReasoningEffortLow,
|
defaultEffort: ReasoningEffortLow,
|
||||||
@@ -102,6 +118,12 @@ func decorateModelDepth(entries []ModelEntry) {
|
|||||||
"gemini-2.5-flash-lite": {
|
"gemini-2.5-flash-lite": {
|
||||||
upgradeTarget: "gemini-2.5-flash",
|
upgradeTarget: "gemini-2.5-flash",
|
||||||
},
|
},
|
||||||
|
"gpt-4.1": {
|
||||||
|
fastVariant: "gpt-4.1-mini",
|
||||||
|
},
|
||||||
|
"gpt-4o": {
|
||||||
|
fastVariant: "gpt-4o-mini",
|
||||||
|
},
|
||||||
"gpt-4.1-mini": {
|
"gpt-4.1-mini": {
|
||||||
upgradeTarget: "gpt-4.1",
|
upgradeTarget: "gpt-4.1",
|
||||||
},
|
},
|
||||||
@@ -143,6 +165,9 @@ func decorateModelDepth(entries []ModelEntry) {
|
|||||||
if extra.upgradeTarget != "" {
|
if extra.upgradeTarget != "" {
|
||||||
entries[index].UpgradeTargetID = extra.upgradeTarget
|
entries[index].UpgradeTargetID = extra.upgradeTarget
|
||||||
}
|
}
|
||||||
|
if extra.fastVariant != "" {
|
||||||
|
entries[index].FastVariantID = extra.fastVariant
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,14 @@ type ModelEntry struct {
|
|||||||
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
|
// during mid-run escalation. Empty means no escalation target (e.g. top-tier
|
||||||
// models). Resolved and availability-checked by Registry.UpgradeTarget.
|
// models). Resolved and availability-checked by Registry.UpgradeTarget.
|
||||||
UpgradeTargetID string
|
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
|
// 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)
|
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
|
return registry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ const (
|
|||||||
commandSelfCorrect
|
commandSelfCorrect
|
||||||
commandTurns
|
commandTurns
|
||||||
commandNew
|
commandNew
|
||||||
|
commandFast
|
||||||
commandUnknown
|
commandUnknown
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,6 +236,13 @@ var commandDefinitions = []commandDefinition{
|
|||||||
description: "Show or set reasoning effort for supported models.",
|
description: "Show or set reasoning effort for supported models.",
|
||||||
kind: commandEffort,
|
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",
|
name: "/style",
|
||||||
usage: "/style [list|balanced|concise|explanatory|review]",
|
usage: "/style [list|balanced|concise|explanatory|review]",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) + " ")
|
||||||
|
}
|
||||||
@@ -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
@@ -326,9 +326,14 @@ type model struct {
|
|||||||
// mouse cursor with no button pressed, so it renders in a distinct style —
|
// mouse cursor with no button pressed, so it renders in a distinct style —
|
||||||
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
// the visual cue that it's clickable. Requires AllMotion mouse reporting
|
||||||
// (see wantsMouseCapture) since idle cursor movement carries no button.
|
// (see wantsMouseCapture) since idle cursor movement carries no button.
|
||||||
hover hoverTarget
|
hover hoverTarget
|
||||||
copyStatus string
|
copyStatus string
|
||||||
copyStatusSeq int
|
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
|
exitConfirmActive bool
|
||||||
exitConfirmSeq int
|
exitConfirmSeq int
|
||||||
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
|
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
|
||||||
@@ -1015,6 +1020,11 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.copyStatus = ""
|
m.copyStatus = ""
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case fastToastExpiredMsg:
|
||||||
|
if msg.seq == m.fastToastSeq {
|
||||||
|
m.fastToast = ""
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
case exitConfirmExpiredMsg:
|
case exitConfirmExpiredMsg:
|
||||||
if msg.seq == m.exitConfirmSeq {
|
if msg.seq == m.exitConfirmSeq {
|
||||||
m.exitConfirmActive = false
|
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
|
// 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),
|
// cue on the right when scrolled up. Always one line (blank when nothing shows),
|
||||||
// so the footer height is unchanged.
|
// 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))
|
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
|
||||||
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
|
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
|
||||||
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
|
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, text = m.handleEffortCommand(command.text)
|
||||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||||
return m, nil
|
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:
|
case commandStyle:
|
||||||
text := ""
|
text := ""
|
||||||
m, text = m.handleStyleCommand(command.text)
|
m, text = m.handleStyleCommand(command.text)
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ type pickerItem struct {
|
|||||||
Remote bool
|
Remote bool
|
||||||
Local bool
|
Local bool
|
||||||
Favorite 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
|
// 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 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
decorateFastVariantItems(registry, items)
|
||||||
return &commandPicker{kind: pickerModel, title: "Choose a model", items: items, allItems: append([]pickerItem{}, items...), selected: 0}
|
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
|
// modelPickerProviders returns the providers to list in /model: all saved
|
||||||
// providers, falling back to the active profile when none were threaded in.
|
// providers, falling back to the active profile when none were threaded in.
|
||||||
func (m model) modelPickerProviders() []config.ProviderProfile {
|
func (m model) modelPickerProviders() []config.ProviderProfile {
|
||||||
|
|||||||
@@ -1567,6 +1567,9 @@ func (wizard *providerWizardState) renderSelectableModel(width int, index int, m
|
|||||||
marker = surface(zeroTheme.accent).Render("❯ ")
|
marker = surface(zeroTheme.accent).Render("❯ ")
|
||||||
}
|
}
|
||||||
left := marker + surface(zeroTheme.ink).Render(model.displayLabel())
|
left := marker + surface(zeroTheme.ink).Render(model.displayLabel())
|
||||||
|
if _, ok := fastVariantEntryFor(model.ID); ok {
|
||||||
|
left += renderFastBadge(surface(zeroTheme.accent))
|
||||||
|
}
|
||||||
return fitStyledLine(left, width)
|
return fitStyledLine(left, width)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1578,6 +1581,9 @@ func providerWizardModelDetail(model providerWizardModel) string {
|
|||||||
if meta := strings.TrimSpace(model.Meta); meta != "" {
|
if meta := strings.TrimSpace(model.Meta); meta != "" {
|
||||||
parts = append(parts, meta)
|
parts = append(parts, meta)
|
||||||
}
|
}
|
||||||
|
if fast, ok := fastVariantEntryFor(model.ID); ok {
|
||||||
|
parts = append(parts, fastVariantHint(fastVariantDisplayName(fast)))
|
||||||
|
}
|
||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-2
@@ -153,16 +153,23 @@ func (m model) titlePRSegment() string {
|
|||||||
func (m model) titleModelSegment() string {
|
func (m model) titleModelSegment() string {
|
||||||
provider := strings.TrimSpace(m.providerDisplayName())
|
provider := strings.TrimSpace(m.providerDisplayName())
|
||||||
model := strings.TrimSpace(m.modelName)
|
model := strings.TrimSpace(m.modelName)
|
||||||
|
var segment string
|
||||||
switch {
|
switch {
|
||||||
case provider == "" && model == "":
|
case provider == "" && model == "":
|
||||||
return zeroTheme.muted.Render("no provider")
|
return zeroTheme.muted.Render("no provider")
|
||||||
case model == "":
|
case model == "":
|
||||||
return zeroTheme.ink.Render(provider)
|
return zeroTheme.ink.Render(provider)
|
||||||
case provider == "":
|
case provider == "":
|
||||||
return zeroTheme.ink.Render(model)
|
segment = zeroTheme.ink.Render(model)
|
||||||
default:
|
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 {
|
func (m model) composerDividerLine(width int) string {
|
||||||
@@ -866,6 +873,9 @@ func modelPickerOverlayWidth(terminalWidth int, picker *commandPicker) int {
|
|||||||
if item.Favorite {
|
if item.Favorite {
|
||||||
labelWidth += lipgloss.Width("* ")
|
labelWidth += lipgloss.Width("* ")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(item.FastVariant) != "" {
|
||||||
|
labelWidth += lipgloss.Width(renderFastBadge(zeroTheme.accent))
|
||||||
|
}
|
||||||
target = maxInt(target, lipgloss.Width("❯ ")+labelWidth)
|
target = maxInt(target, lipgloss.Width("❯ ")+labelWidth)
|
||||||
if detail := modelPickerItemDetail(item); detail != "" {
|
if detail := modelPickerItemDetail(item); detail != "" {
|
||||||
target = maxInt(target, lipgloss.Width(" "+detail))
|
target = maxInt(target, lipgloss.Width(" "+detail))
|
||||||
@@ -902,6 +912,11 @@ func renderModelPickerRow(width int, selected bool, item pickerItem) string {
|
|||||||
prefix = "* "
|
prefix = "* "
|
||||||
}
|
}
|
||||||
left := marker + surface(zeroTheme.ink).Render(prefix+label)
|
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
|
// 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).
|
// repeat it as a right-aligned tag (matches a grouped provider+model list).
|
||||||
return fillPaletteLine(left, width, surface)
|
return fillPaletteLine(left, width, surface)
|
||||||
@@ -917,6 +932,9 @@ func modelPickerItemDetail(item pickerItem) string {
|
|||||||
if meta := strings.TrimSpace(item.Meta); meta != "" {
|
if meta := strings.TrimSpace(item.Meta); meta != "" {
|
||||||
parts = append(parts, meta)
|
parts = append(parts, meta)
|
||||||
}
|
}
|
||||||
|
if hint := fastVariantHint(item.FastVariant); hint != "" {
|
||||||
|
parts = append(parts, hint)
|
||||||
|
}
|
||||||
return strings.Join(parts, " · ")
|
return strings.Join(parts, " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user