Skip to content

Commit 32320b9

Browse files
author
gluck
committed
:models is a pit you choose in, and Enter sets the model
`:models` in the command box printed the table `figaro models` prints, with os.Stdout: round the pager's writer, onto the terminal. Nothing on it could be acted on. It is now an itemised live view (itemView, like the form and the queue), so the pit's one picker drives it: ^N/^P and j/k move, the wash and the marker show the choice, `y` yanks the model id, `/` searches, G and gg reach the ends of three hundred models. The subject's current model carries a ●, and each row carries its provider. ENTER SETS THE MODEL, and its provider with it, in one patch: system.model to the chosen id and system.provider to the provider that listed it. A model is only meaningful to its own provider, and choosing copilot's model while the board still said anthropic would send a name Anthropic has never heard of on the next turn. When the provider already matches, the store drops the unchanged key and the write is exactly `figaro set system.model <id>`. The bar says what took; the ● moves. Activate hands off to a goroutine: it runs under the render lock and the set dials. `/` could not reach a provider: the heading is chrome and search walks selectable rows. The picker now searches a row's note as well as its text -- what a reader sees is what `/` finds -- which also makes the tab pit's descriptions searchable. The standalone `figaro models` shares the fetch and now writes to the package's stdout, so it can never paint over a pager again. Verified with the flake build in an isolated .#sandbox shell, real credentials: :models lists every configured provider's models, Enter on claude-opus-5-5 set it (figaro state agrees) and the next turn was answered by it; Enter on a copilot model set model and provider together and the next turn was answered through copilot; Enter back on anthropic restored both.
1 parent 635d7ef commit 32320b9

5 files changed

Lines changed: 358 additions & 26 deletions

File tree

‎internal/cli/command_router.go‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ package cli
2020

2121
import (
2222
"context"
23+
"encoding/json"
2324
"fmt"
25+
"github.com/jack-work/figaro/api/form"
2426
"github.com/jack-work/figaro/internal/cmdkit"
2527
"strings"
2628
"sync"
@@ -103,6 +105,9 @@ var overlayVerbs = map[string]bool{
103105
"kill": true, // it can kill the aria on screen, and must then move
104106
"import": true, // reads os.Stdin for `-` (portable.go)
105107
"login": true, // an interactive OAuth flow with its own prompts
108+
// models is a PIT, not a table: a list you choose the subject's model
109+
// in (models_view.go). As a routed verb it printed to os.Stdout.
110+
"models": true,
106111
}
107112

108113
// terminalSubverbs are verb+subverb pairs that take the terminal, which a bare
@@ -232,11 +237,83 @@ func (in *interactiveInput) runOverlay(verb string, args []string) {
232237
in.commandAsync(func(ctx context.Context) (string, error) {
233238
return in.commandFork(ctx, args)
234239
})
240+
case "models":
241+
in.openModels()
235242
default:
236243
in.note(fmt.Sprintf("%s: not available inside the pager (it opens a view of its own)", verb))
237244
}
238245
}
239246

