fix(native-chat): locate Claude's model row by frame structure

The scraper assumed the model descriptor sits within three rows of the
`Claude Code vX` line. It does not: Claude prints it near the bottom of the
startup frame with the welcome art and release-notes panel in between — eight
rows down at 100 columns. Narrow panes degrade the frame further, dropping the
version from the title row entirely below ~70 columns and wrapping the billing
tail onto its own row. Any one of those made the scrape return null, so the
model picker showed no current selection at all.

Search the frame from its bottom border upward for the row carrying model
metadata, read only the leftmost frame cell so release-notes prose can never
win, accept the frame corner as header proof when the version is gone, and
tolerate the effort suffix being elided to an ellipsis. Catalog families now
match as a leading word, which both survives the resolved-name suffix
("Opus 5 (1M context)") and keeps custom slugs like company/my-haiku-v2 from
being claimed as haiku; an unrecognized name is reported as a custom model.

Fixtures are real: captured from a live claude 2.1.220 by replaying the PTY
bytes through @xterm/headless and serializing exactly as TerminalPane does.
This commit is contained in:
Brennan Benson
2026-08-05 18:38:50 -07:00
parent 79896cb9a6
commit d241dd33a7
2 changed files with 274 additions and 16 deletions
@@ -1,7 +1,124 @@
import { describe, expect, it } from 'vitest'
import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options'
// Captured from a live `claude` 2.1.220 startup frame by replaying the PTY
// bytes through @xterm/headless and calling serializeAddon.serialize({
// scrollback: 0 }) — byte-for-byte what TerminalPane hands the scraper. The two
// widths matter: at 100 columns the frame keeps its version and prints billing
// on the model row, at 60 it drops the version and wraps billing to its own row.
const FRESH_SESSION_100_COLS = [
'\u001b[?1049h\u001b[H\r',
'\u001b[38;2;215;119;87m╭───\u001b[1CClaude Code\u001b[1C\u001b[38;2;153;153;153mv2.1.220\u001b[1C\u001b[38;2;215;119;87m─────────────────────────────────────────────────────────────────────────╮\r',
'│\u001b[50C\u001b[2m│\u001b[1C\u001b[22;1mTips for getting started\u001b[22C\u001b[22m│\r',
'│\u001b[19C\u001b[39;1mWelcome back!\u001b[18C\u001b[38;2;215;119;87;22;2m│\u001b[1C\u001b[0mRun\u001b[1C/init\u001b[1Cto\u001b[1Ccreate\u001b[1Ca\u001b[1CCLAUDE.md\u001b[1Cfile\u001b[1Cwith\u001b[1Cin…\u001b[1C\u001b[38;2;215;119;87m│\r',
'│\u001b[50C\u001b[2m│\u001b[1C\u001b[22m─────────────────────────────────────────────\u001b[1C│\r',
"│\u001b[21C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[21C\u001b[2m│\u001b[1C\u001b[22;1mWhat's new\u001b[36C\u001b[22m│\r",
'│\u001b[21C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[20C\u001b[2m│\u001b[1C\u001b[0mBug\u001b[1Cfixes\u001b[1Cand\u001b[1Creliability\u001b[1Cimprovements\u001b[8C\u001b[38;2;215;119;87m│\r',
'│\u001b[21C ▘▘ ▝▝ \u001b[20C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1CClaude\u001b[1COpus\u001b[1C5\u001b[1C(`claude-opus-5`),\u001b[1Cnow\u001b[1Ct…\u001b[1C\u001b[38;2;215;119;87m│\r',
'│\u001b[50C\u001b[2m│\u001b[1C\u001b[0mAdded\u001b[1C`sandbox.network.strictAllowlist`\u001b[1Csett…\u001b[1C\u001b[38;2;215;119;87m│\r',
'│\u001b[3C\u001b[38;2;153;153;153mFable 5 with high effort · API Usage Billing\u001b[3C\u001b[38;2;215;119;87;2m│\u001b[1C\u001b[38;2;153;153;153;22;3m/release-notes for more\u001b[23C\u001b[38;2;215;119;87;23m│\r',
'│\u001b[19C\u001b[38;2;153;153;153m/private/tmp\u001b[19C\u001b[38;2;215;119;87;2m│\u001b[47C\u001b[22m│\r',
'╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth source is set and …\r',
'\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────────────────────────────────────────────\r',
'\u001b[0m❯ \u001b[2mTry "fix typecheck errors"\r',
'\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────────────────────────────────────────────\r',
'\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker\u001b[38;2;153;153;153m · restart with CLAUDE_COD…\r',
'\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r',
'\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h'
].join('\n')
const FRESH_SESSION_60_COLS = [
'\u001b[?1049h\u001b[H\r',
'\u001b[38;2;215;119;87m╭─ Claude Code ────────────────────────────────────────────╮\r',
'│\u001b[58C│\r',
'│\u001b[22C\u001b[39;1mWelcome back!\u001b[23C\u001b[38;2;215;119;87;22m│\r',
'│\u001b[58C│\r',
'│\u001b[25C ▐\u001b[48;2;0;0;0m▛███▜\u001b[49m▌\u001b[25C│\r',
'│\u001b[25C▝▜\u001b[48;2;0;0;0m█████\u001b[49m▛▘\u001b[24C│\r',
'│\u001b[25C ▘▘ ▝▝ \u001b[24C│\r',
'│\u001b[58C│\r',
'│\u001b[17C\u001b[38;2;153;153;153mFable 5 with high effort\u001b[17C\u001b[38;2;215;119;87m│\r',
'│\u001b[20C\u001b[38;2;153;153;153mAPI Usage Billing\u001b[21C\u001b[38;2;215;119;87m│\r',
'│\u001b[23C\u001b[38;2;153;153;153m/private/tmp\u001b[23C\u001b[38;2;215;119;87m│\r',
'│\u001b[58C│\r',
'╰──────────────────────────────────────────────────────────╯\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\r',
'\u001b[2C\u001b[38;2;255;193;7m⚠ claude.ai connectors are disabled because ANTHROPIC_AP…\r',
'\u001b[38;2;136;136;136m────────────────────────────────────────────────────────────\r',
'\u001b[0m❯ \u001b[2mTry "refactor <filepath>"\r',
'\u001b[38;2;136;136;136;22m────────────────────────────────────────────────────────────\r',
'\u001b[2C\u001b[38;2;255;193;7m⚠ Transcript saving is off — inherited CLAUDE_CODE_CHILD_…\r',
'\u001b[2C\u001b[96m🤖\u001b[38;2;153;153;153m \u001b[96mFable 5\u001b[37m | \u001b[93m📁\u001b[38;2;153;153;153m \u001b[92mtmp\u001b[37m | \u001b[2C\u001b[95m⚡️ \u001b[38;2;153;153;153m \u001b[95m11.9% · 23.8k tokens\r',
'\u001b[2C\u001b[38;2;255;107;128m⏵⏵ bypass permissions on\u001b[38;2;153;153;153m (shift+tab to cycle)\u001b[4A\u001b[45D\u001b[0m\u001b[?2004h\u001b[?1004h\u001b[?1003h'
].join('\n')
describe('Claude terminal session option detection', () => {
it('reads the model from a real 100-column startup frame', () => {
// The descriptor sits eight rows under the version, below the welcome art.
expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_100_COLS)).toEqual({
model: 'fable',
effort: 'high'
})
})
it('reads a real 60-column frame that drops the version and wraps billing', () => {
expect(readClaudeSessionOptionsFromTerminalScreen(FRESH_SESSION_60_COLS)).toEqual({
model: 'fable',
effort: 'high'
})
})
it('reports nothing rather than release-notes prose when the model row is bare', () => {
// Why: a custom model with its billing wrapped away carries no descriptor
// metadata, so nothing in the frame identifies it. The panel shares rows
// with the art and its borders collide (`││`) — reading past them would
// make panel prose win the search and report a sentence as the model.
const screen = [
'╭─── Claude Codev2.1.220 ───────────────────────╮',
'││Runs faster · uses fewer tokens│',
'│ ▐▛███▜▌│What‽s new│',
'│acme-frontier│',
'│API Usage Billing│',
'│/repo││',
'╰───────────────────────────────────────────────╯'
].join('\r\n')
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toBeNull()
})
it('reads an effort suffix the frame truncated to an ellipsis', () => {
const screen =
'Claude Code v2.1.220\r\n' +
'Opus 5 (1M context) with high… · API Usage Billing\r\n' +
'~/repo'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'opus',
effort: 'high'
})
})
it('reads the current model and effort from Claude header chrome', () => {
const screen =
'\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' +
@@ -63,4 +180,60 @@ describe('Claude terminal session option detection', () => {
readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.')
).toBeNull()
})
it('recovers a custom model name that is not in the catalog', () => {
const screen =
'\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' +
'\u001b[38;2;102;102;102mmy-custom-model with high effort · API Usage Billing\r\n' +
'~/repo'
// Custom models have no catalog options, so effort is intentionally dropped.
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'my-custom-model'
})
})
it('recovers a custom model with no effort suffix', () => {
const screen = 'Claude Code v2.1.211\r\ncompany/internal-opus · API Usage Billing\r\n~/repo'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'company/internal-opus'
})
})
it('keeps a custom model containing a catalog label', () => {
const screen = 'Claude Code v2.1.211\r\ncompany/my-haiku-v2 · API Usage Billing\r\n~/repo'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'company/my-haiku-v2'
})
})
it('keeps a custom model whose name only starts with a catalog family', () => {
// Why: the family must match as a whole leading word, not a bare prefix, or
// every vendor slug built off a family name would be claimed as that model.
const screen = 'Claude Code v2.1.211\r\nopus-internal-v3 · API Usage Billing\r\n~/repo'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'opus-internal-v3'
})
})
it('ignores catalog labels outside the descriptor row', () => {
const screen = 'Claude Code v2.1.211\r\nacme-frontier · API Usage Billing\r\n~/work/Haiku'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'acme-frontier'
})
})
it('recovers a custom model when xterm joins the descriptor to the header', () => {
const screen =
'\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211' +
'▝▜█████▛▘acme-frontier with medium effort · API Usage Billing'
expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({
model: 'acme-frontier'
})
})
})
@@ -11,12 +11,99 @@ const EFFORT_ID_BY_LABEL: Record<string, string> = {
max: 'max'
}
// Why: narrow panes elide the trailing `effort` word to an ellipsis, so the
// level itself is the only part of the suffix we can rely on.
const CLAUDE_MODEL_EFFORT =
/\bwith\s+(extra high|xhigh|medium|high|low|max)(?:\s+effort\b|\s*…|\s*$)/i
// Claude's startup frame. Chat text uses an em dash rather than these box
// characters, so a leading corner is safe proof that a row is chrome.
const FRAME_TOP = '╭'
const FRAME_BOTTOM = '╰'
const FRAME_COLUMN = '│'
/** Rows of the visible screen, ANSI-stripped and whitespace-collapsed. */
function normalizedScreenLines(screen: string): string[] {
return stripScrollbackAnsi(screen)
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
}
function isClaudeHeaderRow(line: string): boolean {
// No trailing word boundary: serialization drops the gap before the version,
// leaving `Claude Codev2.1.220`.
if (!/\bClaude Code/i.test(line)) {
return false
}
// Why: panes under ~70 columns drop the version from the frame entirely, so
// accept the frame corner as the alternative proof this is Claude's chrome.
// xterm serialization also drops cursor-positioning cells between the product
// name and version, producing `Claude Codev2.1.211`.
return /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) || line.startsWith(FRAME_TOP)
}
/** The frame's leftmost cell on a row. Reading up to the next column border
* keeps the release-notes panel out: on rows whose left cell is empty the
* borders collide (`││`) and this yields nothing rather than the panel text. */
function frameCell(line: string): string {
if (line.startsWith(FRAME_COLUMN)) {
const afterBorder = line.slice(FRAME_COLUMN.length)
const columnEnd = afterBorder.indexOf(FRAME_COLUMN)
return (columnEnd < 0 ? afterBorder : afterBorder.slice(0, columnEnd)).trim()
}
// Unframed buffers put the descriptor on the row directly, behind box art.
return line.replace(/^[^A-Za-z0-9]+/, '').trim()
}
/** Whether a cell carries the metadata Claude only prints on the model row, so
* ordinary frame text is never mistaken for a model. */
function isModelDescriptorCell(cell: string): boolean {
return cell.includes('·') || CLAUDE_MODEL_EFFORT.test(cell)
}
/**
* Claude prints the model descriptor near the bottom of its startup frame, with
* the welcome art and the release-notes panel in between — it is not adjacent to
* the version row. Search the frame from the bottom up so the panel's own text
* can never win over the descriptor.
*/
function claudeModelDescriptorCell(lines: string[], headerIndex: number): string | null {
const frameBottom = lines.findIndex(
(line, index) => index > headerIndex && line.startsWith(FRAME_BOTTOM)
)
// Why: an unframed buffer gives no bottom to bound the search, so keep the
// original tight window rather than reaching into conversation text.
const lastRow = frameBottom > 0 ? frameBottom - 1 : Math.min(headerIndex + 2, lines.length - 1)
for (let index = lastRow; index > headerIndex; index -= 1) {
const cell = frameCell(lines[index] ?? '')
if (isModelDescriptorCell(cell)) {
return cell
}
}
// xterm serialization can join the descriptor onto the version row itself.
const joined = frameCell((lines[headerIndex] ?? '').replace(/^.*?\bClaude Code\s*v?[\d.]*/i, ''))
return isModelDescriptorCell(joined) ? joined : null
}
/** The displayed model name, without the effort suffix or billing tail. */
function parseClaudeModelName(cell: string): string | null {
// split always yields a non-empty first segment for a non-empty cell.
const beforeBilling = cell.split('·')[0]!
const effort = beforeBilling.match(CLAUDE_MODEL_EFFORT)
const name = (
effort?.index === undefined ? beforeBilling : beforeBilling.slice(0, effort.index)
).trim()
return name || null
}
/** Whether the reported name is this catalog family, optionally followed by the
* resolved version the header appends. */
function leadsWithLabel(reportedModel: string, label: string): boolean {
const name = reportedModel.toLowerCase()
const family = label.toLowerCase()
return name === family || name.startsWith(`${family} `)
}
export function readClaudeSessionOptionsFromTerminalScreen(
screen: string | null | undefined
): Record<string, SessionOptionValue> | null {
@@ -24,28 +111,26 @@ export function readClaudeSessionOptionsFromTerminalScreen(
return null
}
const lines = normalizedScreenLines(screen)
const headerIndex = lines.findIndex((line) =>
// xterm serialization can remove cursor-positioning cells between the
// product name and version, producing `Claude Codev2.1.211`.
/\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line)
)
const headerIndex = lines.findIndex(isClaudeHeaderRow)
if (headerIndex < 0) {
return null
}
// Why: only Claude's fixed header rows describe live state; conversation
// text and old command confirmations elsewhere in the buffer can be stale.
const header = lines.slice(headerIndex, headerIndex + 3).join(' ')
const catalog = getAgentSessionOptionCatalog('claude')
const model = [...(catalog?.models ?? [])]
.sort((left, right) => right.label.length - left.label.length)
.find(({ label }) => header.toLowerCase().includes(label.toLowerCase()))
if (!model) {
const descriptorCell = claudeModelDescriptorCell(lines, headerIndex)
const reportedModel = descriptorCell ? parseClaudeModelName(descriptorCell) : null
if (!reportedModel) {
return null
}
const catalog = getAgentSessionOptionCatalog('claude')
// Why: catalog labels are version-neutral family names while the frame names
// the resolved model ("Opus 5 (1M context)"), so the family has to match as a
// leading word. Anchoring is what keeps `company/my-haiku-v2` out of `haiku`.
const model = catalog?.models.find(({ label }) => leadsWithLabel(reportedModel, label))
if (!model) {
// A custom model carries no catalog options, so no effort is reported.
return { model: reportedModel }
}
const result: Record<string, SessionOptionValue> = { model: model.id }
const effortLabel = header.match(
/\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i
)?.[1]
const effortLabel = descriptorCell?.match(CLAUDE_MODEL_EFFORT)?.[1]
const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined
if (effort && model.options.some((option) => option.id === 'effort')) {
result.effort = effort