From 7ea5f331d960b5aa7da066e2f100ae0d7ea46aea Mon Sep 17 00:00:00 2001 From: Jarrod Watts Date: Sat, 4 Apr 2026 13:59:08 +1100 Subject: [PATCH] fix: wrap HUD output when terminal width is unavailable --- src/render/index.ts | 9 ++++----- src/utils/terminal.ts | 4 +++- tests/render-width.test.js | 41 ++++++++++++++++++++++++++++++++++++++ tests/render.test.js | 26 ++++++++++++++++++++---- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/render/index.ts b/src/render/index.ts index 6ed8e55..bea3611 100644 --- a/src/render/index.ts +++ b/src/render/index.ts @@ -13,6 +13,7 @@ import { renderMemoryLine, } from './lines/index.js'; import { dim, RESET } from './colors.js'; +import { UNKNOWN_TERMINAL_WIDTH } from '../utils/terminal.js'; // eslint-disable-next-line no-control-regex const ANSI_ESCAPE_PATTERN = /^\x1b\[[0-9;]*m/; @@ -25,7 +26,7 @@ function stripAnsi(str: string): string { return str.replace(ANSI_ESCAPE_GLOBAL, ''); } -function getTerminalWidth(): number | null { +function getTerminalWidth(): number { const stdoutColumns = process.stdout?.columns; if (typeof stdoutColumns === 'number' && Number.isFinite(stdoutColumns) && stdoutColumns > 0) { return Math.floor(stdoutColumns); @@ -43,7 +44,7 @@ function getTerminalWidth(): number | null { return envColumns; } - return null; + return UNKNOWN_TERMINAL_WIDTH; } function splitAnsiTokens(str: string): Array<{ type: 'ansi' | 'text'; value: string }> { @@ -459,9 +460,7 @@ export function render(ctx: RenderContext): void { } const physicalLines = lines.flatMap(line => line.split('\n')); - const visibleLines = terminalWidth - ? physicalLines.flatMap(line => wrapLineToWidth(line, terminalWidth)) - : physicalLines; + const visibleLines = physicalLines.flatMap(line => wrapLineToWidth(line, terminalWidth)); for (const line of visibleLines) { const outputLine = `${RESET}${line}`; diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index fd82f6d..054498f 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -1,5 +1,7 @@ +export const UNKNOWN_TERMINAL_WIDTH = 40; + // Returns a progress bar width scaled to the current terminal width. -// Wide (>=100): 10, Medium (60-99): 6, Narrow (<60): 4. Defaults to 10. +// Wide (>=100): 10, Medium (60-99): 6, Narrow (<60): 4. export function getAdaptiveBarWidth(): number { const stdoutCols = process.stdout?.columns; const cols = (typeof stdoutCols === 'number' && Number.isFinite(stdoutCols) && stdoutCols > 0) diff --git a/tests/render-width.test.js b/tests/render-width.test.js index c6e012e..64b96d5 100644 --- a/tests/render-width.test.js +++ b/tests/render-width.test.js @@ -213,6 +213,47 @@ test('render falls back to stderr.columns when stdout.columns is unavailable', ( assert.ok(lines.some(line => displayWidth(line) > 10), 'stderr width should override COLUMNS fallback'); }); +test('render falls back to a safe default width when no terminal size is available', () => { + const ctx = baseContext(); + ctx.stdin.model = { display_name: 'Sonnet 4.6' }; + ctx.stdin.cwd = '/tmp/very-long-project-name-for-ghostty-fallback-check'; + ctx.gitStatus = { + branch: 'feature/ghostty-width-fallback', + isDirty: true, + ahead: 0, + behind: 0, + fileStats: { modified: 2, added: 1, deleted: 0, untracked: 1 }, + }; + ctx.config.gitStatus.showFileStats = true; + ctx.usageData = { + planName: 'Pro', + fiveHour: 42, + sevenDay: 12, + fiveHourResetAt: new Date(Date.now() + 2 * 60 * 60 * 1000), + sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }; + + const originalEnvColumns = process.env.COLUMNS; + let lines = []; + withColumns(process.stdout, undefined, () => { + withColumns(process.stderr, undefined, () => { + delete process.env.COLUMNS; + try { + lines = captureRender(ctx); + } finally { + if (originalEnvColumns === undefined) { + delete process.env.COLUMNS; + } else { + process.env.COLUMNS = originalEnvColumns; + } + } + }); + }); + + assert.ok(lines.length > 1, 'should wrap output instead of emitting one oversized line'); + assert.ok(lines.every(line => displayWidth(line) <= 40), 'all lines should fit the safe fallback width'); +}); + test('render prefers stdout columns over COLUMNS env fallback', () => { const ctx = baseContext(); ctx.stdin.cwd = '/tmp/very-long-project-name-for-width-checking'; diff --git a/tests/render.test.js b/tests/render.test.js index 025fc70..c54540f 100644 --- a/tests/render.test.js +++ b/tests/render.test.js @@ -79,6 +79,24 @@ function captureRenderLines(ctx) { return logs; } +function withColumns(stream, columns, fn) { + const originalColumns = stream.columns; + Object.defineProperty(stream, 'columns', { value: columns, configurable: true }); + try { + return fn(); + } finally { + if (originalColumns === undefined) { + delete stream.columns; + } else { + Object.defineProperty(stream, 'columns', { value: originalColumns, configurable: true }); + } + } +} + +function withTerminal(columns, fn) { + return withColumns(process.stdout, columns, fn); +} + async function withDeterministicSpeedCache(fn) { const tempConfigDir = await mkdtemp(path.join(tmpdir(), 'claude-hud-render-')); const originalConfigDir = process.env.CLAUDE_CONFIG_DIR; @@ -591,7 +609,7 @@ test('render expanded layout includes speed and duration on the project line', a ctx.config.display.showSpeed = true; ctx.sessionDuration = '12m 34s'; - const lines = captureRenderLines(ctx); + const lines = withTerminal(120, () => captureRenderLines(ctx)); const projectLine = lines.find(line => line.includes('my-project')); assert.ok(projectLine, 'expected an expanded project line'); @@ -1238,7 +1256,7 @@ test('renderUsageLine uses custom usage palette overrides', () => { sevenDayResetAt: null, }; - const line = renderUsageLine(ctx); + const line = withTerminal(120, () => renderUsageLine(ctx)); assert.ok(line, 'should render usage line'); assert.ok(line.includes('\x1b[36m███'), `expected custom usage bar color, got: ${JSON.stringify(line)}`); assert.ok(line.includes('\x1b[36m25%\x1b[0m'), `expected custom usage percentage color, got: ${JSON.stringify(line)}`); @@ -1456,7 +1474,7 @@ test('render expanded layout honors custom elementOrder including activity place ctx.config.display.showMemoryUsage = true; ctx.config.elementOrder = ['tools', 'project', 'usage', 'context', 'memory', 'environment', 'agents', 'todos']; - const lines = captureRenderLines(ctx); + const lines = withTerminal(120, () => captureRenderLines(ctx)); const toolIndex = lines.findIndex(line => line.includes('Read')); const projectIndex = lines.findIndex(line => line.includes('my-project')); const combinedIndex = lines.findIndex(line => line.includes('Usage') && line.includes('Context')); @@ -1532,7 +1550,7 @@ test('render expanded layout combines usage and context when adjacent in element }; ctx.config.elementOrder = ['usage', 'context']; - const lines = captureRenderLines(ctx); + const lines = withTerminal(120, () => captureRenderLines(ctx)); assert.equal(lines.length, 1, 'adjacent usage and context should share one expanded line'); assert.ok(lines[0].includes('Usage'), 'combined line should include usage');