@@ -5,6 +5,8 @@ import type {
55 AgentSession ,
66 AgentSessionSource ,
77 ListAgentSessionsQuery ,
8+ ModelManufacturer ,
9+ ModelRateCard ,
810 StartAgentSessionRequest ,
911 SupportedModel ,
1012} from './types'
@@ -259,37 +261,152 @@ export function attentionFlip(prevWord: string | undefined, nextWord: string): b
259261
260262// --------------------------- new-session picker ---------------------------
261263
262- export type ComposerModel = { id : string | null ; label : string }
264+ // One row of a composer picker. `group` and `rate` are what the model list
265+ // uses and the other two pickers leave unset: repositories and agent configs
266+ // are flat lists of names with no vendor to group under and no price to quote.
267+ export type ComposerModel = {
268+ id : string | null
269+ label : string
270+ // The heading this row sits under. Consecutive rows sharing a group print
271+ // one heading between them; an unset group prints none.
272+ group ?: string | null
273+ // The muted subtext printed after the label: what the model charges per 1M
274+ // tokens. Unset when there is no rate to quote (see modelRateHint).
275+ rate ?: string | null
276+ }
277+
278+ // The vendor groups, in the order their headings appear, and the names those
279+ // headings carry. Both are copies of the dashboard's rate-card table
280+ // (frontend ModelsRateCardTab + manufacturerLabel), so a model sits under the
281+ // same vendor with the same spelling in the terminal as on the web.
282+ //
283+ // Ranked, not sorted: alphabetical would put OpenAI above Anthropic, and price
284+ // would reshuffle the groups every time one rate card moves. A manufacturer
285+ // the server adds before this build knows about it lands last, under its raw
286+ // enum name, which is wrong-looking but never missing.
287+ const MANUFACTURER_ORDER : readonly string [ ] = [
288+ 'anthropic' ,
289+ 'openai' ,
290+ 'zai' ,
291+ 'minimax' ,
292+ 'moonshot' ,
293+ ]
294+ const MANUFACTURER_LABELS : Readonly < Record < string , string > > = {
295+ anthropic : 'Anthropic' ,
296+ openai : 'OpenAI' ,
297+ zai : 'Z.ai' ,
298+ minimax : 'MiniMax' ,
299+ moonshot : 'Moonshot AI' ,
300+ }
301+
302+ function manufacturerLabel ( manufacturer : ModelManufacturer | string ) : string {
303+ return MANUFACTURER_LABELS [ manufacturer ] ?? manufacturer
304+ }
305+
306+ function manufacturerRank ( manufacturer : ModelManufacturer | string ) : number {
307+ const at = MANUFACTURER_ORDER . indexOf ( manufacturer )
308+ return at === - 1 ? MANUFACTURER_ORDER . length : at
309+ }
310+
311+ // Rate-card cents per 1M tokens → "$5", "$0.75". Whole dollars drop the
312+ // ".00": at a glance "$5" is a price, where "$5.00" reads as a table cell.
313+ export function rateDollars ( cents : number ) : string {
314+ return cents % 100 === 0 ? `$${ cents / 100 } ` : `$${ ( cents / 100 ) . toFixed ( 2 ) } `
315+ }
316+
317+ // A model's price as one line of subtext: the two lanes that decide what a
318+ // session costs, read and written. The three cache lanes are deliberately
319+ // left out — five numbers on a picker row is a rate card, not a hint, and
320+ // `agent model list` (plus the dashboard's Models tab) is where the full card
321+ // belongs. Null when the server sent no card, which is the honest answer: a
322+ // stale hardcoded price is worse than no price.
323+ export function modelRateHint ( rate : ModelRateCard | null | undefined ) : string | null {
324+ if ( ! rate ) return null
325+ const input = rateDollars ( rate . input_cents_per_1m_tokens )
326+ const output = rateDollars ( rate . output_cents_per_1m_tokens )
327+ return `in ${ input } · out ${ output } per 1M`
328+ }
263329
264330// The composer's model list when GET /models is unavailable (an older
265331// server): the agent-selectable set as of this build, most expensive first.
266332// `null` id = let the server pick (DEFAULT_AGENT_MODEL). Labels are the raw
267- // model ids — the CLI speaks the API's vocabulary, not marketing names.
333+ // model ids — the CLI speaks the API's vocabulary, not marketing names. Every
334+ // id here is Anthropic-built, so the one heading is hardcoded; no rates,
335+ // because a price this list can't refresh would go stale silently.
268336export const COMPOSER_MODELS : ReadonlyArray < ComposerModel > = [
269337 { id : null , label : 'Default' } ,
270- { id : 'claude-fable-5' , label : 'claude-fable-5' } ,
271- { id : 'claude-opus-5' , label : 'claude-opus-5' } ,
272- { id : 'claude-opus-4-8' , label : 'claude-opus-4-8' } ,
273- { id : 'claude-sonnet-5' , label : 'claude-sonnet-5' } ,
274- { id : 'claude-haiku-4-5-20251001' , label : 'claude-haiku-4-5-20251001' } ,
338+ { id : 'claude-fable-5' , label : 'claude-fable-5' , group : 'Anthropic' } ,
339+ { id : 'claude-opus-5' , label : 'claude-opus-5' , group : 'Anthropic' } ,
340+ { id : 'claude-opus-4-8' , label : 'claude-opus-4-8' , group : 'Anthropic' } ,
341+ { id : 'claude-sonnet-5' , label : 'claude-sonnet-5' , group : 'Anthropic' } ,
342+ {
343+ id : 'claude-haiku-4-5-20251001' ,
344+ label : 'claude-haiku-4-5-20251001' ,
345+ group : 'Anthropic' ,
346+ } ,
275347]
276348
277- // The composer's model options from the server's list, keeping its order.
278- // Labels are raw model ids; the null "let the server pick" entry IS the
279- // default model's row — labelled with the id it resolves to
280- // (DEFAULT_AGENT_MODEL), replacing that model's own entry so the id appears
281- // once in the list.
349+ // The composer's model options from the server's list, grouped by who BUILT
350+ // each model (MANUFACTURER_ORDER) and, inside a group, left in the server's
351+ // order — which is most expensive first, so every group reads down from its
352+ // flagship. Labels are raw model ids, each carrying its rate as subtext.
353+ //
354+ // The null "let the server pick" entry IS the default model's row — labelled
355+ // with the id it resolves to (DEFAULT_AGENT_MODEL) and quoting that model's
356+ // rate, replacing its own entry so the id appears once in the list. It heads
357+ // the list under its own heading rather than sitting inside its vendor's
358+ // group, because what it selects is "the account default", not that id: the
359+ // server is still the one resolving it, and it may resolve to something else
360+ // tomorrow.
282361export function composerModelOptions ( models : readonly SupportedModel [ ] ) : ComposerModel [ ] {
283362 if ( models . length === 0 ) return [ ...COMPOSER_MODELS ]
284363 const fallback = models . find ( ( m ) => m . is_default_agent_model )
285364 return [
286- { id : null , label : fallback ? fallback . id : 'Default' } ,
365+ {
366+ id : null ,
367+ label : fallback ? fallback . id : 'Default' ,
368+ // Nothing to head a group of one with when no model claims the flag:
369+ // the row already reads "Default".
370+ group : fallback ? 'Agent default' : null ,
371+ rate : modelRateHint ( fallback ?. rate_card ) ,
372+ } ,
287373 ...models
288374 . filter ( ( m ) => ! m . is_default_agent_model )
289- . map ( ( m ) => ( { id : m . id as string | null , label : m . id } ) ) ,
375+ // Stable, so the server's within-vendor ordering survives the regroup.
376+ . sort ( ( a , b ) => manufacturerRank ( a . manufacturer ) - manufacturerRank ( b . manufacturer ) )
377+ . map ( ( m ) => ( {
378+ id : m . id as string | null ,
379+ label : m . id ,
380+ group : manufacturerLabel ( m . manufacturer ) ,
381+ rate : modelRateHint ( m . rate_card ) ,
382+ } ) ) ,
290383 ]
291384}
292385
386+ // A picker's display rows: each group's heading, then the options under it.
387+ // Headings are DECORATION — they carry no index, ↑/↓ never lands on one, and
388+ // activating a row can't select one — so the list scrolls over these rows
389+ // while the highlight stays an option index. A heading therefore scrolls away
390+ // with its group instead of pinning to the top of the window, which is what
391+ // keeps this a plain list and not a sticky-header layout.
392+ export type ComposerPickerRow =
393+ | { kind : 'group' ; label : string }
394+ | { kind : 'option' ; at : number }
395+
396+ export function composerPickerRows (
397+ options : readonly ComposerModel [ ] ,
398+ ) : ComposerPickerRow [ ] {
399+ const rows : ComposerPickerRow [ ] = [ ]
400+ let group : string | null = null
401+ options . forEach ( ( option , at ) => {
402+ const next = option . group ?? null
403+ if ( next !== null && next !== group ) rows . push ( { kind : 'group' , label : next } )
404+ group = next
405+ rows . push ( { kind : 'option' , at } )
406+ } )
407+ return rows
408+ }
409+
293410// A saved config's display name (the YAML's ellipsis.name), falling back to
294411// the row id.
295412export function configDisplayName ( config : {
0 commit comments