247+
// openModels hosts the models pit. Asking every provider dials, so the list is
248+
// fetched off the input goroutine and installed under the render lock, as a
249+
// live form view is (openLive).
250+
func (in *interactiveInput) openModels() {
251+
in.note("…models")
252+
go func() {
253+
models, warnings := fetchModels(in.loaded)
254+
current := in.subjectModel()
255+
view := newModelsView(models, current, in.chooseModel)
256+
in.mu.Lock()
257+
in.lt.tr.showLivePit("models", view, false)
258+
in.mu.Unlock()
259+
if len(models) == 0 && len(warnings) > 0 {
260+
in.note("models: " + strings.Join(warnings, "; "))
261+
}
262+
}()
263+
}
264+
265+
// subjectModel is the aria on screen's system.model, or "" when it cannot be
266+
// read: the ● in the list is a courtesy, not a precondition.
267+
func (in *interactiveInput) subjectModel() string {
268+
cli := in.aria()
269+
if cli == nil {
270+
return ""
271+
}
272+
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
273+
defer cancel()
274+
resp, err := cli.Form(ctx)
275+
if err != nil {
276+
return ""
277+
}
278+
raw, ok := resp.Snapshot.Get("system.model")
279+
if !ok {
280+
return ""
281+
}
282+
return valueSummary(raw)
283+
}
284+
285+
// chooseModel is Enter in the models pit: the subject's system.model becomes
286+
// the chosen id, and system.provider the provider that listed it, in ONE
287+
// patch. A model is only meaningful to its own provider; choosing copilot's
288+
// gpt-5 while the board still said anthropic would send a name Anthropic has
289+
// never heard of on the next turn. When the provider already matches, the
290+
// write lands only the model (the store drops an unchanged key), which is
291+
// exactly `figaro set system.model <id>`. It runs on its own goroutine.
292+
func (in *interactiveInput) chooseModel(c modelChoice) {
293+
cli := in.aria()
294+
id := in.currentID()
295+
if cli == nil {
296+
in.noteErr("models: no aria on screen to set")
297+
return
298+
}
299+
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
300+
defer cancel()
301+
set := map[string]json.RawMessage{"system.model": mustJSON(c.model)}
302+
if c.provider != "" {
303+
set["system.provider"] = mustJSON(c.provider)
304+
}
305+
if _, err := cli.Set(ctx, form.Build(form.Snapshot{}, set, nil), 0); err != nil {
306+
in.noteErr("models: " + wireErrorText(err))
307+
return
308+
}
309+
in.note(fmt.Sprintf("model: %s (%s) on %s", c.model, c.provider, id))
310+
}
311+
312+
func mustJSON(s string) json.RawMessage {
313+
b, _ := json.Marshal(s)
314+
return b
315+
}
316+
240317
// ---------------------------------------------------------------------------
241318
// The router, run in-process.
242319
// ---------------------------------------------------------------------------

