refactor: implement phase 1 terminal/render dedup with tests

This commit is contained in:
Matthew Breedlove committed 2026-03-03 12:43:44 -05:00
1 parent 6edc1b4277
commit 2d05b3b891
8 files changed
+323 -131

No files matched your search

+3 -5
View File
@@ -21,6 +21,7 @@ import {
preRenderAllWidgets,
renderStatusLine
} from './utils/renderer';
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
const durationMs = data.cost?.total_duration_ms;
@@ -119,17 +120,14 @@ async function renderMultipleLines(data: StatusJSON) {
// Strip ANSI codes to check if there's actual text
const strippedLine = getVisibleText(line).trim();
if (strippedLine.length > 0) {
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1)
globalSeparatorIndex += nonMergedWidgets.length - 1;
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
let outputLine = line.replace(/ /g, '\u00A0');
// Add reset code at the beginning to override Claude Code's dim setting
outputLine = '\x1b[0m' + outputLine;
console.log(outputLine);
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
}
}
}
+4 -11
View File
@@ -15,7 +15,7 @@ import {
type PreRenderedWidget,
type RenderResult
} from '../../utils/renderer';
import { canDetectTerminalWidth } from '../../utils/terminal';
import { advanceGlobalSeparatorIndex } from '../../utils/separator-index';
export interface StatusLinePreviewProps {
lines: WidgetItem[][];
@@ -27,7 +27,6 @@ export interface StatusLinePreviewProps {
const renderSingleLine = (
widgets: WidgetItem[],
terminalWidth: number,
widthDetectionAvailable: boolean,
settings: Settings,
lineIndex: number,
globalSeparatorIndex: number,
@@ -46,8 +45,6 @@ const renderSingleLine = (
};
export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings, onTruncationChange }) => {
const widthDetectionAvailable = React.useMemo(() => canDetectTerminalWidth(), []);
// Render each configured line
// Pass the full terminal width - the renderer will handle preview adjustments
const { renderedLines, anyTruncated } = React.useMemo(() => {
@@ -66,22 +63,18 @@ export const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, ter
const lineItems = lines[i];
if (lineItems && lineItems.length > 0) {
const preRenderedWidgets = preRenderedLines[i] ?? [];
const renderResult = renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
const renderResult = renderSingleLine(lineItems, terminalWidth, settings, i, globalSeparatorIndex, preRenderedWidgets, preCalculatedMaxWidths);
result.push(renderResult.line);
if (renderResult.wasTruncated) {
truncated = true;
}
// Count separators used in this line (widgets - 1, excluding merged widgets)
const nonMergedWidgets = lineItems.filter((_, idx) => idx === lineItems.length - 1 || !lineItems[idx]?.merge);
if (nonMergedWidgets.length > 1) {
globalSeparatorIndex += nonMergedWidgets.length - 1;
}
globalSeparatorIndex = advanceGlobalSeparatorIndex(globalSeparatorIndex, lineItems);
}
}
return { renderedLines: result, anyTruncated: truncated };
}, [lines, terminalWidth, widthDetectionAvailable, settings]);
}, [lines, terminalWidth, settings]);
// Notify parent when truncation status changes
React.useEffect(() => {
@@ -0,0 +1,113 @@
import {
describe,
expect,
it
} from 'vitest';
import type { RenderContext } from '../../types/RenderContext';
import {
DEFAULT_SETTINGS,
type Settings
} from '../../types/Settings';
import type { WidgetItem } from '../../types/Widget';
import { getVisibleWidth } from '../ansi';
import {
calculateMaxWidthsFromPreRendered,
preRenderAllWidgets,
renderStatusLine
} from '../renderer';
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
...DEFAULT_SETTINGS,
...overrides,
powerline: {
...DEFAULT_SETTINGS.powerline,
...(overrides.powerline ?? {})
}
};
}
function renderLine(
widgets: WidgetItem[],
settingsOverrides: Partial<Settings>,
contextOverrides: Partial<RenderContext> = {}
): string {
const settings = createSettings(settingsOverrides);
const context: RenderContext = {
isPreview: false,
terminalWidth: 50,
...contextOverrides
};
const preRenderedLines = preRenderAllWidgets([widgets], settings, context);
const preCalculatedMaxWidths = calculateMaxWidthsFromPreRendered(preRenderedLines, settings);
const preRenderedWidgets = preRenderedLines[0] ?? [];
return renderStatusLine(widgets, settings, context, preRenderedWidgets, preCalculatedMaxWidths);
}
describe('renderer flex width behavior', () => {
const longTextWidget: WidgetItem = {
id: 'text',
type: 'custom-text',
customText: 'abcdefghijklmnopqrstuvwxyz1234567890'
};
it('uses full-minus-40 width in normal mode', () => {
const line = renderLine([longTextWidget], { flexMode: 'full-minus-40' });
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
it('uses full width in full-until-compact when under threshold', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, { data: { context_window: { used_percentage: 20 } } });
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
expect(line.endsWith('...')).toBe(false);
});
it('uses compact width in full-until-compact when above threshold', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, { data: { context_window: { used_percentage: 80 } } });
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
it('always uses full preview width in full-until-compact preview mode', () => {
const line = renderLine([longTextWidget], {
flexMode: 'full-until-compact',
compactThreshold: 60
}, {
isPreview: true,
data: { context_window: { used_percentage: 99 } }
});
expect(getVisibleWidth(line)).toBe(longTextWidget.customText?.length);
expect(line.endsWith('...')).toBe(false);
});
it('applies the same width behavior in powerline mode', () => {
const line = renderLine([{
...longTextWidget,
backgroundColor: 'bgBlue',
color: 'white'
}], {
flexMode: 'full-minus-40',
powerline: {
...DEFAULT_SETTINGS.powerline,
enabled: true
}
});
expect(getVisibleWidth(line)).toBe(10);
expect(line.endsWith('...')).toBe(true);
});
});
@@ -0,0 +1,68 @@
import {
describe,
expect,
it
} from 'vitest';
import type { WidgetItem } from '../../types/Widget';
import {
advanceGlobalSeparatorIndex,
countSeparatorSlots
} from '../separator-index';
describe('separator index utils', () => {
it('returns zero for empty and single-item lines', () => {
expect(countSeparatorSlots([])).toBe(0);
const single: WidgetItem[] = [{ id: '1', type: 'model' }];
expect(countSeparatorSlots(single)).toBe(0);
});
it('counts one separator slot between two non-merged items', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'context-length' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('does not count separator slots for merged items', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model', merge: true },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('treats no-padding merge the same as merged', () => {
const widgets: WidgetItem[] = [
{ id: '1', type: 'model', merge: 'no-padding' },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
expect(countSeparatorSlots(widgets)).toBe(1);
});
it('advances a running global separator index', () => {
const firstLine: WidgetItem[] = [
{ id: '1', type: 'model' },
{ id: '2', type: 'context-length' },
{ id: '3', type: 'version' }
];
const secondLine: WidgetItem[] = [
{ id: '4', type: 'git-branch', merge: true },
{ id: '5', type: 'git-changes' },
{ id: '6', type: 'session-cost' }
];
const afterFirst = advanceGlobalSeparatorIndex(0, firstLine);
const afterSecond = advanceGlobalSeparatorIndex(afterFirst, secondLine);
expect(afterFirst).toBe(2);
expect(afterSecond).toBe(3);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { execSync } from 'child_process';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';
import {
canDetectTerminalWidth,
getTerminalWidth
} from '../terminal';
vi.mock('child_process', () => ({ execSync: vi.fn() }));
const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementationOnce: (impl: () => never) => void;
mockReturnValueOnce: (value: string) => void;
};
describe('terminal utils', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('returns width from tty probe when available', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('120\n');
expect(getTerminalWidth()).toBe(120);
expect(mockExecSync.mock.calls[0]?.[0]).toContain('ps -o tty=');
expect(mockExecSync.mock.calls[1]?.[0]).toContain('stty size < /dev/ttys001');
});
it('falls back to tput cols when tty probe fails', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
mockExecSync.mockReturnValueOnce('90\n');
expect(getTerminalWidth()).toBe(90);
expect(mockExecSync.mock.calls[1]?.[0]).toBe('tput cols 2>/dev/null');
});
it('returns null when width probes fail', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('not-a-number\n');
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
expect(getTerminalWidth()).toBeNull();
});
it('detects availability when tty probe succeeds', () => {
mockExecSync.mockReturnValueOnce('ttys001\n');
mockExecSync.mockReturnValueOnce('80\n');
expect(canDetectTerminalWidth()).toBe(true);
});
it('returns false for availability when all probes fail', () => {
mockExecSync.mockImplementationOnce(() => { throw new Error('tty unavailable'); });
mockExecSync.mockImplementationOnce(() => { throw new Error('tput unavailable'); });
expect(canDetectTerminalWidth()).toBe(false);
});
it('disables width detection on Windows', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
expect(getTerminalWidth()).toBeNull();
expect(canDetectTerminalWidth()).toBe(false);
expect(mockExecSync.mock.calls.length).toBe(0);
});
});
+43 -68
View File
@@ -31,6 +31,47 @@ export function formatTokens(count: number): string {
return count.toString();
}
function resolveEffectiveTerminalWidth(
detectedWidth: number | null,
settings: Settings,
context: RenderContext
): number | null {
if (!detectedWidth) {
return null;
}
const flexMode = settings.flexMode as string;
if (context.isPreview) {
if (flexMode === 'full') {
return detectedWidth - 6;
}
if (flexMode === 'full-minus-40') {
return detectedWidth - 40;
}
if (flexMode === 'full-until-compact') {
return detectedWidth - 6;
}
return null;
}
if (flexMode === 'full') {
return detectedWidth - 6;
}
if (flexMode === 'full-minus-40') {
return detectedWidth - 40;
}
if (flexMode === 'full-until-compact') {
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
return contextPercentage >= threshold
? detectedWidth - 40
: detectedWidth - 6;
}
return null;
}
function renderPowerlineStatusLine(
widgets: WidgetItem[],
settings: Settings,
@@ -82,37 +123,7 @@ function renderPowerlineStatusLine(
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode as string;
if (context.isPreview) {
// In preview mode, account for box borders and padding (6 chars total)
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
terminalWidth = detectedWidth - 6;
}
} else {
// In actual rendering mode
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
if (contextPercentage >= threshold) {
terminalWidth = detectedWidth - 40;
} else {
terminalWidth = detectedWidth - 6;
}
}
}
}
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
// Build widget elements (similar to regular mode but without separators)
const widgetElements: { content: string; bgColor?: string; fgColor?: string; widget: WidgetItem }[] = [];
@@ -632,43 +643,7 @@ export function renderStatusLine(
const detectedWidth = context.terminalWidth ?? getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode as string;
if (context.isPreview) {
// In preview mode, account for box borders and padding (6 chars total)
if (flexMode === 'full') {
terminalWidth = detectedWidth - 6; // Subtract 6 for box borders and padding in preview
} else if (flexMode === 'full-minus-40') {
terminalWidth = detectedWidth - 40; // -40 for auto-compact + 3 for preview
} else if (flexMode === 'full-until-compact') {
// For preview, always show full width minus preview padding
terminalWidth = detectedWidth - 6;
}
} else {
// In actual rendering mode
if (flexMode === 'full') {
// Use full width minus 4 for terminal padding
terminalWidth = detectedWidth - 6;
} else if (flexMode === 'full-minus-40') {
// Always subtract 41 for auto-compact message
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
// Check context percentage to decide
const threshold = settings.compactThreshold;
const contextPercentage = calculateContextPercentage(context);
if (contextPercentage >= threshold) {
// Context is high, leave space for auto-compact
terminalWidth = detectedWidth - 40;
} else {
// Context is low, use full width minus 4 for padding
terminalWidth = detectedWidth - 6;
}
}
}
}
const terminalWidth = resolveEffectiveTerminalWidth(detectedWidth, settings, context);
const elements: { content: string; type: string; widget?: WidgetItem }[] = [];
let hasFlexSeparator = false;
+10
View File
@@ -0,0 +1,10 @@
import type { WidgetItem } from '../types/Widget';
export function countSeparatorSlots(widgets: WidgetItem[]): number {
const nonMergedWidgets = widgets.filter((_, idx) => idx === widgets.length - 1 || !widgets[idx]?.merge);
return Math.max(0, nonMergedWidgets.length - 1);
}
export function advanceGlobalSeparatorIndex(currentIndex: number, widgets: WidgetItem[]): number {
return currentIndex + countSeparatorSlots(widgets);
}
+7 -47
View File
@@ -32,8 +32,7 @@ export function getPackageVersion(): string {
return '';
}
// Get terminal width
export function getTerminalWidth(): number | null {
function probeTerminalWidth(): number | null {
// Preserve historical behavior on Windows: width detection is unavailable.
// This avoids Unix fallback command behavior (e.g. 2>/dev/null) on Windows.
if (process.platform === 'win32') {
@@ -87,51 +86,12 @@ export function getTerminalWidth(): number | null {
return null;
}
// Get terminal width
export function getTerminalWidth(): number | null {
return probeTerminalWidth();
}
// Check if terminal width detection is available
export function canDetectTerminalWidth(): boolean {
// Preserve historical behavior on Windows: width detection is unavailable.
if (process.platform === 'win32') {
return false;
}
try {
// First try to get the tty of the parent process
const tty = execSync('ps -o tty= -p $(ps -o ppid= -p $$)', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
shell: '/bin/sh'
}).trim();
// Check if we got a valid tty
if (tty && tty !== '??' && tty !== '?') {
const width = execSync(
`stty size < /dev/${tty} | awk '{print $2}'`,
{
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
shell: '/bin/sh'
}
).trim();
const parsed = parseInt(width, 10);
if (!isNaN(parsed) && parsed > 0) {
return true;
}
}
} catch {
// Try fallback
}
// Fallback: try tput cols
try {
const width = execSync('tput cols 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const parsed = parseInt(width, 10);
return !isNaN(parsed) && parsed > 0;
} catch {
return false;
}
return probeTerminalWidth() !== null;
}