Skip to content

Commit 7099a5c

Browse files
ui: group the composer's model list by maker, with each model's price as subtext
The Model row's list was a flat run of ids in one colour. It now prints a muted eyebrow per manufacturer (Anthropic, OpenAI, Z.ai, MiniMax, Moonshot AI, in the dashboard's ranked order) with each vendor's models under it, still in the server's most-expensive-first order, and each row carries its input/output rate per 1M tokens in muted ink to the right. The collapsed row keeps that price beside the picked id. Headings are decoration: they take a row from the dropdown's window but carry no index, so the highlight and ↑/↓ still walk options only, and "… N more" still counts models rather than rows. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 83c75f2 commit 7099a5c

3 files changed

Lines changed: 382 additions & 31 deletions

File tree

src/lib/sessions.ts

Lines changed: 131 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
268336
export 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.
282361
export 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.
295412
export function configDisplayName(config: {

src/ui/SessionsApp.tsx

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import {
2727
attentionFlip,
2828
compactTokens,
2929
composerModelOptions,
30+
composerPickerRows,
3031
configDisplayName,
3132
connectability,
3233
type ComposerChoices,
34+
type ComposerModel,
3335
rowDescription,
3436
rowGlyph,
3537
rowMeta,
@@ -95,6 +97,11 @@ const NAV_GUTTER = 1
9597
// vertical pad so the caret and text start well clear of the panel edge.
9698
const COMPOSER_PAD_X = 2
9799

100+
// Everything an open picker's option row prints before its label: the row
101+
// indent, the selection cell and its space, then the "[x] " checkbox. What the
102+
// price column has to clear on the left.
103+
const OPTION_GUTTER = ' '.length + 2 + '[x] '.length
104+
98105
export interface SessionsAppProps {
99106
api: Ellipsis
100107
openSocket: OpenSocket
@@ -886,7 +893,10 @@ function NewSessionPane({
886893
null,
887894
)
888895

889-
const configOptions = useMemo(
896+
// All three pickers deal in the same option shape (ComposerModel), so the
897+
// renderer can ask any of them for a group heading or a subtext; only the
898+
// model list fills those in.
899+
const configOptions = useMemo<ComposerModel[]>(
890900
() => [
891901
{ id: null as string | null, label: 'Default' },
892902
...(configs ?? []).map((c) => ({ id: c.id as string | null, label: configDisplayName(c) })),
@@ -902,7 +912,7 @@ function NewSessionPane({
902912
// unchecked, or checked alongside any others (repositories multi-select).
903913
// Only with no detection does the null Default row appear (the server still
904914
// resolves the checkout, but there's no name to show).
905-
const repoOptions = useMemo(() => {
915+
const repoOptions = useMemo<ComposerModel[]>(() => {
906916
const listed = (repos ?? []).filter((r) => r !== detectedRepo)
907917
return detectedRepo
908918
? [detectedRepo, ...listed].map((r) => ({ id: r as string | null, label: r }))
@@ -1094,6 +1104,15 @@ function NewSessionPane({
10941104
return options[Math.min(idx, options.length - 1)]?.label ?? 'Default'
10951105
}
10961106

1107+
// The muted tail after a collapsed row's value: the picked model's price, so
1108+
// the row still says what a run costs once the list is folded away. Only the
1109+
// model rows carry one.
1110+
const rowNote = (key: PickerRow['key']): string | null => {
1111+
if (key !== 'model') return null
1112+
const options = optionsFor(key)
1113+
return options[Math.min(modelIdx, options.length - 1)]?.rate ?? null
1114+
}
1115+
10971116
// How many option rows an open picker shows inside the panel: the pane
10981117
// minus the heading, notices, and the panel's other rows (~12); the panel
10991118
// grows upward into the spacer above, so the prompt never moves.
@@ -1110,9 +1129,39 @@ function NewSessionPane({
11101129
const open = openPicker
11111130
const openOptions = open ? optionsFor(open.key) : []
11121131
const openHover = open ? Math.min(open.hover, openOptions.length - 1) : 0
1132+
// What actually gets printed: the options plus their group headings (the
1133+
// model list has them; the other two pickers produce a row per option and
1134+
// nothing else). The window slides over THESE rows, not over the options, so
1135+
// a heading takes a row from the capacity like anything else.
1136+
const openRows = open ? composerPickerRows(openOptions) : []
1137+
const hoverRow = Math.max(
1138+
0,
1139+
openRows.findIndex((r) => r.kind === 'option' && r.at === openHover),
1140+
)
11131141
const win = open
1114-
? sidebarSlice(openOptions.length, dropdownCapacity, openHover)
1142+
? sidebarSlice(openRows.length, dropdownCapacity, hoverRow)
11151143
: { start: 0, end: 0 }
1144+
// A heading whose options all fell past the bottom edge labels nothing, so
1145+
// the window gives its last row back rather than print it; it returns with
1146+
// its group on the next scroll.
1147+
const visibleRows = (() => {
1148+
const rows = openRows.slice(win.start, win.end)
1149+
return rows.at(-1)?.kind === 'group' ? rows.slice(0, -1) : rows
1150+
})()
1151+
// The "… N more" counts name OPTIONS, never rows: a heading is not a model,
1152+
// and counting it would overstate what is hidden above and below.
1153+
const hiddenAbove = openRows.slice(0, win.start).filter((r) => r.kind === 'option').length
1154+
const hiddenBelow = openRows.slice(win.end).filter((r) => r.kind === 'option').length
1155+
// Where the price column starts: the widest label in the list, so the rates
1156+
// read down a column instead of ragged. Dropped (0 = one space after the
1157+
// label) when the panel is too narrow to hold label and price both, since a
1158+
// padded row would push the price off the right edge into the truncation.
1159+
const rateColumn = (() => {
1160+
if (!openOptions.some((o) => o.rate)) return 0
1161+
const label = Math.max(...openOptions.map((o) => o.label.length))
1162+
const rate = Math.max(...openOptions.map((o) => (o.rate ?? '').length))
1163+
return OPTION_GUTTER + label + 2 + rate <= inputWidth ? label : 0
1164+
})()
11161165

11171166
return (
11181167
// Bottom-docked, mirroring the chat layout: the heading floats centered
@@ -1177,11 +1226,27 @@ function NewSessionPane({
11771226
return (
11781227
<Box key={r.key} flexDirection="column" width={inputWidth}>
11791228
<Text color={theme.muted}>{' '}{r.label}:</Text>
1180-
{win.start > 0 && (
1181-
<Text color={theme.muted}>{' '}{win.start} more</Text>
1229+
{hiddenAbove > 0 && (
1230+
<Text color={theme.muted}>{' '}{hiddenAbove} more</Text>
11821231
)}
1183-
{openOptions.slice(win.start, win.end).map((opt, j) => {
1184-
const at = win.start + j
1232+
{visibleRows.map((pickerRow) => {
1233+
// A group heading: the vendor that built the models under it,
1234+
// upper-cased into an eyebrow the way the dashboard's
1235+
// rate-card table sets its own, and muted so it reads as
1236+
// structure rather than as another pickable row.
1237+
if (pickerRow.kind === 'group') {
1238+
return (
1239+
<Box key={`group:${pickerRow.label}`} width={inputWidth}>
1240+
<Text wrap="truncate" color={theme.muted}>
1241+
{' '}
1242+
{pickerRow.label.toUpperCase()}
1243+
</Text>
1244+
</Box>
1245+
)
1246+
}
1247+
const at = pickerRow.at
1248+
const opt = openOptions[at]
1249+
if (!opt) return null
11851250
const hovered = at === openHover
11861251
const picked = isPicked(r.key, at)
11871252
return (
@@ -1192,20 +1257,30 @@ function NewSessionPane({
11921257
{hovered ? SELECTION_GLYPH : ' '}
11931258
</Text>{' '}
11941259
<Text color={hovered || picked ? theme.foreground : theme.muted}>
1195-
{`[${picked ? 'x' : ' '}] ${opt.label}`}
1260+
{`[${picked ? 'x' : ' '}] ${rateColumn ? opt.label.padEnd(rateColumn) : opt.label}`}
11961261
</Text>
1262+
{/* The price, always muted — subtext next to the id
1263+
whether or not the row is the highlighted one, so
1264+
walking the list never moves the eye off the name. */}
1265+
{opt.rate && (
1266+
<Text color={theme.muted}>
1267+
{' '}
1268+
{opt.rate}
1269+
</Text>
1270+
)}
11971271
</Text>
11981272
</Box>
11991273
)
12001274
})}
1201-
{win.end < openOptions.length && (
1275+
{hiddenBelow > 0 && (
12021276
<Text color={theme.muted}>
1203-
{' '}{openOptions.length - win.end} more
1277+
{' '}{hiddenBelow} more
12041278
</Text>
12051279
)}
12061280
</Box>
12071281
)
12081282
}
1283+
const note = rowNote(r.key)
12091284
return (
12101285
<Box key={r.key} width={inputWidth}>
12111286
<Text wrap="truncate">
@@ -1216,6 +1291,12 @@ function NewSessionPane({
12161291
<Text color={active ? theme.foreground : theme.muted}>
12171292
{rowValue(r.key)}
12181293
</Text>
1294+
{note && (
1295+
<Text color={theme.muted}>
1296+
{' '}
1297+
{note}
1298+
</Text>
1299+
)}
12191300
</Text>
12201301
</Box>
12211302
)

0 commit comments

Comments
 (0)