804 lines
27 KiB
JavaScript
804 lines
27 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { render } from '../dist/render/index.js';
|
|
import { mergeConfig } from '../dist/config.js';
|
|
import { setLanguage } from '../dist/i18n/index.js';
|
|
|
|
function baseContext() {
|
|
return {
|
|
stdin: {
|
|
model: { display_name: 'Opus' },
|
|
context_window: {
|
|
context_window_size: 200000,
|
|
current_usage: {
|
|
input_tokens: 10000,
|
|
cache_creation_input_tokens: 0,
|
|
cache_read_input_tokens: 0,
|
|
},
|
|
},
|
|
},
|
|
transcript: { tools: [], agents: [], todos: [] },
|
|
claudeMdCount: 0,
|
|
rulesCount: 0,
|
|
mcpCount: 0,
|
|
hooksCount: 0,
|
|
sessionDuration: '',
|
|
gitStatus: null,
|
|
usageData: null,
|
|
config: {
|
|
lineLayout: 'compact',
|
|
showSeparators: false,
|
|
pathLevels: 1,
|
|
gitStatus: { enabled: true, showDirty: true, showAheadBehind: false, showFileStats: false, branchOverflow: 'truncate' },
|
|
display: {
|
|
showModel: true,
|
|
showContextBar: true,
|
|
contextValue: 'percent',
|
|
showConfigCounts: true,
|
|
showDuration: true,
|
|
showSpeed: false,
|
|
showTokenBreakdown: true,
|
|
showUsage: true,
|
|
usageValue: 'percent',
|
|
usageBarEnabled: false,
|
|
showTools: true,
|
|
showAgents: true,
|
|
showTodos: true,
|
|
mergeGroups: [['context', 'usage']],
|
|
autocompactBuffer: 'enabled',
|
|
usageThreshold: 0,
|
|
sevenDayThreshold: 80,
|
|
environmentThreshold: 0,
|
|
},
|
|
},
|
|
extraLabel: null,
|
|
};
|
|
}
|
|
|
|
function stripAnsi(str) {
|
|
// eslint-disable-next-line no-control-regex
|
|
return str
|
|
.replace(/\x1b\[[0-9;]*m/g, '')
|
|
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
|
|
}
|
|
|
|
function isWideCodePoint(codePoint) {
|
|
return codePoint >= 0x1100 && (
|
|
codePoint <= 0x115F ||
|
|
codePoint === 0x2329 ||
|
|
codePoint === 0x232A ||
|
|
(codePoint >= 0x2E80 && codePoint <= 0xA4CF && codePoint !== 0x303F) ||
|
|
(codePoint >= 0xAC00 && codePoint <= 0xD7A3) ||
|
|
(codePoint >= 0xF900 && codePoint <= 0xFAFF) ||
|
|
(codePoint >= 0xFE10 && codePoint <= 0xFE19) ||
|
|
(codePoint >= 0xFE30 && codePoint <= 0xFE6F) ||
|
|
(codePoint >= 0xFF00 && codePoint <= 0xFF60) ||
|
|
(codePoint >= 0xFFE0 && codePoint <= 0xFFE6) ||
|
|
(codePoint >= 0x1F300 && codePoint <= 0x1FAFF) ||
|
|
(codePoint >= 0x20000 && codePoint <= 0x3FFFD)
|
|
);
|
|
}
|
|
|
|
function displayWidth(text) {
|
|
let width = 0;
|
|
for (const char of Array.from(text)) {
|
|
const codePoint = char.codePointAt(0);
|
|
width += codePoint !== undefined && isWideCodePoint(codePoint) ? 2 : 1;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
function withColumns(stream, columns, fn) {
|
|
const originalColumns = stream.columns;
|
|
Object.defineProperty(stream, 'columns', { value: columns, configurable: true });
|
|
try {
|
|
fn();
|
|
} finally {
|
|
if (originalColumns === undefined) {
|
|
delete stream.columns;
|
|
} else {
|
|
Object.defineProperty(stream, 'columns', { value: originalColumns, configurable: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
function withTerminal(columns, fn) {
|
|
withColumns(process.stdout, columns, fn);
|
|
}
|
|
|
|
function captureRender(ctx) {
|
|
const logs = [];
|
|
const originalLog = console.log;
|
|
console.log = line => logs.push(line);
|
|
try {
|
|
render(ctx);
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
return logs.map(line => stripAnsi(line).replace(/\u00A0/g, ' '));
|
|
}
|
|
|
|
function countContaining(lines, needle) {
|
|
return lines.filter(line => line.includes(needle)).length;
|
|
}
|
|
|
|
test('render wraps long lines to terminal width and keeps all activity lines visible', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.model = { display_name: 'Sonnet 4.6' };
|
|
ctx.stdin.cwd = '/tmp/very-long-project-name-for-terminal-wrap-checking';
|
|
ctx.gitStatus = {
|
|
branch: 'feature/this-is-a-very-long-branch-name',
|
|
isDirty: true,
|
|
ahead: 7,
|
|
behind: 0,
|
|
fileStats: { modified: 12, added: 4, deleted: 2, untracked: 9 },
|
|
};
|
|
ctx.config.gitStatus.showFileStats = true;
|
|
ctx.claudeMdCount = 1;
|
|
ctx.rulesCount = 2;
|
|
ctx.hooksCount = 3;
|
|
ctx.usageData = {
|
|
planName: 'Team',
|
|
fiveHour: 30,
|
|
sevenDay: 3,
|
|
fiveHourResetAt: new Date(Date.now() + 2 * 60 * 60 * 1000),
|
|
sevenDayResetAt: new Date(Date.now() + 6 * 24 * 60 * 60 * 1000),
|
|
};
|
|
ctx.transcript.tools = [
|
|
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
|
|
];
|
|
ctx.transcript.agents = [
|
|
{ id: 'agent-1', type: 'plan-a', status: 'running', startTime: new Date(0) },
|
|
{ id: 'agent-2', type: 'plan-b', status: 'completed', startTime: new Date(0), endTime: new Date(3000) },
|
|
{ id: 'agent-3', type: 'plan-c', status: 'completed', startTime: new Date(0), endTime: new Date(3500) },
|
|
];
|
|
ctx.transcript.todos = [
|
|
{ content: 'todo-marker', status: 'in_progress' },
|
|
];
|
|
|
|
let lines = [];
|
|
withTerminal(20, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(countContaining(lines, 'Read'), 1, 'tool line should remain visible');
|
|
assert.equal(countContaining(lines, 'plan-a'), 1, 'first agent line should remain visible');
|
|
assert.equal(countContaining(lines, 'plan-b'), 1, 'second agent line should remain visible');
|
|
assert.equal(countContaining(lines, 'plan-c'), 1, 'third agent line should remain visible');
|
|
assert.equal(countContaining(lines, 'todo-marker'), 1, 'todo line should remain visible');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 20), 'all lines should fit terminal width');
|
|
});
|
|
|
|
test('render can wrap git to its own line without truncating the branch name', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project-with-a-long-name';
|
|
ctx.gitStatus = {
|
|
branch: 'feature/this-is-a-very-long-branch-name',
|
|
isDirty: true,
|
|
ahead: 0,
|
|
behind: 0,
|
|
};
|
|
ctx.config.gitStatus.branchOverflow = 'wrap';
|
|
|
|
let lines = [];
|
|
withTerminal(55, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.every(line => displayWidth(line) <= 55), 'all lines should fit terminal width');
|
|
assert.ok(lines.some(line => line.includes('git:(feature/this-is-a-very-long-branch-name*)')), 'git branch should remain intact on its own line');
|
|
});
|
|
|
|
test('render falls back to COLUMNS env when stdout.columns is unavailable', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.extraLabel = '你好你好你好你好你好';
|
|
const originalEnvColumns = process.env.COLUMNS;
|
|
|
|
let lines = [];
|
|
withTerminal(undefined, () => {
|
|
process.env.COLUMNS = '10';
|
|
try {
|
|
lines = captureRender(ctx);
|
|
} finally {
|
|
if (originalEnvColumns === undefined) {
|
|
delete process.env.COLUMNS;
|
|
} else {
|
|
process.env.COLUMNS = originalEnvColumns;
|
|
}
|
|
}
|
|
});
|
|
|
|
assert.ok(lines.length > 1, 'should still render output lines');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 10), 'all lines should fit COLUMNS width');
|
|
});
|
|
|
|
|
|
test('render falls back to stderr.columns when stdout.columns and COLUMNS are unavailable', () => {
|
|
const ctx = baseContext();
|
|
const originalEnvColumns = process.env.COLUMNS;
|
|
|
|
let lines = [];
|
|
withColumns(process.stdout, undefined, () => {
|
|
withColumns(process.stderr, 12, () => {
|
|
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 > 0, 'should still render output lines');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 12), 'stderr width should be honored');
|
|
assert.ok(lines.some(line => displayWidth(line) > 10), 'stderr width should be used when no env override exists');
|
|
});
|
|
|
|
test('render does not use maxWidth over a detected 80-column width unless forceMaxWidth is enabled', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.config.maxWidth = 300;
|
|
ctx.extraLabel = 'x'.repeat(120);
|
|
|
|
let lines = [];
|
|
withTerminal(80, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.length > 1, 'should still wrap when only detected width is 80 and forceMaxWidth is disabled');
|
|
});
|
|
|
|
test('render ignores forceMaxWidth when maxWidth is null', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.config.forceMaxWidth = true;
|
|
ctx.extraLabel = 'x'.repeat(120);
|
|
|
|
let lines = [];
|
|
withTerminal(80, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.length > 1, 'should keep using detected width when forceMaxWidth is enabled without maxWidth');
|
|
});
|
|
|
|
test('render ignores forceMaxWidth when maxWidth is invalid in user config', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.config = {
|
|
...ctx.config,
|
|
...mergeConfig({ maxWidth: 'wide', forceMaxWidth: true }),
|
|
display: ctx.config.display,
|
|
gitStatus: ctx.config.gitStatus,
|
|
};
|
|
ctx.extraLabel = 'x'.repeat(120);
|
|
|
|
let lines = [];
|
|
withTerminal(80, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.length > 1, 'should keep using detected width when invalid maxWidth is normalized away');
|
|
});
|
|
|
|
test('render uses maxWidth over a detected 80-column width when forceMaxWidth is enabled', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.config.maxWidth = 300;
|
|
ctx.config.forceMaxWidth = true;
|
|
ctx.extraLabel = 'x'.repeat(120);
|
|
|
|
let lines = [];
|
|
withTerminal(80, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(lines.length, 1, 'should keep the line intact when forceMaxWidth overrides a detected 80-column width');
|
|
assert.ok(lines[0].includes('x'.repeat(120)), 'should not truncate the long label when forceMaxWidth is enabled');
|
|
assert.ok(!lines[0].includes('...'), 'should avoid ellipsis truncation');
|
|
});
|
|
|
|
test('render ignores OSC 8 hyperlink sequences when measuring line width', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'compact';
|
|
ctx.stdin.context_window.current_usage.input_tokens = 0;
|
|
ctx.config.display.showContextBar = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.config.display.showUsage = false;
|
|
ctx.stdin.cwd = '/tmp/my-project';
|
|
ctx.sessionDuration = '1m';
|
|
ctx.extraLabel = '\x1b]8;;file:///tmp/my-project\x1b\\linked-label\x1b]8;;\x1b\\';
|
|
|
|
let lines = [];
|
|
withTerminal(47, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(lines.length, 1, 'a visibly short line with an OSC 8 hyperlink should stay on one line');
|
|
assert.ok(lines[0].includes('linked-label'), 'hyperlink label text should still render');
|
|
assert.ok(lines[0].includes('1m'), 'later elements should not be wrapped off the line');
|
|
assert.ok(displayWidth(lines[0]) <= 47, 'visible width should respect terminal width');
|
|
});
|
|
|
|
|
|
test('render ignores BEL-terminated OSC 8 hyperlink sequences when measuring line width', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'compact';
|
|
ctx.stdin.context_window.current_usage.input_tokens = 0;
|
|
ctx.config.display.showContextBar = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.config.display.showUsage = false;
|
|
ctx.stdin.cwd = '/tmp/my-project';
|
|
ctx.sessionDuration = '1m';
|
|
ctx.extraLabel = '\x1b]8;;file:///tmp/my-project\x07linked-label\x1b]8;;\x07';
|
|
|
|
let lines = [];
|
|
withTerminal(47, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(lines.length, 1, 'a visibly short BEL-terminated OSC 8 hyperlink should stay on one line');
|
|
assert.ok(lines[0].includes('linked-label'), 'hyperlink label text should still render');
|
|
assert.ok(lines[0].includes('1m'), 'later elements should not be wrapped off the line');
|
|
assert.ok(displayWidth(lines[0]) <= 47, 'visible width should respect terminal width');
|
|
});
|
|
|
|
test('render closes an OSC 8 hyperlink when truncation cuts inside it', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'compact';
|
|
ctx.stdin.context_window.current_usage.input_tokens = 0;
|
|
ctx.config.display.showContextBar = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.config.display.showUsage = false;
|
|
ctx.stdin.cwd = '/tmp/p';
|
|
// Label is wider than the terminal so truncation must cut inside it.
|
|
const linkLabel = 'a-very-long-hyperlink-label-that-must-be-truncated';
|
|
ctx.extraLabel = `\x1b]8;;file:///tmp/p/x\x1b\\${linkLabel}\x1b]8;;\x1b\\`;
|
|
|
|
const logs = [];
|
|
const originalLog = console.log;
|
|
console.log = line => logs.push(line);
|
|
try {
|
|
// Drive render directly so logs retain ANSI/OSC sequences (the
|
|
// shared captureRender helper strips them before returning).
|
|
withTerminal(20, () => render(ctx));
|
|
} finally {
|
|
console.log = originalLog;
|
|
}
|
|
|
|
const rawLines = logs;
|
|
const truncated = rawLines.find(line => line.includes('...'));
|
|
assert.ok(truncated, 'one line should have been truncated with an ellipsis');
|
|
|
|
const sliceBeforeEllipsis = truncated.slice(0, truncated.indexOf('...'));
|
|
assert.ok(
|
|
/\x1b\]8;;[^\x07\x1b]+(?:\x07|\x1b\\)/.test(sliceBeforeEllipsis),
|
|
'the truncated slice should have opened an OSC 8 hyperlink',
|
|
);
|
|
assert.ok(
|
|
/\x1b\]8;;(?:\x07|\x1b\\)/.test(sliceBeforeEllipsis),
|
|
'the truncated slice should close the hyperlink before the ellipsis so the underline does not bleed past',
|
|
);
|
|
});
|
|
|
|
test('render does not wrap when no real terminal width 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;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// When terminal width is unknown (UNKNOWN_TERMINAL_WIDTH fallback),
|
|
// lines should NOT be wrapped at an arbitrary width — the host process
|
|
// (e.g. Claude Code) is responsible for final display layout.
|
|
assert.ok(lines.length >= 1, 'should still produce output');
|
|
});
|
|
|
|
test('render uses config.maxWidth as fallback when terminal width is unavailable', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.model = { display_name: 'Sonnet 4.6' };
|
|
ctx.stdin.cwd = '/tmp/very-long-project-name-for-maxwidth-fallback';
|
|
ctx.config.maxWidth = 30;
|
|
ctx.usageData = {
|
|
fiveHour: 42,
|
|
sevenDay: null,
|
|
fiveHourResetAt: null,
|
|
sevenDayResetAt: null,
|
|
};
|
|
|
|
// When no terminal size is available, maxWidth should be used as fallback
|
|
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 > 0, 'should produce output');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 30), 'all lines should fit within maxWidth');
|
|
});
|
|
|
|
test('render ignores config.maxWidth when terminal width is detected', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.model = { display_name: 'Sonnet 4.6' };
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.config.maxWidth = 30;
|
|
|
|
// When terminal reports a real width, maxWidth should NOT cap it
|
|
let lines = [];
|
|
withTerminal(120, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
// Lines should use the detected 120 columns, not the 30 maxWidth
|
|
assert.ok(lines.length > 0, 'should produce output');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 120), 'lines should fit detected width');
|
|
// Compact session line is typically wider than 30 when model+context are shown
|
|
const widest = Math.max(...lines.map(displayWidth));
|
|
assert.ok(widest > 30, 'should use detected terminal width, not maxWidth');
|
|
});
|
|
|
|
test('render treats an actual 40-column terminal as a real width', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/very-long-project-name-for-real-40-column-check';
|
|
ctx.extraLabel = 'extra-segment-for-40-column-check';
|
|
|
|
let lines = [];
|
|
withTerminal(40, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.length > 1, 'real 40-column terminals should still wrap');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 40), 'all lines should respect the real 40-column width');
|
|
});
|
|
|
|
test('render does not treat a real 40-column terminal as unknown maxWidth fallback', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.maxWidth = 30;
|
|
ctx.config.display.showModel = false;
|
|
ctx.config.display.showContextBar = false;
|
|
ctx.config.display.showProject = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.config.display.showDuration = false;
|
|
ctx.extraLabel = '12345678901234567890123456789012345';
|
|
|
|
let lines = [];
|
|
withTerminal(40, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(lines.length, 1, 'real 40-column terminals should not fall back to maxWidth wrapping');
|
|
assert.ok(lines[0].includes('12345678901234567890123456789012345'), 'full extra label should remain visible at the real terminal width');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 40), 'lines should still fit the real terminal width');
|
|
});
|
|
|
|
test('render does not strand a bare 5h continuation line in compact mode', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'compact';
|
|
ctx.config.display.usageBarEnabled = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.usageData = {
|
|
planName: 'Pro',
|
|
fiveHour: 30,
|
|
sevenDay: 85,
|
|
fiveHourResetAt: new Date(Date.now() + 60 * 60 * 1000),
|
|
sevenDayResetAt: new Date(Date.now() + 28 * 60 * 60 * 1000),
|
|
};
|
|
|
|
let lines = [];
|
|
withColumns(process.stdout, undefined, () => {
|
|
withColumns(process.stderr, 40, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
});
|
|
|
|
assert.ok(lines.some(line => line.includes('Usage 5h 30%')), `expected usage window to keep its label: ${lines.join(' | ')}`);
|
|
assert.ok(lines.some(line => line.includes('Weekly 85%')), `expected weekly usage window to render: ${lines.join(' | ')}`);
|
|
assert.ok(!lines.some(line => line.startsWith('5h ')), `did not expect a bare 5h continuation line: ${lines.join(' | ')}`);
|
|
});
|
|
|
|
test('render treats COLUMNS env as a hard override over stdout width', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/very-long-project-name-for-width-checking';
|
|
const originalEnvColumns = process.env.COLUMNS;
|
|
process.env.COLUMNS = '10';
|
|
|
|
let lines = [];
|
|
withTerminal(30, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
if (originalEnvColumns === undefined) {
|
|
delete process.env.COLUMNS;
|
|
} else {
|
|
process.env.COLUMNS = originalEnvColumns;
|
|
}
|
|
|
|
assert.ok(lines.every(line => displayWidth(line) <= 10), 'COLUMNS override should be honored');
|
|
assert.ok(lines.length > 1, 'narrow env override should force wrapping');
|
|
});
|
|
|
|
test('render does not split model/provider separator inside brackets', () => {
|
|
process.env.CLAUDE_CODE_USE_BEDROCK = '1';
|
|
try {
|
|
const ctx = baseContext();
|
|
ctx.stdin.model = { display_name: 'Sonnet', id: 'anthropic.claude-3-5-sonnet-20240620-v1:0' };
|
|
ctx.config.display.showUsage = false;
|
|
ctx.config.display.showContextBar = false;
|
|
ctx.config.display.showConfigCounts = false;
|
|
ctx.config.display.showDuration = false;
|
|
|
|
let wideLines = [];
|
|
withTerminal(80, () => {
|
|
wideLines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(wideLines.some(line => line.includes('[Sonnet | Bedrock]')), 'model/provider badge should be preserved when width allows');
|
|
|
|
let lines = [];
|
|
withTerminal(12, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.equal(lines.length, 1, 'single compact line should be truncated, not split');
|
|
assert.ok(!lines[0].startsWith('Bedrock]'), 'provider label should not become a wrapped prefix');
|
|
} finally {
|
|
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
|
}
|
|
});
|
|
|
|
test('render clamps separator width in narrow terminals', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.showSeparators = true;
|
|
ctx.transcript.tools = [
|
|
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
|
|
];
|
|
|
|
let lines = [];
|
|
withTerminal(8, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
const separatorLine = lines.find(line => line.includes('─'));
|
|
assert.ok(separatorLine, 'separator should render when enabled with activity');
|
|
assert.ok(displayWidth(separatorLine) <= 8, 'separator should fit terminal width');
|
|
});
|
|
|
|
test('render truncation respects Unicode display width', () => {
|
|
const ctx = baseContext();
|
|
ctx.stdin.cwd = '/tmp/project';
|
|
ctx.extraLabel = '你好你好你好你好你好';
|
|
|
|
let lines = [];
|
|
withTerminal(10, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.some(line => line.includes('...')), 'should truncate an overlong Unicode segment');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 10), 'all lines should respect terminal cell width');
|
|
});
|
|
|
|
test('render keeps default merge-group elements as separate lines when a narrow terminal cannot fit both', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'expanded';
|
|
ctx.config.display.usageBarEnabled = true;
|
|
ctx.stdin.context_window.current_usage.input_tokens = 120000;
|
|
ctx.usageData = {
|
|
fiveHour: 62,
|
|
sevenDay: null,
|
|
fiveHourResetAt: null,
|
|
sevenDayResetAt: null,
|
|
};
|
|
|
|
let lines = [];
|
|
withTerminal(24, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
|
|
assert.ok(lines.some(line => line.includes('Context')), 'context line should remain visible');
|
|
assert.ok(lines.some(line => line.includes('Usage')), 'usage line should remain visible');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 24), 'all lines should still fit terminal width');
|
|
});
|
|
|
|
test('render respects terminal width with Chinese labels enabled', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'expanded';
|
|
ctx.usageData = {
|
|
planName: 'Pro',
|
|
fiveHour: 42,
|
|
sevenDay: 12,
|
|
fiveHourResetAt: new Date(Date.now() + 90 * 60000),
|
|
sevenDayResetAt: new Date(Date.now() + 24 * 60 * 60000),
|
|
};
|
|
|
|
let lines = [];
|
|
setLanguage('zh');
|
|
try {
|
|
withTerminal(18, () => {
|
|
lines = captureRender(ctx);
|
|
});
|
|
} finally {
|
|
setLanguage('en');
|
|
}
|
|
|
|
assert.ok(lines.some(line => line.includes('上下文')), 'should render the translated context label');
|
|
assert.ok(lines.some(line => line.includes('用量')), 'should render the translated usage label');
|
|
assert.ok(lines.every(line => displayWidth(line) <= 18), 'all lines should fit terminal width with CJK labels');
|
|
});
|
|
|
|
// CJK terminals render East Asian Ambiguous chars (█ ░ │ ◐ ✓ etc.) as 2 cells.
|
|
// Without compensating width math, lines that look short to the code overflow
|
|
// the visible terminal and get wrapped by the terminal itself.
|
|
function ambiguousDisplayWidth(text) {
|
|
let width = 0;
|
|
for (const char of Array.from(text)) {
|
|
const cp = char.codePointAt(0);
|
|
if (cp === undefined) {
|
|
width += 1;
|
|
continue;
|
|
}
|
|
if (isWideCodePoint(cp)) {
|
|
width += 2;
|
|
continue;
|
|
}
|
|
const isAmbiguousWide =
|
|
(cp >= 0x2010 && cp <= 0x2027) ||
|
|
(cp >= 0x2030 && cp <= 0x205E) ||
|
|
(cp >= 0x2190 && cp <= 0x21FF) ||
|
|
(cp >= 0x2200 && cp <= 0x22FF) ||
|
|
(cp >= 0x2300 && cp <= 0x23FF) ||
|
|
(cp >= 0x2460 && cp <= 0x24FF) ||
|
|
(cp >= 0x2500 && cp <= 0x259F) ||
|
|
(cp >= 0x25A0 && cp <= 0x25FF) ||
|
|
(cp >= 0x2600 && cp <= 0x26FF) ||
|
|
(cp >= 0x2700 && cp <= 0x27BF);
|
|
width += isAmbiguousWide ? 2 : 1;
|
|
}
|
|
return width;
|
|
}
|
|
|
|
test('render wraps progress bars when CJK ambiguous-width chars overflow the terminal', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.language = 'zh';
|
|
ctx.config.lineLayout = 'expanded';
|
|
ctx.config.display.showUsage = true;
|
|
ctx.config.display.usageBarEnabled = true;
|
|
ctx.usageData = {
|
|
fiveHour: 49,
|
|
sevenDay: null,
|
|
fiveHourResetAt: new Date(Date.now() + 3 * 3600 * 1000 + 12 * 60 * 1000),
|
|
sevenDayResetAt: null,
|
|
};
|
|
|
|
let cjkLines = [];
|
|
setLanguage('zh');
|
|
try {
|
|
withTerminal(40, () => {
|
|
cjkLines = captureRender(ctx);
|
|
});
|
|
} finally {
|
|
setLanguage('en');
|
|
}
|
|
|
|
assert.ok(
|
|
cjkLines.every(line => ambiguousDisplayWidth(line) <= 40),
|
|
'no line should overflow 40 cells when ambiguous-width chars count as 2',
|
|
);
|
|
|
|
let enLines = [];
|
|
withTerminal(40, () => {
|
|
enLines = captureRender(ctx);
|
|
});
|
|
assert.ok(enLines.length > 0, 'non-CJK mode should still produce output');
|
|
});
|
|
|
|
test('separator width accounts for CJK ambiguous-wide dashes so the terminal does not wrap it', () => {
|
|
const ctx = baseContext();
|
|
ctx.config.lineLayout = 'expanded';
|
|
ctx.config.showSeparators = true;
|
|
ctx.config.display.showContextBar = true;
|
|
ctx.config.display.showUsage = true;
|
|
ctx.config.display.usageBarEnabled = true;
|
|
ctx.usageData = {
|
|
fiveHour: 49,
|
|
sevenDay: null,
|
|
fiveHourResetAt: new Date(Date.now() + 3 * 3600 * 1000),
|
|
sevenDayResetAt: null,
|
|
};
|
|
ctx.transcript.tools = [
|
|
{ id: 'tool-1', name: 'Read', status: 'completed', startTime: new Date(0), endTime: new Date(0), duration: 0 },
|
|
];
|
|
|
|
let cjkLines = [];
|
|
setLanguage('zh');
|
|
try {
|
|
withTerminal(120, () => {
|
|
cjkLines = captureRender(ctx);
|
|
});
|
|
} finally {
|
|
setLanguage('en');
|
|
}
|
|
|
|
const separatorLines = cjkLines.filter(line => /^[\s─]+$/.test(line));
|
|
assert.equal(separatorLines.length, 1, 'separator should render exactly once and not be split into multiple lines');
|
|
assert.ok(
|
|
ambiguousDisplayWidth(separatorLines[0]) <= 120,
|
|
`separator visual width must fit terminal in CJK mode (got ${ambiguousDisplayWidth(separatorLines[0])} cells, terminal=120)`,
|
|
);
|
|
|
|
for (const line of cjkLines) {
|
|
assert.ok(
|
|
ambiguousDisplayWidth(line) <= 120,
|
|
`line "${line}" exceeds 120 cells in CJK mode (got ${ambiguousDisplayWidth(line)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
|
|
test('width math counts ambiguous chars as 2 cells only in CJK mode', async () => {
|
|
const { codePointCellWidth, isAmbiguousWideCodePoint, isCjkAmbiguousWide } =
|
|
await import('../dist/render/width.js');
|
|
|
|
assert.equal(isAmbiguousWideCodePoint(0x2588), true, '█ U+2588 is ambiguous');
|
|
assert.equal(isAmbiguousWideCodePoint(0x2502), true, '│ U+2502 is ambiguous');
|
|
assert.equal(isAmbiguousWideCodePoint(0x0041), false, 'ASCII A is not ambiguous');
|
|
|
|
setLanguage('zh');
|
|
try {
|
|
assert.equal(isCjkAmbiguousWide(), true);
|
|
assert.equal(codePointCellWidth(0x2588, isCjkAmbiguousWide()), 2);
|
|
assert.equal(codePointCellWidth(0x0041, isCjkAmbiguousWide()), 1);
|
|
} finally {
|
|
setLanguage('en');
|
|
}
|
|
|
|
assert.equal(isCjkAmbiguousWide(), false);
|
|
assert.equal(codePointCellWidth(0x2588, isCjkAmbiguousWide()), 1);
|
|
});
|