‎internal/cli/models_view.go‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package cli
2+
3+
// :models AS A PIT YOU CHOOSE IN.
4+
//
5+
// It was a table printed with os.Stdout: round the pager's writer, onto the
6+
// terminal, and nothing on it could be acted on. It is now an itemised live
7+
// view (itemView, pit.go), so the pit's one picker drives it exactly as it
8+
// drives the queue, the form and the tab menu: ^N/^P and j/k move, the wash
9+
// and the marker show the choice, `y` yanks the id, `/` searches. Enter is
10+
// this view's own action, and it makes the chosen model the subject's.
11+
12+
import (
13+
"context"
14+
"fmt"
15+
"strconv"
16+
"strings"
17+
"sync"
18+
"time"
19+
20+
"github.com/jack-work/figaro/internal/config"
21+
"github.com/jack-work/figaro/internal/provider"
22+
"github.com/mattn/go-runewidth"
23+
)
24+
25+
// modelChoice is what Enter chose: a model is only meaningful to its own
26+
// provider, so the two travel together.
27+
type modelChoice struct {
28+
provider string
29+
model string
30+
}
31+
32+
// modelsView is the pit's model: the list, which one the subject is on, and
33+
// what to do when one is chosen.
34+
type modelsView struct {
35+
mu sync.Mutex
36+
models []provider.ModelInfo
37+
current string
38+
// choose applies a choice. It dials, so Activate never calls it on the
39+
// input goroutine: Activate runs under the render lock.
40+
choose func(modelChoice)
41+
}
42+
43+
func newModelsView(models []provider.ModelInfo, current string, choose func(modelChoice)) *modelsView {
44+
return &modelsView{models: models, current: current, choose: choose}
45+
}
46+
47+
// modelRowID addresses a row by position, because one model id can be served
48+
// by two providers (a gateway re-exporting Anthropic's names) and the row, not
49+
// the id, is what was chosen.
50+
func modelRowID(i int) string { return "model:" + strconv.Itoa(i) }
51+
52+
// Items renders one row per model, grouped by provider under a heading. The
53+
// subject's current model carries ● so a reader knows where they are before
54+
// choosing where to go.
55+
func (v *modelsView) Items(width int) []pitRow {
56+
v.mu.Lock()
57+
defer v.mu.Unlock()
58+
idW := 0
59+
for _, m := range v.models {
60+
idW = max(idW, runewidth.StringWidth(m.ID))
61+
}
62+
idW = min(idW, max(width/2, 16))
63+
var rows []pitRow
64+
last := ""
65+
for i, m := range v.models {
66+
if m.Provider != last {
67+
rows = append(rows, staticRow(m.Provider))
68+
last = m.Provider
69+
}
70+
mark := " "
71+
if m.ID == v.current {
72+
mark = "●"
73+
}
74+
text := fmt.Sprintf("%s %s %s", mark, padTo(clipHead(m.ID, idW), idW), modelDetail(m))
75+
// The provider rides every row as its note, not only the heading: `/`
76+
// walks selectable rows, and a heading is chrome, so `/copilot` found
77+
// nothing among three hundred models until the row said it.
78+
rows = append(rows, pitRow{text: text, note: m.Provider, yank: m.ID, id: modelRowID(i)})
79+
}
80+
if len(rows) == 0 {
81+
rows = append(rows, staticRow("no models: no provider answered (figaro login <provider>)"))
82+
}
83+
return rows
84+
}
85+
86+
// modelDetail is the row's second column: the name when it says more than the
87+
// id, and the context window and output cap when the provider reported them.
88+
func modelDetail(m provider.ModelInfo) string {
89+
var parts []string
90+
if m.Name != "" && !strings.EqualFold(m.Name, m.ID) {
91+
parts = append(parts, m.Name)
92+
}
93+
if m.ContextWindow > 0 {
94+
parts = append(parts, dashCount(m.ContextWindow)+" ctx")
95+
}
96+
if m.MaxTokens > 0 {
97+
parts = append(parts, dashCount(m.MaxTokens)+" out")
98+
}
99+
return strings.Join(parts, " · ")
100+
}
101+
102+
// Activate is Enter: choose the model. It HANDS OFF -- it runs under the
103+
// render lock, and the set it causes dials the daemon.
104+
func (v *modelsView) Activate(id string) {
105+
n, err := strconv.Atoi(strings.TrimPrefix(id, "model:"))
106+
v.mu.Lock()
107+
if err != nil || n < 0 || n >= len(v.models) || v.choose == nil {
108+
v.mu.Unlock()
109+
return
110+
}
111+
m := v.models[n]
112+
v.current = m.ID // the mark moves now; the bar says whether it took
113+
choose := v.choose
114+
v.mu.Unlock()
115+
go choose(modelChoice{provider: m.Provider, model: m.ID})
116+
}
117+
118+
// Close: the list is a snapshot, holding nothing.
119+
func (v *modelsView) Close() {}
120+
121+
// fetchModels asks every configured provider for its models, in the order
122+
// `figaro models` lists them. A provider that fails is skipped and reported,
123+
// as the table did.
124+
func fetchModels(loaded *config.Loaded) ([]provider.ModelInfo, []string) {
125+
ensureHush()
126+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
127+
defer cancel()
128+
names := loaded.ListProviders()
129+
if len(names) == 0 {
130+
names = KnownProviders()
131+
}
132+
var out []provider.ModelInfo
133+
var warnings []string
134+
for _, name := range names {
135+
prov, _ := buildProvider(loaded, name)
136+
if prov == nil {
137+
continue
138+
}
139+
models, err := prov.Models(ctx)
140+
if err != nil {
141+
warnings = append(warnings, name+": "+err.Error())
142+
continue
143+
}
144+
out = append(out, models...)
145+
}
146+
return out, warnings
147+
}

