Skip to content

Commit 4b199df

Browse files
ui: group the composer's model list by maker, with each model's price as subtext (#118)
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: ellipsis-dev[bot] <ellipsis-dev[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7c0d678 commit 4b199df

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,
@@ -96,6 +98,11 @@ const NAV_GUTTER = 1
9698
// vertical pad so the caret and text start well clear of the panel edge.
9799
const COMPOSER_PAD_X = 2
98100

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

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

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

11181167
return (
11191168
// Bottom-docked, mirroring the chat layout: the heading floats centered
@@ -1181,11 +1230,27 @@ function NewSessionPane({
11811230
return (
11821231
<Box key={r.key} flexDirection="column" width={inputWidth}>
11831232
<Text color={theme.muted}>{' '}{r.label}:</Text>
1184-
{win.start > 0 && (
1185-
<Text color={theme.muted}>{' '}{win.start} more</Text>
1233+
{hiddenAbove > 0 && (
1234+
<Text color={theme.muted}>{' '}{hiddenAbove} more</Text>
11861235
)}
1187-
{openOptions.slice(win.start, win.end).map((opt, j) => {
1188-
const at = win.start + j
1236+
{visibleRows.map((pickerRow) => {
1237+
// A group heading: the vendor that built the models under it,
1238+
// upper-cased into an eyebrow the way the dashboard's
1239+
// rate-card table sets its own, and muted so it reads as
1240+
// structure rather than as another pickable row.
1241+
if (pickerRow.kind === 'group') {
1242+
return (
1243+
<Box key={`group:${pickerRow.label}`} width={inputWidth}>
1244+
<Text wrap="truncate" color={theme.muted}>
1245+
{' '}
1246+
{pickerRow.label.toUpperCase()}
1247+
</Text>
1248+
</Box>
1249+
)
1250+
}
1251+
const at = pickerRow.at
1252+
const opt = openOptions[at]
1253+
if (!opt) return null
11891254
const hovered = at === openHover
11901255
const picked = isPicked(r.key, at)
11911256
return (
@@ -1196,20 +1261,30 @@ function NewSessionPane({
11961261
{hovered ? SELECTION_GLYPH : ' '}
11971262
</Text>{' '}
11981263
<Text color={hovered || picked ? theme.foreground : theme.muted}>
1199-
{`[${picked ? 'x' : ' '}] ${opt.label}`}
1264+
{`[${picked ? 'x' : ' '}] ${rateColumn ? opt.label.padEnd(rateColumn) : opt.label}`}
12001265
</Text>
1266+
{/* The price, always muted — subtext next to the id
1267+
whether or not the row is the highlighted one, so
1268+
walking the list never moves the eye off the name. */}
1269+
{opt.rate && (
1270+
<Text color={theme.muted}>
1271+
{' '}
1272+
{opt.rate}
1273+
</Text>
1274+
)}
12011275
</Text>
12021276
</Box>
12031277
)
12041278
})}
1205-
{win.end < openOptions.length && (
1279+
{hiddenBelow > 0 && (
12061280
<Text color={theme.muted}>
1207-
{' '}{openOptions.length - win.end} more
1281+
{' '}{hiddenBelow} more
12081282
</Text>
12091283
)}
12101284
</Box>
12111285
)
12121286
}
1287+
const note = rowNote(r.key)
12131288
return (
12141289
<Box key={r.key} width={inputWidth}>
12151290
<Text wrap="truncate">
@@ -1220,6 +1295,12 @@ function NewSessionPane({
12201295
<Text color={active ? theme.foreground : theme.muted}>
12211296
{rowValue(r.key)}
12221297
</Text>
1298+
{note && (
1299+
<Text color={theme.muted}>
1300+
{' '}
1301+
{note}
1302+
</Text>
1303+
)}
12231304
</Text>
12241305
</Box>
12251306
)

0 commit comments

Comments
 (0)