Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 131 additions & 14 deletions src/lib/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type {
AgentSession,
AgentSessionSource,
ListAgentSessionsQuery,
ModelManufacturer,
ModelRateCard,
StartAgentSessionRequest,
SupportedModel,
} from './types'
Expand Down Expand Up @@ -259,37 +261,152 @@ export function attentionFlip(prevWord: string | undefined, nextWord: string): b

// --------------------------- new-session picker ---------------------------

export type ComposerModel = { id: string | null; label: string }
// One row of a composer picker. `group` and `rate` are what the model list
// uses and the other two pickers leave unset: repositories and agent configs
// are flat lists of names with no vendor to group under and no price to quote.
export type ComposerModel = {
id: string | null
label: string
// The heading this row sits under. Consecutive rows sharing a group print
// one heading between them; an unset group prints none.
group?: string | null
// The muted subtext printed after the label: what the model charges per 1M
// tokens. Unset when there is no rate to quote (see modelRateHint).
rate?: string | null
}

// The vendor groups, in the order their headings appear, and the names those
// headings carry. Both are copies of the dashboard's rate-card table
// (frontend ModelsRateCardTab + manufacturerLabel), so a model sits under the
// same vendor with the same spelling in the terminal as on the web.
//
// Ranked, not sorted: alphabetical would put OpenAI above Anthropic, and price
// would reshuffle the groups every time one rate card moves. A manufacturer
// the server adds before this build knows about it lands last, under its raw
// enum name, which is wrong-looking but never missing.
const MANUFACTURER_ORDER: readonly string[] = [
'anthropic',
'openai',
'zai',
'minimax',
'moonshot',
]
const MANUFACTURER_LABELS: Readonly<Record<string, string>> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
zai: 'Z.ai',
minimax: 'MiniMax',
moonshot: 'Moonshot AI',
}

function manufacturerLabel(manufacturer: ModelManufacturer | string): string {
return MANUFACTURER_LABELS[manufacturer] ?? manufacturer
}

function manufacturerRank(manufacturer: ModelManufacturer | string): number {
const at = MANUFACTURER_ORDER.indexOf(manufacturer)
return at === -1 ? MANUFACTURER_ORDER.length : at
}

// Rate-card cents per 1M tokens → "$5", "$0.75". Whole dollars drop the
// ".00": at a glance "$5" is a price, where "$5.00" reads as a table cell.
export function rateDollars(cents: number): string {
return cents % 100 === 0 ? `$${cents / 100}` : `$${(cents / 100).toFixed(2)}`
}

// A model's price as one line of subtext: the two lanes that decide what a
// session costs, read and written. The three cache lanes are deliberately
// left out — five numbers on a picker row is a rate card, not a hint, and
// `agent model list` (plus the dashboard's Models tab) is where the full card
// belongs. Null when the server sent no card, which is the honest answer: a
// stale hardcoded price is worse than no price.
export function modelRateHint(rate: ModelRateCard | null | undefined): string | null {
if (!rate) return null
const input = rateDollars(rate.input_cents_per_1m_tokens)
const output = rateDollars(rate.output_cents_per_1m_tokens)
return `in ${input} · out ${output} per 1M`
}

// The composer's model list when GET /models is unavailable (an older
// server): the agent-selectable set as of this build, most expensive first.
// `null` id = let the server pick (DEFAULT_AGENT_MODEL). Labels are the raw
// model ids — the CLI speaks the API's vocabulary, not marketing names.
// model ids — the CLI speaks the API's vocabulary, not marketing names. Every
// id here is Anthropic-built, so the one heading is hardcoded; no rates,
// because a price this list can't refresh would go stale silently.
export const COMPOSER_MODELS: ReadonlyArray<ComposerModel> = [
{ id: null, label: 'Default' },
{ id: 'claude-fable-5', label: 'claude-fable-5' },
{ id: 'claude-opus-5', label: 'claude-opus-5' },
{ id: 'claude-opus-4-8', label: 'claude-opus-4-8' },
{ id: 'claude-sonnet-5', label: 'claude-sonnet-5' },
{ id: 'claude-haiku-4-5-20251001', label: 'claude-haiku-4-5-20251001' },
{ id: 'claude-fable-5', label: 'claude-fable-5', group: 'Anthropic' },
{ id: 'claude-opus-5', label: 'claude-opus-5', group: 'Anthropic' },
{ id: 'claude-opus-4-8', label: 'claude-opus-4-8', group: 'Anthropic' },
{ id: 'claude-sonnet-5', label: 'claude-sonnet-5', group: 'Anthropic' },
{
id: 'claude-haiku-4-5-20251001',
label: 'claude-haiku-4-5-20251001',
group: 'Anthropic',
},
]

