fix(native-chat): identify option-less models on narrow panes

Live capture at 60 columns: a Haiku session prints a bare `Haiku 4.5` row with
its billing wrapped to the next line. No middot, no effort suffix — nothing
marks it as the model, so it reported nothing. Claude always closes the frame
with the working directory and prints at most the descriptor plus a wrapped
billing line above it, so fall back to walking up from there when no row
carries descriptor metadata. The height bound is what keeps the walk from
climbing into the welcome art.
This commit is contained in:
Brennan Benson
2026-08-05 21:01:00 -07:00
parent 6138546904
commit 09ddc758b0
2 changed files with 76 additions and 1 deletions
@@ -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 <filepath> 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
@@ -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. */