diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index a41d62eeb7a..f3ce5d4f295 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -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 "\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' + }) + }) }) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts index 7a1e5b8fbd8..185897e8e79 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -11,12 +11,99 @@ const EFFORT_ID_BY_LABEL: Record = { 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 | 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 = { 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