// The composer's model options from the server's list, keeping its order.
// Labels are raw model ids; the null "let the server pick" entry IS the
// default model's row — labelled with the id it resolves to
// (DEFAULT_AGENT_MODEL), replacing that model's own entry so the id appears
// once in the list.
// The composer's model options from the server's list, grouped by who BUILT
// each model (MANUFACTURER_ORDER) and, inside a group, left in the server's
// order — which is most expensive first, so every group reads down from its
// flagship. Labels are raw model ids, each carrying its rate as subtext.
//
// The null "let the server pick" entry IS the default model's row — labelled
// with the id it resolves to (DEFAULT_AGENT_MODEL) and quoting that model's
// rate, replacing its own entry so the id appears once in the list. It heads
// the list under its own heading rather than sitting inside its vendor's
// group, because what it selects is "the account default", not that id: the
// server is still the one resolving it, and it may resolve to something else
// tomorrow.
export function composerModelOptions(models: readonly SupportedModel[]): ComposerModel[] {
if (models.length === 0) return [...COMPOSER_MODELS]
const fallback = models.find((m) => m.is_default_agent_model)
return [
{ id: null, label: fallback ? fallback.id : 'Default' },
{
id: null,
label: fallback ? fallback.id : 'Default',
// Nothing to head a group of one with when no model claims the flag:
// the row already reads "Default".
group: fallback ? 'Agent default' : null,
rate: modelRateHint(fallback?.rate_card),
},
...models
.filter((m) => !m.is_default_agent_model)
.map((m) => ({ id: m.id as string | null, label: m.id })),
// Stable, so the server's within-vendor ordering survives the regroup.
.sort((a, b) => manufacturerRank(a.manufacturer) - manufacturerRank(b.manufacturer))
.map((m) => ({
id: m.id as string | null,
label: m.id,
group: manufacturerLabel(m.manufacturer),
rate: modelRateHint(m.rate_card),
})),
]
}

// A picker's display rows: each group's heading, then the options under it.
// Headings are DECORATION — they carry no index, ↑/↓ never lands on one, and
// activating a row can't select one — so the list scrolls over these rows
// while the highlight stays an option index. A heading therefore scrolls away
// with its group instead of pinning to the top of the window, which is what
// keeps this a plain list and not a sticky-header layout.
export type ComposerPickerRow =
| { kind: 'group'; label: string }
| { kind: 'option'; at: number }

export function composerPickerRows(
options: readonly ComposerModel[],
): ComposerPickerRow[] {
const rows: ComposerPickerRow[] = []
let group: string | null = null
options.forEach((option, at) => {
const next = option.group ?? null
if (next !== null && next !== group) rows.push({ kind: 'group', label: next })
group = next
rows.push({ kind: 'option', at })
})
return rows
}

