mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533b41495d | ||
|
|
bb66a44ab3 | ||
|
|
6f97c90316 |
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
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]",
|
||||
|
||||
@@ -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 —
|
||||
// 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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, " · ")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user