mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-13 22:15:27 +00:00
refactor: implement phase 1 terminal/render dedup with tests
This commit is contained in:
1 parent
6edc1b4277
commit
2d05b3b891
8 files changed
+323
-131
No files matched your search
+3
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user