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 175e0c01ec1..d1879011f16 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 @@ -73,6 +73,39 @@ const FRESH_SESSION_60_COLS = [ '\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 OPTION_LESS_MODEL_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[24C\u001b[38;2;153;153;153mHaiku 4.5\u001b[25C\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 "how does work?"\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[96mHaiku 4.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 EFFORT = { id: 'effort', label: 'Effort', @@ -104,6 +137,15 @@ describe('Claude terminal session option detection', () => { }) }) + it('reads a real narrow frame whose option-less model carries no metadata', () => { + // Why: `Haiku 4.5` has no effort control and its billing wrapped to the next + // row, so nothing marks the row as a model — only its position above the + // working directory does. + expect(readClaudeSessionOptionsFromTerminalScreen(OPTION_LESS_MODEL_60_COLS)).toEqual({ + model: 'haiku' + }) + }) + 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 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 0ee39ad9319..6939c57796d 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 @@ -85,7 +85,40 @@ function claudeModelDescriptorCell(lines: string[], headerIndex: number): string } // 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 + if (isModelDescriptorCell(joined)) { + return joined + } + return frameBottom > 0 ? modelRowAboveWorkingDirectory(lines, headerIndex, frameBottom) : null +} + +/** + * A model with no effort control whose billing wrapped away — `Haiku 4.5` on a + * narrow pane — leaves a bare name that no metadata identifies. Claude always + * closes the frame with the working directory, and prints at most the + * descriptor plus a wrapped billing line above it, so walk up from there. + */ +function modelRowAboveWorkingDirectory( + lines: string[], + headerIndex: number, + frameBottom: number +): string | null { + let workingDirectory = -1 + for (let index = frameBottom - 1; index > headerIndex; index -= 1) { + const cell = frameCell(lines[index] ?? '') + if (cell.startsWith('/') || cell.startsWith('~')) { + workingDirectory = index + break + } + } + if (workingDirectory < 0) { + return null + } + let top = workingDirectory + while (top - 1 > headerIndex && frameCell(lines[top - 1] ?? '')) { + top -= 1 + } + // Anything taller than descriptor + billing is the welcome art, not a model. + return top < workingDirectory && workingDirectory - top <= 2 ? frameCell(lines[top] ?? '') : null } /** The displayed model name, without the effort suffix or billing tail. */