‎internal/cli/models_view_test.go‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package cli
2+
3+
import (
4+
"strings"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/jack-work/figaro/internal/provider"
10+
)
11+
12+
// :models IS A PIT YOU CHOOSE IN. It used to be a table printed to os.Stdout
13+
// -- round the pager, onto the terminal -- that nothing could act on. Now it is
14+
// an itemised view: the picker drives it (^N/^P, j/k, the wash, the marker),
15+
// `y` yanks the id, and Enter makes the chosen model the subject's.
16+
17+
func modelsFixture() []provider.ModelInfo {
18+
return []provider.ModelInfo{
19+
{Provider: "anthropic", ID: "claude-opus-5", Name: "Claude Opus 5", ContextWindow: 1_000_000, MaxTokens: 32000},
20+
{Provider: "anthropic", ID: "claude-sonnet-4-5", Name: "Claude Sonnet 4.5", ContextWindow: 200_000, MaxTokens: 64000},
21+
{Provider: "copilot", ID: "gpt-5", Name: "GPT-5", ContextWindow: 400_000},
22+
}
23+
}
24+
25+
func TestModelsViewIsAListYouChooseIn(t *testing.T) {
26+
v := newModelsView(modelsFixture(), "claude-opus-5", nil)
27+
rows := v.Items(100)
28+
selectable := 0
29+
for _, r := range rows {
30+
if r.selectable() {
31+
selectable++
32+
if r.yank == "" || r.id == "" {
33+
t.Fatalf("a model row must be yankable and addressable: %+v", r)
34+
}
35+
}
36+
}
37+
if selectable != 3 {
38+
t.Fatalf("%d selectable rows, want one per model", selectable)
39+
}
40+
var current string
41+
for _, r := range rows {
42+
if strings.Contains(r.text, "claude-opus-5") {
43+
current = r.text
44+
}
45+
}
46+
if !strings.Contains(current, "●") {
47+
t.Fatalf("the subject's current model must be marked: %q", current)
48+
}
49+
// `y` copies the bare id, which is what `set system.model` takes.
50+
for _, r := range rows {
51+
if r.id != "" && strings.Contains(r.text, "gpt-5") && r.yank != "gpt-5" {
52+
t.Fatalf("yank = %q, want the model id", r.yank)
53+
}
54+
}
55+
}
56+
57+
// ENTER SETS system.model, and system.provider with it: a model is only
58+
// meaningful to its own provider, and choosing copilot's gpt-5 while the board
59+
// still says anthropic would send a model name Anthropic has never heard of on
60+
// the next turn.
61+
func TestModelsViewEnterSetsTheModelAndItsProvider(t *testing.T) {
62+
var mu sync.Mutex
63+
var got []modelChoice
64+
done := make(chan struct{}, 1)
65+
v := newModelsView(modelsFixture(), "claude-opus-5", func(c modelChoice) {
66+
mu.Lock()
67+
got = append(got, c)
68+
mu.Unlock()
69+
done <- struct{}{}
70+
})
71+
var gpt string
72+
for _, r := range v.Items(100) {
73+
if strings.Contains(r.text, "gpt-5") {
74+
gpt = r.id
75+
}
76+
}
77+
v.Activate(gpt)
78+
<-done
79+
mu.Lock()
80+
defer mu.Unlock()
81+
if len(got) != 1 || got[0].model != "gpt-5" || got[0].provider != "copilot" {
82+
t.Fatalf("Enter chose %+v, want gpt-5 on copilot", got)
83+
}
84+
}
85+
86+
// Activate runs under the render lock: it must hand off, never call the set
87+
// itself (which dials) on the input goroutine.
88+
func TestModelsViewActivateDoesNotBlock(t *testing.T) {
89+
block := make(chan struct{})
90+
v := newModelsView(modelsFixture(), "", func(modelChoice) { <-block })
91+
defer close(block)
92+
returned := make(chan struct{})
93+
go func() {
94+
for _, r := range v.Items(100) {
95+
if r.id != "" {
96+
v.Activate(r.id)
97+
break
98+
}
99+
}
100+
close(returned)
101+
}()
102+
select {
103+
case <-returned:
104+
case <-timeAfter():
105+
t.Fatal("Activate blocked on the set: it runs under the render lock and must hand off")
106+
}
107+
}
108+
109+
func timeAfter() <-chan time.Time { return time.After(2 * time.Second) }
110+
111+
// `/` walks selectable rows; the provider heading is chrome. So each row must
112+
// carry its provider for `/copilot` to land anywhere.
113+
func TestModelsViewRowsCarryTheirProvider(t *testing.T) {
114+
v := newModelsView(modelsFixture(), "", nil)
115+
p := newPicker(v.Items(100))
116+
if !p.find("copilot", 1) {
117+
t.Fatal("/copilot found no row")
118+
}
119+
row, _ := p.selected()
120+
if row.yank != "gpt-5" {
121+
t.Fatalf("/copilot landed on %q", row.yank)
122+
}
123+
}

‎internal/cli/picker.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ func (p *picker) find(q string, dir int) bool {
128128
if !p.rows[n].selectable() {
129129
continue
130130
}
131-
if searchContains(p.rows[n].text, q) {
131+
// The note is drawn, so it is searched: a row is what a reader SEES.
132+
if searchContains(p.rows[n].text, q) || searchContains(p.rows[n].note, q) {
132133
p.cursor = n
133134
p.follow()
134135
return true

0 commit comments

Comments
 (0)