Vectrum
Sign In

tui: /model, a picker fed by the endpoint's own model list

/model asks GET /v1/models — paginated, authenticated the same way messages
are — and opens a picker on the answer: display name, id, context window and
list price, the model in force marked and under the cursor. Enter switches for
the session; the conversation so far is replayed to the new model on the next
turn. An endpoint that does not serve that route says so and closes, because
an empty picker and "this endpoint cannot be asked" are different things to
tell someone; /model <id> and -model still work there.

Two things a switchable model breaks, fixed here rather than left to rot:

Cost was the cumulative usage priced at the current model, so switching
reprices the whole session retroactively. It is now accumulated as usage
arrives, at the model in force then, with a record of what has already been
priced so a resumed session's restored totals are charged once.

Pricing was keyed on exact ids, and /v1/models hands out dated ones, so
picking from the real list would have silently blanked the cost readout.
Lookup now strips the date, the -latest, and the [1m]-style variant tag: same
model, same rate.

Client.Model becomes an accessor pair over a locked field. A mutex beside a
field everyone reads directly would be decoration; making it unexported let
the compiler find every reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 37f21fdcommit3f44aaf

Showing 16 changed files with 983 additions and 34 deletions.

+25−2README.mdView File
@@ -80,8 +80,8 @@ to six lines.
8080 | `esc` | interrupt the turn |
8181 | `ctrl+c` | interrupt the turn; again when idle to exit |
8282
83- Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/skills`, and one
84- per discovered skill.
83+ Commands: `/allowed`, `/clear`, `/effort`, `/exit`, `/help`, `/model`,
84+ `/skills`, and one per discovered skill.
8585
8686 A slash at the start of an empty prompt opens the command menu, which filters
8787 as you type — a name that starts with what you typed before one that merely
@@ -102,6 +102,29 @@ level for `/effort` — and `esc` dismisses the menu without touching what you
102102 typed. The menu offers exactly the commands the TUI answers, so nothing it
103103 suggests can come back as "unknown command".
104104
105+ `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and
106+ opens a picker on the answer:
107+
108+ ```
109+ Select model
110+ from anthropic · ↑↓ move · enter apply · esc cancel
111+ 1. Claude Fable 5.1 claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok
112+ 2. Claude Opus 5 claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓
113+ ❯ 3. Claude Sonnet 5 claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok
114+ 4. Claude Haiku 4.5 claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok
115+ ```
116+
117+ The cursor starts on the model in force, which is also the one marked. The id,
118+ the context window and the list price come from the endpoint and from the
119+ pricing table; a model neither knows about simply says less. The switch applies
120+ from the next turn — the conversation so far is replayed to the new model — and
121+ costs already incurred stay priced at the model that incurred them, because a
122+ session that switched models was not all billed at the model it ended on.
123+
124+ Not every endpoint serves that route. When the fetch fails the picker says so,
125+ names the host and the reason, and closes; `/model <id>` and the `-model` flag
126+ still switch without asking anyone's permission.
127+
105128 `/effort` opens a slider in place of the prompt, on the level in force — an
106129 unset level is the API's own default, `high`, so the marker starts there rather
107130 than at an end:
+4−4cmd/harness/main.goView File
@@ -79,7 +79,7 @@ func run() error {
7979 }
8080 defer log.Close()
8181 log.Log(metrics.Event{Type: "session_start", Extra: map[string]any{
82- "root": root, "model": client.Model, "base_url": client.BaseURL,
82+ "root": root, "model": client.Model(), "base_url": client.BaseURL,
8383 }})
8484
8585 // The TUI takes over the screen, so anything worth saying before the loop
@@ -200,7 +200,7 @@ func run() error {
200200 }
201201
202202 fmt.Printf("harness · %s · %s\ntools: %s\ntype a prompt, ctrl-c to interrupt a turn, ctrl-d to exit\n\n",
203- client.Model, root, strings.Join(toolNames(reg), ", "))
203+ client.Model(), root, strings.Join(toolNames(reg), ", "))
204204
205205 for {
206206 fmt.Print("> ")
@@ -239,9 +239,9 @@ func once(ctx context.Context, a *agent.Agent, render *ui.Renderer, sessionDir,
239239
240240 render.Flush()
241241 usage, turns := a.State().Usage()
242- render.Usage(usage, turns, client.Model)
242+ render.Usage(usage, turns, client.Model())
243243
244- if err := a.Save(sessionDir, id, root, client.Model); err != nil {
244+ if err := a.Save(sessionDir, id, root, client.Model()); err != nil {
245245 fmt.Fprintln(os.Stderr, "harness: saving session:", err)
246246 }
247247 if errors.Is(runErr, context.Canceled) {
+2−2evals/runner.goView File
@@ -97,7 +97,7 @@ func run() error {
9797 if err := os.MkdirAll(runRoot, 0o755); err != nil {
9898 return err
9999 }
100- fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model, runRoot)
100+ fmt.Printf("%d tasks × %d runs · %s · transcripts in %s\n\n", len(tasks), *n, client.Model(), runRoot)
101101
102102 type job struct {
103103 task Task
@@ -163,7 +163,7 @@ func run() error {
163163 // The return value is named so the deferred timing assignment lands in the
164164 // value actually returned rather than in a copy already made.
165165 func runOne(task Task, runNo int, client *llm.Client, opts agent.Options, timeout time.Duration, keep bool) (res Result) {
166- res = Result{Task: task.Name, Run: runNo, Model: client.Model}
166+ res = Result{Task: task.Name, Run: runNo, Model: client.Model()}
167167 start := time.Now()
168168 defer func() { res.WallMS = time.Since(start).Milliseconds() }()
169169
+46−11internal/llm/client.goView File
@@ -11,6 +11,7 @@ import (
1111 "os"
1212 "strconv"
1313 "strings"
14+ "sync"
1415 "time"
1516 )
1617
@@ -28,7 +29,6 @@ const (
2829 // Client talks to POST /v1/messages.
2930 type Client struct {
3031 BaseURL string
31- Model string
3232 MaxTokens int
3333 MaxRetries int
3434 // RetryDelay is the base of the exponential backoff between retries.
@@ -37,6 +37,26 @@ type Client struct {
3737
3838 apiKey string
3939 authToken string
40+
41+ // model is behind a lock because a front end can switch it while the
42+ // agent is between turns. It is the one field that moves after
43+ // construction, so it is the one that is not public.
44+ mu sync.Mutex
45+ model string
46+ }
47+
48+ // Model reports the model requests default to.
49+ func (c *Client) Model() string {
50+ c.mu.Lock()
51+ defer c.mu.Unlock()
52+ return c.model
53+ }
54+
55+ // SetModel changes the model the requests that follow will ask for.
56+ func (c *Client) SetModel(id string) {
57+ c.mu.Lock()
58+ defer c.mu.Unlock()
59+ c.model = id
4060 }
4161
4262 // Option configures a Client.
@@ -55,7 +75,7 @@ func WithBaseURL(u string) Option {
5575 func WithModel(m string) Option {
5676 return func(c *Client) {
5777 if m != "" {
58- c.Model = m
78+ c.model = m
5979 }
6080 }
6181 }
@@ -89,7 +109,7 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.HTTP = h
89109 func New(opts ...Option) *Client {
90110 c := &Client{
91111 BaseURL: DefaultBaseURL,
92- Model: DefaultModel,
112+ model: DefaultModel,
93113 MaxTokens: DefaultMaxTokens,
94114 MaxRetries: defaultMaxRetries,
95115 RetryDelay: 500 * time.Millisecond,
@@ -154,7 +174,7 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
154174 return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
155175 }
156176 if req.Model == "" {
157- req.Model = c.Model
177+ req.Model = c.Model()
158178 }
159179 if req.MaxTokens == 0 {
160180 req.MaxTokens = c.MaxTokens
@@ -187,20 +207,35 @@ func (c *Client) CreateMessage(ctx context.Context, req Request) (*Response, err
187207 return nil, lastErr
188208 }
189209
190- func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
191- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/v1/messages", bytes.NewReader(body))
210+ // newRequest builds a request to the endpoint with the version and the
211+ // credential already on it. Every call the client makes goes through here, so
212+ // a second route cannot end up authenticating differently from the first.
213+ func (c *Client) newRequest(ctx context.Context, method, path string, body []byte) (*http.Request, error) {
214+ var rdr io.Reader
215+ if body != nil {
216+ rdr = bytes.NewReader(body)
217+ }
218+ req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
192219 if err != nil {
193220 return nil, err
194221 }
195- httpReq.Header.Set("content-type", "application/json")
196- httpReq.Header.Set("anthropic-version", apiVersion)
222+ req.Header.Set("content-type", "application/json")
223+ req.Header.Set("anthropic-version", apiVersion)
197224 switch {
198225 case c.apiKey != "":
199- httpReq.Header.Set("x-api-key", c.apiKey)
226+ req.Header.Set("x-api-key", c.apiKey)
200227 default:
201228 // OAuth tokens go on Authorization, not x-api-key, and need the beta flag.
202- httpReq.Header.Set("authorization", "Bearer "+c.authToken)
203- httpReq.Header.Set("anthropic-beta", oauthBeta)
229+ req.Header.Set("authorization", "Bearer "+c.authToken)
230+ req.Header.Set("anthropic-beta", oauthBeta)
231+ }
232+ return req, nil
233+ }
234+
235+ func (c *Client) attempt(ctx context.Context, body []byte) (*Response, error) {
236+ httpReq, err := c.newRequest(ctx, http.MethodPost, "/v1/messages", body)
237+ if err != nil {
238+ return nil, err
204239 }
205240
206241 httpResp, err := c.HTTP.Do(httpReq)
+32−0internal/llm/client_test.goView File
@@ -3,6 +3,7 @@ package llm
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
67 "io"
78 "net/http"
89 "net/http/httptest"
@@ -284,3 +285,34 @@ func TestCustomBaseURLIsActuallyUsed(t *testing.T) {
284285 t.Errorf("path = %q, want /v1/messages", gotPath)
285286 }
286287 }
288+
289+ // The model is switchable mid-session, so what a request carries is whatever
290+ // was last set rather than what the client was built with.
291+ func TestSetModelChangesWhatTheNextRequestAsksFor(t *testing.T) {
292+ var models []string
293+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
294+ var body map[string]any
295+ raw, _ := io.ReadAll(r.Body)
296+ json.Unmarshal(raw, &body)
297+ models = append(models, fmt.Sprint(body["model"]))
298+ io.WriteString(w, okResponse)
299+ })
300+ WithModel("claude-opus-5")(c)
301+
302+ msg := Request{Messages: []Message{{Role: RoleUser, Content: []ContentBlock{TextBlock("hi")}}}}
303+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
304+ t.Fatalf("CreateMessage: %v", err)
305+ }
306+
307+ c.SetModel("claude-haiku-4-5")
308+ if got := c.Model(); got != "claude-haiku-4-5" {
309+ t.Errorf("Model() = %q", got)
310+ }
311+ if _, err := c.CreateMessage(context.Background(), msg); err != nil {
312+ t.Fatalf("CreateMessage: %v", err)
313+ }
314+
315+ if want := "claude-opus-5 claude-haiku-4-5"; strings.Join(models, " ") != want {
316+ t.Errorf("models sent = %v, want %q", models, want)
317+ }
318+ }
+87−0internal/llm/models.goaddedView File
@@ -0,0 +1,87 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "net/http"
9+ "net/url"
10+ "time"
11+ )
12+
13+ // modelPageSize is what one GET /v1/models asks for. The list is short enough
14+ // that this is almost always the only request.
15+ const modelPageSize = 100
16+
17+ // maxModelPages stops a paginating endpoint that never says it is done from
18+ // looping forever behind a picker that is waiting on it.
19+ const maxModelPages = 10
20+
21+ // Model is one entry from GET /v1/models. MaxInputTokens is the context
22+ // window; it was added to the API in 2026 and is zero on an endpoint that
23+ // does not report it, which the caller has to treat as "not said" rather than
24+ // as a model with no context.
25+ type Model struct {
26+ ID string `json:"id"`
27+ DisplayName string `json:"display_name"`
28+ CreatedAt time.Time `json:"created_at"`
29+ MaxInputTokens int `json:"max_input_tokens"`
30+ MaxTokens int `json:"max_tokens"`
31+ }
32+
33+ type modelPage struct {
34+ Data []Model `json:"data"`
35+ HasMore bool `json:"has_more"`
36+ LastID string `json:"last_id"`
37+ }
38+
39+ // ListModels returns the models the endpoint offers, in the order it gave
40+ // them — which from Anthropic is newest first.
41+ //
42+ // Not every endpoint serves this route: a gateway that only proxies
43+ // /v1/messages answers 404, and the error says so rather than being turned
44+ // into an empty list, because "no models" and "this endpoint cannot be asked"
45+ // are different things to tell someone.
46+ func (c *Client) ListModels(ctx context.Context) ([]Model, error) {
47+ if !c.HasCredentials() {
48+ return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
49+ }
50+
51+ var out []Model
52+ after := ""
53+ for page := 0; page < maxModelPages; page++ {
54+ q := url.Values{"limit": {fmt.Sprint(modelPageSize)}}
55+ if after != "" {
56+ q.Set("after_id", after)
57+ }
58+
59+ req, err := c.newRequest(ctx, http.MethodGet, "/v1/models?"+q.Encode(), nil)
60+ if err != nil {
61+ return nil, err
62+ }
63+ resp, err := c.HTTP.Do(req)
64+ if err != nil {
65+ return nil, fmt.Errorf("llm: %w", err)
66+ }
67+ raw, err := io.ReadAll(resp.Body)
68+ resp.Body.Close()
69+ if err != nil {
70+ return nil, fmt.Errorf("llm: reading models: %w", err)
71+ }
72+ if resp.StatusCode != http.StatusOK {
73+ return nil, parseAPIError(resp.StatusCode, resp.Header.Get("retry-after"), raw)
74+ }
75+
76+ var p modelPage
77+ if err := json.Unmarshal(raw, &p); err != nil {
78+ return nil, fmt.Errorf("llm: decoding models: %w", err)
79+ }
80+ out = append(out, p.Data...)
81+ if !p.HasMore || p.LastID == "" {
82+ break
83+ }
84+ after = p.LastID
85+ }
86+ return out, nil
87+ }
+101−0internal/llm/models_test.goaddedView File
@@ -0,0 +1,101 @@
1+ package llm
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "strings"
8+ "testing"
9+ )
10+
11+ func TestListModelsReadsEveryPage(t *testing.T) {
12+ var paths []string
13+ var headers http.Header
14+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
15+ headers = r.Header.Clone()
16+ paths = append(paths, r.URL.RequestURI())
17+ if after := r.URL.Query().Get("after_id"); after == "" {
18+ fmt.Fprint(w, `{"data":[
19+ {"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5","created_at":"2026-02-01T00:00:00Z"},
20+ {"type":"model","id":"claude-sonnet-5","display_name":"Claude Sonnet 5","created_at":"2025-09-29T00:00:00Z"}],
21+ "has_more":true,"last_id":"claude-sonnet-5"}`)
22+ return
23+ }
24+ fmt.Fprint(w, `{"data":[
25+ {"type":"model","id":"claude-haiku-4-5-20251001","display_name":"Claude Haiku 4.5","created_at":"2025-10-01T00:00:00Z"}],
26+ "has_more":false}`)
27+ })
28+
29+ got, err := c.ListModels(context.Background())
30+ if err != nil {
31+ t.Fatalf("ListModels: %v", err)
32+ }
33+
34+ var ids []string
35+ for _, m := range got {
36+ ids = append(ids, m.ID)
37+ }
38+ if want := "claude-opus-5 claude-sonnet-5 claude-haiku-4-5-20251001"; strings.Join(ids, " ") != want {
39+ t.Errorf("ids = %v, want %s in the order the API gave them", ids, want)
40+ }
41+ if got[0].DisplayName != "Claude Opus 5" {
42+ t.Errorf("display name = %q", got[0].DisplayName)
43+ }
44+ if len(paths) != 2 {
45+ t.Fatalf("requests = %v, want a second one for the second page", paths)
46+ }
47+ if !strings.HasPrefix(paths[0], "/v1/models?") {
48+ t.Errorf("path = %q, want /v1/models", paths[0])
49+ }
50+ if !strings.Contains(paths[1], "after_id=claude-sonnet-5") {
51+ t.Errorf("second request = %q, want it to carry the last id", paths[1])
52+ }
53+ if headers.Get("x-api-key") != "test-key" || headers.Get("anthropic-version") == "" {
54+ t.Errorf("credentials or version missing: %v", headers)
55+ }
56+ }
57+
58+ // Not every endpoint serves this route. The error has to name what went wrong
59+ // so the picker can say it out loud.
60+ func TestListModelsReportsAnEndpointThatWillNotAnswer(t *testing.T) {
61+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
62+ http.Error(w, `{"type":"error","error":{"type":"not_found_error","message":"unknown route"}}`, http.StatusNotFound)
63+ })
64+
65+ _, err := c.ListModels(context.Background())
66+ if err == nil {
67+ t.Fatal("a 404 produced no error")
68+ }
69+ if !strings.Contains(err.Error(), "404") && !strings.Contains(err.Error(), "not_found") {
70+ t.Errorf("error = %q, want it to say what the endpoint answered", err)
71+ }
72+ }
73+
74+ // A bearer credential is what OAuth tokens and most gateways use, and the
75+ // listing has to authenticate the same way the messages call does.
76+ func TestListModelsSendsABearerCredential(t *testing.T) {
77+ var headers http.Header
78+ c := testClient(t, func(w http.ResponseWriter, r *http.Request) {
79+ headers = r.Header.Clone()
80+ fmt.Fprint(w, `{"data":[],"has_more":false}`)
81+ })
82+ WithAuthToken("oauth-token")(c)
83+
84+ if _, err := c.ListModels(context.Background()); err != nil {
85+ t.Fatalf("ListModels: %v", err)
86+ }
87+ if got := headers.Get("authorization"); got != "Bearer oauth-token" {
88+ t.Errorf("authorization = %q", got)
89+ }
90+ if headers.Get("anthropic-beta") == "" {
91+ t.Error("the oauth beta flag is missing")
92+ }
93+ }
94+
95+ func TestListModelsRefusesWithoutCredentials(t *testing.T) {
96+ c := New(WithBaseURL("http://127.0.0.1:1"))
97+ c.apiKey, c.authToken = "", ""
98+ if _, err := c.ListModels(context.Background()); err == nil {
99+ t.Fatal("listing without credentials should not even be attempted")
100+ }
101+ }
+36−1internal/llm/pricing.goView File
@@ -1,5 +1,7 @@
11 package llm
22
3+ import "strings"
4+
35 // Price is per-million-token list pricing.
46 type Price struct {
57 InputPerMTok float64
@@ -27,7 +29,7 @@ var prices = map[string]Price{
2729 // Cost prices one usage record. The boolean reports whether the model's
2830 // pricing is known; a made-up number in METRICS.md is worse than a blank.
2931 func (u Usage) Cost(model string) (float64, bool) {
30- p, ok := prices[model]
32+ p, ok := prices[basePrice(model)]
3133 if !ok {
3234 return 0, false
3335 }
@@ -36,3 +38,36 @@ func (u Usage) Cost(model string) (float64, bool) {
3638 float64(u.CacheReadInputTokens)*p.InputPerMTok*cacheReadMultiplier +
3739 float64(u.CacheCreationInputTokens)*p.InputPerMTok*cacheWriteMultiplier) / 1_000_000, true
3840 }
41+
42+ // PriceOf reports the list pricing for a model, if this harness knows it.
43+ // The picker shows what it knows and says nothing about what it does not.
44+ func PriceOf(model string) (Price, bool) {
45+ p, ok := prices[basePrice(model)]
46+ return p, ok
47+ }
48+
49+ // basePrice strips the decoration the API puts on a model id — a bracketed
50+ // variant tag, a -latest or -YYYYMMDD suffix — leaving the name the price is
51+ // listed under. A dated id and a context-window variant are the same model
52+ // billed at the same rate, and /v1/models hands out both.
53+ func basePrice(model string) string {
54+ if i := strings.IndexByte(model, '['); i >= 0 {
55+ model = model[:i]
56+ }
57+ model = strings.TrimSuffix(model, "-latest")
58+ if i := strings.LastIndexByte(model, '-'); i >= 0 {
59+ if suffix := model[i+1:]; len(suffix) == 8 && allDigits(suffix) {
60+ model = model[:i]
61+ }
62+ }
63+ return model
64+ }
65+
66+ func allDigits(s string) bool {
67+ for _, r := range s {
68+ if r < '0' || r > '9' {
69+ return false
70+ }
71+ }
72+ return len(s) > 0
73+ }
+36−0internal/llm/pricing_test.goaddedView File
@@ -0,0 +1,36 @@
1+ package llm
2+
3+ import "testing"
4+
5+ // The model picker offers whatever /v1/models returns, which is dated ids and
6+ // context-window variants. Those are the same model at the same price, so
7+ // pricing has to look past the decoration rather than report "unknown".
8+ func TestCostPricesDecoratedModelIDs(t *testing.T) {
9+ u := Usage{InputTokens: 1_000_000}
10+ base, ok := u.Cost("claude-sonnet-5")
11+ if !ok {
12+ t.Fatal("the bare id is not priced")
13+ }
14+ for _, id := range []string{
15+ "claude-sonnet-5-20250930",
16+ "claude-sonnet-5[1m]",
17+ "claude-sonnet-5-latest",
18+ } {
19+ got, ok := u.Cost(id)
20+ if !ok {
21+ t.Errorf("Cost(%q) reported no price", id)
22+ continue
23+ }
24+ if got != base {
25+ t.Errorf("Cost(%q) = %v, want the same %v as the bare id", id, got, base)
26+ }
27+ }
28+
29+ // A model nobody has priced still reports nothing rather than guessing.
30+ if _, ok := u.Cost("some-gateway/mystery-model"); ok {
31+ t.Error("an unknown model was given a price")
32+ }
33+ if _, ok := u.Cost("claude-something-9-20990101"); ok {
34+ t.Error("an unknown Claude model was given a price")
35+ }
36+ }
+1−1internal/llm/stream.goView File
@@ -38,7 +38,7 @@ func (c *Client) StreamMessage(ctx context.Context, req Request, onEvent func(St
3838 return nil, fmt.Errorf("llm: no credentials: set ANTHROPIC_API_KEY, or run `ant auth login`")
3939 }
4040 if req.Model == "" {
41- req.Model = c.Model
41+ req.Model = c.Model()
4242 }
4343 if req.MaxTokens == 0 {
4444 req.MaxTokens = c.MaxTokens
+1−0internal/tui/commands.goView File
@@ -23,6 +23,7 @@ type command struct {
2323 var builtins = []command{
2424 {name: "help", desc: "the commands and the keys"},
2525 {name: "skills", desc: "the instruction sets found at startup"},
26+ {name: "model", args: "id", desc: "switch model — lists what the endpoint serves"},
2627 {name: "effort", args: "level", desc: "how hard the model thinks — opens a slider"},
2728 {name: "clear", desc: "empty the transcript"},
2829 {name: "allowed", desc: "what is permanently allowed this session"},
+73−11internal/tui/model.goView File
@@ -56,6 +56,9 @@ const (
5656 // phaseEffort is the effort slider: the prompt is gone until a level is
5757 // picked or the slider is dismissed.
5858 phaseEffort
59+ // phaseModel is the model picker, which starts on a wait: the list comes
60+ // from the endpoint.
61+ phaseModel
5962 )
6063
6164 // inputMaxLines caps how far the prompt box grows before it scrolls. Past
@@ -77,12 +80,18 @@ type model struct {
7780 ready bool
7881
7982 // Turn bookkeeping.
80- turn int
81- started time.Time
82- usage llm.Usage
83- turns int
84- lastCost float64
83+ turn int
84+ started time.Time
85+ usage llm.Usage
86+ turns int
87+ // cost is the running bill, accumulated as usage is reported and priced
88+ // at the model in force at that moment: a session that switched models
89+ // was not all billed at the model it happens to end on. priced is what
90+ // has already been counted, so a turn's totals — which on a resumed
91+ // session include history this process never saw — are charged once.
92+ cost float64
8593 costKnown bool
94+ priced llm.Usage
8695 // ctxTokens is the last turn's input size — what the model actually read,
8796 // which is the number the context gauge is about. It replaces itself each
8897 // turn rather than accumulating, and compaction makes it drop.
@@ -97,6 +106,12 @@ type model struct {
97106 // effortIdx is where the marker sits while the effort slider is up.
98107 effortIdx int
99108
109+ // The model picker: what the endpoint offered, where the cursor is, and
110+ // whether we are still waiting to be told.
111+ models []llm.Model
112+ modelIdx int
113+ modelsLoading bool
114+
100115 // pending approvals. The agent blocks on one at a time, but a queue costs
101116 // nothing and means a second request can never be dropped.
102117 pending []approvalMsg
@@ -207,7 +222,7 @@ func (m *model) runTurn(prompt string) tea.Cmd {
207222 _, err := a.Run(ctx, prompt)
208223 cancel()
209224
210- if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model); saveErr != nil && err == nil {
225+ if saveErr := a.Save(m.opts.SessionDir, m.opts.SessionID, m.opts.Root, m.opts.Client.Model()); saveErr != nil && err == nil {
211226 err = fmt.Errorf("saving session: %w", saveErr)
212227 }
213228 u, turns := a.State().Usage()
@@ -224,8 +239,11 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
224239 case tea.KeyMsg:
225240 return m.onKey(msg)
226241
242+ case modelsMsg:
243+ return m, m.onModels(msg)
244+
227245 case spinner.TickMsg:
228- if m.phase == phaseRunning {
246+ if m.phase == phaseRunning || m.modelsLoading {
229247 var cmd tea.Cmd
230248 m.spin, cmd = m.spin.Update(msg)
231249 return m, cmd
@@ -256,6 +274,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
256274
257275 case evUsage:
258276 m.usage = addUsage(m.usage, msg.usage)
277+ m.addCost(msg.usage)
259278 m.ctxTokens = msg.usage.InputTokens + msg.usage.CacheReadInputTokens + msg.usage.CacheCreationInputTokens
260279 return m, nil
261280
@@ -315,7 +334,7 @@ func (m *model) finishTurn(msg turnDone) tea.Cmd {
315334 m.phase = phaseIdle
316335 m.interrupting = false
317336 m.usage, m.turns = msg.usage, msg.turns
318- m.lastCost, m.costKnown = msg.usage.Cost(m.opts.Client.Model)
337+ m.addCost(subUsage(msg.usage, m.priced))
319338 m.tr.closeOpen()
320339
321340 // Any approval still outstanding belongs to a turn that no longer exists.
@@ -342,6 +361,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
342361 if m.phase == phaseEffort {
343362 return m.onEffortKey(msg)
344363 }
364+ if m.phase == phaseModel {
365+ return m.onModelKey(msg)
366+ }
345367
346368 // An open menu owns the keys that drive it — including enter and esc,
347369 // which mean something else the rest of the time.
@@ -458,6 +480,9 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
458480 m.tr.addNotice(m.helpText())
459481 return m, m.sync()
460482
483+ case line == "/model" || strings.HasPrefix(line, "/model "):
484+ return m, m.setModel(strings.TrimSpace(strings.TrimPrefix(line, "/model")))
485+
461486 case line == "/effort" || strings.HasPrefix(line, "/effort "):
462487 return m, m.setEffort(strings.TrimSpace(strings.TrimPrefix(line, "/effort")))
463488
@@ -500,6 +525,17 @@ func (m *model) submit(line string) (tea.Model, tea.Cmd) {
500525 return m, tea.Batch(m.sync(), m.spin.Tick, m.runTurn(line))
501526 }
502527
528+ // setModel answers /model. Bare, it asks the endpoint what it serves and
529+ // opens the picker on the answer; with an id, it switches straight away,
530+ // which also works on an endpoint that does not list its models.
531+ func (m *model) setModel(id string) tea.Cmd {
532+ if id == "" {
533+ return m.openModelPicker()
534+ }
535+ m.applyModel(id)
536+ return m.sync()
537+ }
538+
503539 // setEffort answers /effort. Bare, it opens the slider; with a level, it
504540 // applies it directly, which is the fast path for anyone who knows what they
505541 // want and can type it.
@@ -646,6 +682,8 @@ func (m *model) bottom() string {
646682 b.WriteString(m.approvalView() + "\n")
647683 case m.phase == phaseEffort:
648684 b.WriteString(m.effortView() + "\n")
685+ case m.phase == phaseModel:
686+ b.WriteString(m.modelView() + "\n")
649687 default:
650688 b.WriteString(m.inputBox() + "\n")
651689 if v := m.menuView(); v != "" {
@@ -707,7 +745,7 @@ func (m *model) inputBox() string {
707745 func (m *model) status() string {
708746 parts := []string{
709747 styName.Render(username()),
710- styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model)),
748+ styCoral.Render("✳ ") + styDim.Render(prettyModel(m.opts.Client.Model())),
711749 }
712750
713751 if pill, ok := m.contextPill(); ok {
@@ -724,8 +762,8 @@ func (m *model) status() string {
724762 }
725763 io += fmt.Sprintf(" ↓ %s", human(u.OutputTokens))
726764 parts = append(parts, styDim.Render(io))
727- if cost, known := u.Cost(m.opts.Client.Model); known {
728- parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", cost)))
765+ if m.costKnown {
766+ parts = append(parts, styDim.Render(fmt.Sprintf("$%.4f", m.cost)))
729767 }
730768 }
731769 return truncateStyled(strings.Join(parts, " "), maxInt(1, m.width))
@@ -819,6 +857,30 @@ func username() string {
819857 return "you"
820858 }
821859
860+ // addCost prices one piece of usage at the model in force and adds it to the
861+ // running total, recording that those tokens have now been paid for.
862+ func (m *model) addCost(u llm.Usage) {
863+ if u == (llm.Usage{}) {
864+ return
865+ }
866+ m.priced = addUsage(m.priced, u)
867+ if c, known := u.Cost(m.opts.Client.Model()); known {
868+ m.cost += c
869+ m.costKnown = true
870+ }
871+ }
872+
873+ // subUsage is a minus b, floored at zero per field: totals only ever grow, and
874+ // a negative remainder would mean charging backwards.
875+ func subUsage(a, b llm.Usage) llm.Usage {
876+ return llm.Usage{
877+ InputTokens: maxInt(0, a.InputTokens-b.InputTokens),
878+ OutputTokens: maxInt(0, a.OutputTokens-b.OutputTokens),
879+ CacheReadInputTokens: maxInt(0, a.CacheReadInputTokens-b.CacheReadInputTokens),
880+ CacheCreationInputTokens: maxInt(0, a.CacheCreationInputTokens-b.CacheCreationInputTokens),
881+ }
882+ }
883+
822884 func addUsage(a, b llm.Usage) llm.Usage {
823885 a.InputTokens += b.InputTokens
824886 a.OutputTokens += b.OutputTokens
+212−0internal/tui/model_picker.goaddedView File
@@ -0,0 +1,212 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/textarea"
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+
13+ "harness/internal/llm"
14+ )
15+
16+ // modelFetchTimeout bounds the wait. Someone is sitting in front of a picker
17+ // that says it is asking; a minute of that is not a picker, it is a hang.
18+ const modelFetchTimeout = 20 * time.Second
19+
20+ // modelsMsg carries the answer to GET /v1/models back into the UI.
21+ type modelsMsg struct {
22+ models []llm.Model
23+ err error
24+ }
25+
26+ // fetchModels asks the endpoint what it serves. It runs off the UI goroutine
27+ // like any other command, and reports failure as a value rather than by
28+ // leaving the picker waiting forever.
29+ func fetchModels(c *llm.Client) tea.Cmd {
30+ return func() tea.Msg {
31+ ctx, cancel := context.WithTimeout(context.Background(), modelFetchTimeout)
32+ defer cancel()
33+ models, err := c.ListModels(ctx)
34+ return modelsMsg{models: models, err: err}
35+ }
36+ }
37+
38+ // openModelPicker puts the picker up and starts asking.
39+ func (m *model) openModelPicker() tea.Cmd {
40+ m.phase = phaseModel
41+ m.models, m.modelIdx, m.modelsLoading = nil, 0, true
42+ m.closeMenu()
43+ return tea.Batch(m.sync(), m.spin.Tick, fetchModels(m.opts.Client))
44+ }
45+
46+ // onModels lands the answer. An answer nobody is waiting for any more is
47+ // dropped: the picker was escaped, and reopening it over whatever the user
48+ // went on to do would be the UI arguing with them.
49+ func (m *model) onModels(msg modelsMsg) tea.Cmd {
50+ if m.phase != phaseModel {
51+ return nil
52+ }
53+ m.modelsLoading = false
54+ m.phase = phaseIdle
55+
56+ switch {
57+ case msg.err != nil:
58+ m.tr.addError("could not list models from " + endpointLabel(m.opts.Client) + ": " +
59+ msg.err.Error() + " — this endpoint may not serve /v1/models; -model or /model <id> still works")
60+ case len(msg.models) == 0:
61+ m.tr.addError(endpointLabel(m.opts.Client) + " returned no models — -model or /model <id> still works")
62+ default:
63+ m.phase = phaseModel
64+ m.models = msg.models
65+ m.modelIdx = modelIndex(msg.models, m.opts.Client.Model())
66+ }
67+ return tea.Batch(m.sync(), textarea.Blink)
68+ }
69+
70+ // modelIndex finds the model in force, so the picker opens where the session
71+ // already is rather than at the top of a list.
72+ func modelIndex(models []llm.Model, current string) int {
73+ for i, mo := range models {
74+ if mo.ID == current {
75+ return i
76+ }
77+ }
78+ return 0
79+ }
80+
81+ // onModelKey drives the picker. While the fetch is in flight there is nothing
82+ // to move through, so only the ways out are live.
83+ func (m *model) onModelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
84+ key := msg.String()
85+ if key == "esc" || key == "ctrl+c" {
86+ m.phase = phaseIdle
87+ m.models, m.modelsLoading = nil, false
88+ return m, tea.Batch(m.sync(), textarea.Blink)
89+ }
90+ if m.modelsLoading || len(m.models) == 0 {
91+ return m, nil
92+ }
93+
94+ last := len(m.models) - 1
95+ switch key {
96+ case "up", "ctrl+p":
97+ m.modelIdx = maxInt(0, m.modelIdx-1)
98+ case "down", "ctrl+n":
99+ m.modelIdx = minInt(last, m.modelIdx+1)
100+ case "home":
101+ m.modelIdx = 0
102+ case "end":
103+ m.modelIdx = last
104+ case "enter", "tab":
105+ id := m.models[m.modelIdx].ID
106+ m.phase = phaseIdle
107+ m.models = nil
108+ m.applyModel(id)
109+ return m, tea.Batch(m.sync(), textarea.Blink)
110+ default:
111+ if len(key) == 1 && key[0] >= '1' && key[0] <= '9' {
112+ if i := int(key[0] - '1'); i <= last {
113+ m.modelIdx = i
114+ }
115+ }
116+ }
117+ return m, m.sync()
118+ }
119+
120+ // applyModel switches the client and says so. The session keeps its history:
121+ // the conversation so far is replayed to the new model on the next turn,
122+ // which is the point of switching mid-session.
123+ func (m *model) applyModel(id string) {
124+ m.opts.Client.SetModel(id)
125+ m.tr.addNotice("model: " + prettyModel(id) + " (" + id + ") — from the next turn on")
126+ }
127+
128+ // modelView is the picker: a heading, where the list came from, and a row per
129+ // model with what it is called, its id, and what it costs.
130+ func (m *model) modelView() string {
131+ width := maxInt(1, m.width)
132+ head := " " + styHeading.Render("Select model")
133+ from := styDim.Render("from " + endpointLabel(m.opts.Client))
134+
135+ if m.modelsLoading {
136+ return head + "\n" + truncateStyled(" "+m.spin.View()+" "+from+styFaint.Render(" · esc to cancel"), width)
137+ }
138+
139+ rows := m.modelRows()
140+ shown := rows
141+ if len(m.models) > rows {
142+ shown = maxInt(1, rows-1)
143+ }
144+ start := 0
145+ if m.modelIdx >= shown {
146+ start = m.modelIdx - shown + 1
147+ }
148+ end := minInt(len(m.models), start+shown)
149+
150+ // One column for the names, so the ids line up under each other.
151+ names := make([]string, len(m.models))
152+ col := 0
153+ for i, mo := range m.models {
154+ names[i] = mo.DisplayName
155+ if names[i] == "" {
156+ names[i] = prettyModel(mo.ID)
157+ }
158+ if i >= start && i < end {
159+ col = maxInt(col, lipgloss.Width(names[i]))
160+ }
161+ }
162+ col = minInt(col, maxInt(10, width/3))
163+
164+ var b strings.Builder
165+ b.WriteString(head + "\n")
166+ b.WriteString(truncateStyled(" "+from+styFaint.Render(" · ↑↓ move · enter apply · esc cancel"), width) + "\n")
167+ for i := start; i < end; i++ {
168+ mo := m.models[i]
169+ marker, name := " ", styDim.Render(pad(names[i], col))
170+ if i == m.modelIdx {
171+ marker, name = styKey.Render("❯ "), styKey.Render(pad(names[i], col))
172+ }
173+ // An endpoint that gave no display name leaves the id as the name;
174+ // repeating it in the detail column says nothing twice.
175+ detail := ""
176+ if names[i] != mo.ID {
177+ detail = mo.ID
178+ }
179+ if mo.MaxInputTokens > 0 {
180+ if detail != "" {
181+ detail += " · "
182+ }
183+ detail += human(mo.MaxInputTokens) + " in"
184+ }
185+ if p, ok := llm.PriceOf(mo.ID); ok {
186+ if detail != "" {
187+ detail += " · "
188+ }
189+ detail += fmt.Sprintf("$%g / $%g per Mtok", p.InputPerMTok, p.OutputPerMTok)
190+ }
191+ line := marker + fmt.Sprintf("%d. ", i+1) + name + " " + styFaint.Render(detail)
192+ if mo.ID == m.opts.Client.Model() {
193+ line += styOK.Render(" ✓")
194+ }
195+ b.WriteString(truncateStyled(" "+line, width) + "\n")
196+ }
197+ if n := len(m.models) - (end - start); n > 0 {
198+ b.WriteString(truncateStyled(" "+styFaint.Render(fmt.Sprintf("… +%d more", n)), width) + "\n")
199+ }
200+ return strings.TrimRight(b.String(), "\n")
201+ }
202+
203+ // modelRows is how many model rows fit: the same arithmetic the completion
204+ // menu does, minus the heading and the line saying where the list came from.
205+ func (m *model) modelRows() int {
206+ room := m.height - 6
207+ return maxInt(1, minInt(modelMaxRows, room))
208+ }
209+
210+ // modelMaxRows caps the list. Anthropic serves a couple of dozen models and
211+ // nobody scrolls a picker looking for the twenty-fourth.
212+ const modelMaxRows = 10
+325−0internal/tui/model_picker_test.goaddedView File
@@ -0,0 +1,325 @@
1+ package tui
2+
3+ import (
4+ "errors"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+
11+ tea "github.com/charmbracelet/bubbletea"
12+
13+ "harness/internal/llm"
14+ )
15+
16+ func someModels() []llm.Model {
17+ return []llm.Model{
18+ {ID: "claude-opus-5", DisplayName: "Claude Opus 5"},
19+ {ID: "claude-sonnet-5", DisplayName: "Claude Sonnet 5"},
20+ {ID: "claude-haiku-4-5-20251001", DisplayName: "Claude Haiku 4.5"},
21+ }
22+ }
23+
24+ // /model opens on a wait, because the list comes from the endpoint rather
25+ // than from a table compiled into the harness.
26+ func TestModelPickerAsksTheEndpoint(t *testing.T) {
27+ m := newTestModel(t)
28+ m.submit("/model")
29+
30+ if m.phase != phaseModel {
31+ t.Fatalf("phase = %v, want the picker open", m.phase)
32+ }
33+ if !m.modelsLoading {
34+ t.Error("the picker is not waiting on anything")
35+ }
36+ if !strings.Contains(m.modelView(), "anthropic") {
37+ t.Errorf("the wait does not say where it is asking:\n%s", m.modelView())
38+ }
39+
40+ m.Update(modelsMsg{models: someModels()})
41+
42+ if m.modelsLoading {
43+ t.Error("still loading after the answer arrived")
44+ }
45+ view := m.modelView()
46+ for _, want := range []string{"Claude Opus 5", "claude-sonnet-5", "Select model"} {
47+ if !strings.Contains(view, want) {
48+ t.Errorf("%q missing from the picker:\n%s", want, view)
49+ }
50+ }
51+ }
52+
53+ // The cursor starts on what is in force, and that row is marked, so the
54+ // picker opens saying where you already are.
55+ func TestModelPickerStartsOnTheModelInForce(t *testing.T) {
56+ m := newTestModel(t)
57+ m.opts.Client.SetModel("claude-sonnet-5")
58+ m.submit("/model")
59+ m.Update(modelsMsg{models: someModels()})
60+
61+ if got := m.models[m.modelIdx].ID; got != "claude-sonnet-5" {
62+ t.Errorf("cursor on %q, want the model in force", got)
63+ }
64+ if !strings.Contains(m.modelView(), "✓") {
65+ t.Errorf("the model in force is not marked:\n%s", m.modelView())
66+ }
67+ }
68+
69+ // Prices we know are shown beside the id, including for the dated ids the API
70+ // hands out; a model we have no price for simply says less.
71+ func TestModelRowsShowTheIDAndWhatItCosts(t *testing.T) {
72+ m := newTestModel(t)
73+ m.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
74+ m.submit("/model")
75+ m.Update(modelsMsg{models: append(someModels(), llm.Model{ID: "mystery-1", DisplayName: "Mystery"})})
76+
77+ view := m.modelView()
78+ if !strings.Contains(view, "claude-haiku-4-5-20251001") {
79+ t.Errorf("the id is missing:\n%s", view)
80+ }
81+ if !strings.Contains(view, "$1") || !strings.Contains(view, "$5") {
82+ t.Errorf("the price of a known model is missing:\n%s", view)
83+ }
84+ for _, line := range strings.Split(view, "\n") {
85+ if strings.Contains(line, "Mystery") && strings.Contains(line, "$") {
86+ t.Errorf("a model with no known price was given one: %q", line)
87+ }
88+ }
89+
90+ // A model the endpoint gave no name for is shown once, not twice: there
91+ // is nothing to say beyond its id.
92+ m2 := newTestModel(t)
93+ m2.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
94+ m2.submit("/model")
95+ m2.Update(modelsMsg{models: []llm.Model{{ID: "gateway/mystery"}}})
96+ for _, line := range strings.Split(m2.modelView(), "\n") {
97+ if strings.Count(line, "gateway/mystery") > 1 {
98+ t.Errorf("the id is printed twice: %q", line)
99+ }
100+ }
101+ }
102+
103+ func TestModelPickerAppliesOnEnter(t *testing.T) {
104+ m := newTestModel(t)
105+ m.submit("/model")
106+ m.Update(modelsMsg{models: someModels()})
107+
108+ m.Update(key("down"))
109+ if m.opts.Client.Model() != "claude-opus-5" {
110+ t.Error("moving the cursor switched the model; it should only move")
111+ }
112+
113+ m.Update(key("enter"))
114+
115+ if got := m.opts.Client.Model(); got != "claude-sonnet-5" {
116+ t.Errorf("model = %q, want claude-sonnet-5", got)
117+ }
118+ if m.phase != phaseIdle {
119+ t.Errorf("phase = %v after applying, want the prompt back", m.phase)
120+ }
121+ if out := m.tr.render(); !strings.Contains(out, "claude-sonnet-5") {
122+ t.Errorf("the switch was not reported: %q", out)
123+ }
124+ if !strings.Contains(m.status(), "Sonnet 5") {
125+ t.Errorf("the status bar still names the old model: %q", m.status())
126+ }
127+ }
128+
129+ func TestModelPickerNumbersJumpAndEscapeCancels(t *testing.T) {
130+ m := newTestModel(t)
131+ m.submit("/model")
132+ m.Update(modelsMsg{models: someModels()})
133+
134+ m.Update(key("3"))
135+ if got := m.models[m.modelIdx].ID; got != "claude-haiku-4-5-20251001" {
136+ t.Errorf("3 selected %q", got)
137+ }
138+ m.Update(key("esc"))
139+
140+ if got := m.opts.Client.Model(); got != "claude-opus-5" {
141+ t.Errorf("model = %q, want it left alone", got)
142+ }
143+ if m.phase != phaseIdle {
144+ t.Errorf("phase = %v, want the prompt back", m.phase)
145+ }
146+ }
147+
148+ // An endpoint that cannot be asked says so, and says where to go instead,
149+ // rather than leaving an empty picker on screen.
150+ func TestModelPickerReportsAFailedFetch(t *testing.T) {
151+ m := newTestModel(t)
152+ m.submit("/model")
153+
154+ m.Update(modelsMsg{err: errors.New("404 not_found_error: unknown route")})
155+
156+ if m.phase != phaseIdle {
157+ t.Errorf("phase = %v, want the picker gone", m.phase)
158+ }
159+ out := m.tr.render()
160+ for _, want := range []string{"404", "-model"} {
161+ if !strings.Contains(out, want) {
162+ t.Errorf("%q missing from the report: %q", want, out)
163+ }
164+ }
165+ }
166+
167+ func TestModelPickerReportsAnEmptyList(t *testing.T) {
168+ m := newTestModel(t)
169+ m.submit("/model")
170+
171+ m.Update(modelsMsg{models: nil})
172+
173+ if m.phase != phaseIdle {
174+ t.Errorf("phase = %v, want the picker gone", m.phase)
175+ }
176+ if !strings.Contains(m.tr.render(), "no models") {
177+ t.Errorf("an empty list was not explained: %q", m.tr.render())
178+ }
179+ }
180+
181+ // Escaping the wait means the answer is no longer wanted: it must not reopen
182+ // the picker over whatever the user did next.
183+ func TestModelPickerIgnoresAnAnswerItNoLongerWants(t *testing.T) {
184+ m := newTestModel(t)
185+ m.submit("/model")
186+ m.Update(key("esc"))
187+
188+ m.Update(modelsMsg{models: someModels()})
189+
190+ if m.phase != phaseIdle {
191+ t.Errorf("phase = %v, want the picker to have stayed closed", m.phase)
192+ }
193+ if len(m.models) != 0 {
194+ t.Error("a discarded answer was kept")
195+ }
196+ }
197+
198+ // Typing the id is still the fast path, and it does not need the endpoint to
199+ // answer anything.
200+ func TestTypedModelSkipsThePicker(t *testing.T) {
201+ m := newTestModel(t)
202+ m.submit("/model claude-haiku-4-5")
203+
204+ if m.phase != phaseIdle {
205+ t.Errorf("phase = %v, want no picker", m.phase)
206+ }
207+ if got := m.opts.Client.Model(); got != "claude-haiku-4-5" {
208+ t.Errorf("model = %q", got)
209+ }
210+ }
211+
212+ // A long list scrolls inside the room the terminal has rather than pushing
213+ // the transcript off the screen.
214+ func TestModelPickerFitsTheTerminal(t *testing.T) {
215+ var many []llm.Model
216+ for i := 0; i < 30; i++ {
217+ many = append(many, llm.Model{ID: fmt.Sprintf("claude-model-%d", i), DisplayName: fmt.Sprintf("Model %d", i)})
218+ }
219+ for _, h := range []int{12, 24, 40} {
220+ m := newTestModel(t)
221+ m.Update(tea.WindowSizeMsg{Width: 80, Height: h})
222+ m.submit("/model")
223+ m.Update(modelsMsg{models: many})
224+
225+ if got := strings.Count(m.View(), "\n") + 1; got > h {
226+ t.Errorf("height %d: the screen is %d lines", h, got)
227+ }
228+ if !strings.Contains(m.modelView(), "more") {
229+ t.Errorf("height %d: a list of 30 showed no count of what did not fit", h)
230+ }
231+
232+ // The cursor stays visible as it walks down the list.
233+ for i := 0; i < 29; i++ {
234+ m.Update(key("down"))
235+ }
236+ if !strings.Contains(m.modelView(), "Model 29") {
237+ t.Errorf("height %d: the list did not scroll to the cursor:\n%s", h, m.modelView())
238+ }
239+ }
240+ }
241+
242+ // The fetch command is the part that talks to the endpoint; it turns both
243+ // outcomes into a message the model can handle.
244+ func TestFetchModelsCommandCarriesTheResult(t *testing.T) {
245+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
246+ fmt.Fprint(w, `{"data":[{"type":"model","id":"claude-opus-5","display_name":"Claude Opus 5"}],"has_more":false}`)
247+ }))
248+ defer srv.Close()
249+
250+ client := llm.New(llm.WithBaseURL(srv.URL), llm.WithAPIKey("k"))
251+ msg, ok := fetchModels(client)().(modelsMsg)
252+ if !ok {
253+ t.Fatal("the command did not produce a modelsMsg")
254+ }
255+ if msg.err != nil {
256+ t.Fatalf("err = %v", msg.err)
257+ }
258+ if len(msg.models) != 1 || msg.models[0].ID != "claude-opus-5" {
259+ t.Errorf("models = %+v", msg.models)
260+ }
261+ }
262+
263+ // Cost is accumulated at the model that earned it. Pricing the whole session
264+ // at whatever it ended on would rewrite history every time you switch.
265+ func TestCostIsPricedAtTheModelInForceWhenItWasSpent(t *testing.T) {
266+ m := newTestModel(t)
267+ m.opts.Client.SetModel("claude-opus-5") // $5 per Mtok in
268+ m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
269+
270+ m.opts.Client.SetModel("claude-haiku-4-5") // $1 per Mtok in
271+ m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
272+
273+ if got := m.cost; got != 6 {
274+ t.Errorf("cost = %v, want 6: five dollars of Opus and one of Haiku", got)
275+ }
276+ if !strings.Contains(m.status(), "$6.0000") {
277+ t.Errorf("the status bar disagrees: %q", m.status())
278+ }
279+ }
280+
281+ // A turn ends with the session's totals, which on a resumed session include
282+ // usage this process never saw. That remainder is priced once.
283+ func TestTurnTotalsPriceOnlyWhatWasNotPricedAlready(t *testing.T) {
284+ m := newTestModel(t)
285+ m.opts.Client.SetModel("claude-opus-5")
286+ m.Update(evUsage{usage: llm.Usage{InputTokens: 1_000_000}})
287+
288+ total := llm.Usage{InputTokens: 3_000_000}
289+ m.Update(turnDone{usage: total, turns: 1})
290+ if got := m.cost; got != 15 {
291+ t.Errorf("cost = %v, want 15: the session's three million at five dollars each", got)
292+ }
293+
294+ // The same totals arriving again are the same tokens, not new ones.
295+ m.Update(turnDone{usage: total, turns: 2})
296+ if got := m.cost; got != 15 {
297+ t.Errorf("cost = %v after a second turn that spent nothing, want 15", got)
298+ }
299+ }
300+
301+ // The context window is the other half of what a model is: /v1/models reports
302+ // it, so the picker shows it rather than making you remember which model is
303+ // the 200K one.
304+ func TestModelRowsShowTheContextWindow(t *testing.T) {
305+ m := newTestModel(t)
306+ m.Update(tea.WindowSizeMsg{Width: 110, Height: 30})
307+ m.submit("/model")
308+ m.Update(modelsMsg{models: []llm.Model{
309+ {ID: "claude-opus-5", DisplayName: "Claude Opus 5", MaxInputTokens: 1_000_000},
310+ {ID: "claude-haiku-4-5", DisplayName: "Claude Haiku 4.5", MaxInputTokens: 200_000},
311+ {ID: "mystery", DisplayName: "Mystery"},
312+ }})
313+
314+ view := m.modelView()
315+ for _, want := range []string{"1.0M in", "200.0k in"} {
316+ if !strings.Contains(view, want) {
317+ t.Errorf("%q missing from the picker:\n%s", want, view)
318+ }
319+ }
320+ for _, line := range strings.Split(view, "\n") {
321+ if strings.Contains(line, "Mystery") && strings.Contains(line, " in") {
322+ t.Errorf("a context window was invented for a model that reported none: %q", line)
323+ }
324+ }
325+ }
+1−1internal/tui/model_test.goView File
@@ -359,7 +359,7 @@ func TestStatusLineKeepsCostAtNormalWidth(t *testing.T) {
359359 m.Update(evUsage{usage: llm.Usage{InputTokens: 2400, OutputTokens: 190, CacheReadInputTokens: 16000}})
360360
361361 status := m.status()
362- if _, known := m.usage.Cost(m.opts.Client.Model); !known {
362+ if _, known := m.usage.Cost(m.opts.Client.Model()); !known {
363363 t.Skip("no pricing for the test model")
364364 }
365365 if strings.HasSuffix(strings.TrimSpace(status), "…") {
+1−1internal/tui/splash.goView File
@@ -30,7 +30,7 @@ func splash(o Options, width int) string {
3030 }
3131 lines := []string{
3232 name,
33- styDim.Render(prettyModel(o.Client.Model) + " · " + endpointLabel(o.Client)),
33+ styDim.Render(prettyModel(o.Client.Model()) + " · " + endpointLabel(o.Client)),
3434 styDim.Render(shortenHome(o.Root)),
3535 }
3636