// A saved config's display name (the YAML's ellipsis.name), falling back to
// the row id.
export function configDisplayName(config: {
Expand Down
101 changes: 91 additions & 10 deletions src/ui/SessionsApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,11 @@ import {
attentionFlip,
compactTokens,
composerModelOptions,
composerPickerRows,
configDisplayName,
connectability,
type ComposerChoices,
type ComposerModel,
rowDescription,
rowGlyph,
rowMeta,
Expand Down Expand Up @@ -95,6 +97,11 @@ const NAV_GUTTER = 1
// vertical pad so the caret and text start well clear of the panel edge.
const COMPOSER_PAD_X = 2

// Everything an open picker's option row prints before its label: the row
// indent, the selection cell and its space, then the "[x] " checkbox. What the
// price column has to clear on the left.
const OPTION_GUTTER = ' '.length + 2 + '[x] '.length

export interface SessionsAppProps {
api: Ellipsis
openSocket: OpenSocket
Expand Down Expand Up @@ -886,7 +893,10 @@ function NewSessionPane({
null,
)

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

// The muted tail after a collapsed row's value: the picked model's price, so
// the row still says what a run costs once the list is folded away. Only the
// model rows carry one.
const rowNote = (key: PickerRow['key']): string | null => {
if (key !== 'model') return null
const options = optionsFor(key)
return options[Math.min(modelIdx, options.length - 1)]?.rate ?? null
}

// How many option rows an open picker shows inside the panel: the pane
// minus the heading, notices, and the panel's other rows (~12); the panel
// grows upward into the spacer above, so the prompt never moves.
Expand All @@ -1110,9 +1129,39 @@ function NewSessionPane({
const open = openPicker
const openOptions = open ? optionsFor(open.key) : []
const openHover = open ? Math.min(open.hover, openOptions.length - 1) : 0
// What actually gets printed: the options plus their group headings (the
// model list has them; the other two pickers produce a row per option and
// nothing else). The window slides over THESE rows, not over the options, so
// a heading takes a row from the capacity like anything else.
const openRows = open ? composerPickerRows(openOptions) : []
const hoverRow = Math.max(
0,
openRows.findIndex((r) => r.kind === 'option' && r.at === openHover),
)
const win = open
? sidebarSlice(openOptions.length, dropdownCapacity, openHover)
? sidebarSlice(openRows.length, dropdownCapacity, hoverRow)
: { start: 0, end: 0 }
// A heading whose options all fell past the bottom edge labels nothing, so
// the window gives its last row back rather than print it; it returns with
// its group on the next scroll.
const visibleRows = (() => {
const rows = openRows.slice(win.start, win.end)
return rows.at(-1)?.kind === 'group' ? rows.slice(0, -1) : rows
})()
// The "… N more" counts name OPTIONS, never rows: a heading is not a model,
// and counting it would overstate what is hidden above and below.
const hiddenAbove = openRows.slice(0, win.start).filter((r) => r.kind === 'option').length
const hiddenBelow = openRows.slice(win.end).filter((r) => r.kind === 'option').length
// Where the price column starts: the widest label in the list, so the rates
// read down a column instead of ragged. Dropped (0 = one space after the
// label) when the panel is too narrow to hold label and price both, since a
// padded row would push the price off the right edge into the truncation.
const rateColumn = (() => {
if (!openOptions.some((o) => o.rate)) return 0
const label = Math.max(...openOptions.map((o) => o.label.length))
const rate = Math.max(...openOptions.map((o) => (o.rate ?? '').length))
return OPTION_GUTTER + label + 2 + rate <= inputWidth ? label : 0
})()

return (
// Bottom-docked, mirroring the chat layout: the heading floats centered
Expand Down Expand Up @@ -1177,11 +1226,27 @@ function NewSessionPane({
return (
<Box key={r.key} flexDirection="column" width={inputWidth}>
<Text color={theme.muted}>{' '}{r.label}:</Text>
{win.start > 0 && (
<Text color={theme.muted}>{' '}… {win.start} more</Text>
{hiddenAbove > 0 && (
<Text color={theme.muted}>{' '}… {hiddenAbove} more</Text>
)}
{openOptions.slice(win.start, win.end).map((opt, j) => {
const at = win.start + j
{visibleRows.map((pickerRow) => {
// A group heading: the vendor that built the models under it,
// upper-cased into an eyebrow the way the dashboard's
// rate-card table sets its own, and muted so it reads as
// structure rather than as another pickable row.
if (pickerRow.kind === 'group') {
return (
<Box key={`group:${pickerRow.label}`} width={inputWidth}>
<Text wrap="truncate" color={theme.muted}>
{' '}
{pickerRow.label.toUpperCase()}
</Text>
</Box>
)
}
const at = pickerRow.at
const opt = openOptions[at]
if (!opt) return null
const hovered = at === openHover
const picked = isPicked(r.key, at)
return (
Expand All @@ -1192,20 +1257,30 @@ function NewSessionPane({
{hovered ? SELECTION_GLYPH : ' '}
</Text>{' '}
<Text color={hovered || picked ? theme.foreground : theme.muted}>
{`[${picked ? 'x' : ' '}] ${opt.label}`}
{`[${picked ? 'x' : ' '}] ${rateColumn ? opt.label.padEnd(rateColumn) : opt.label}`}
</Text>
{/* The price, always muted — subtext next to the id
whether or not the row is the highlighted one, so
walking the list never moves the eye off the name. */}
{opt.rate && (
<Text color={theme.muted}>
{' '}
{opt.rate}
</Text>
)}
</Text>
</Box>
)
})}
{win.end < openOptions.length && (
{hiddenBelow > 0 && (
<Text color={theme.muted}>
{' '}… {openOptions.length - win.end} more
{' '}… {hiddenBelow} more
</Text>
)}
</Box>
)
}
const note = rowNote(r.key)
return (
<Box key={r.key} width={inputWidth}>
<Text wrap="truncate">
Expand All @@ -1216,6 +1291,12 @@ function NewSessionPane({
<Text color={active ? theme.foreground : theme.muted}>
{rowValue(r.key)}
</Text>
{note && (
<Text color={theme.muted}>
{' '}
{note}
</Text>
)}
</Text>
</Box>
)
Expand Down
Loading