diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 79818d5..e48dca2 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -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); } } } diff --git a/src/tui/components/StatusLinePreview.tsx b/src/tui/components/StatusLinePreview.tsx index fe71ce9..9db79da 100644 --- a/src/tui/components/StatusLinePreview.tsx +++ b/src/tui/components/StatusLinePreview.tsx @@ -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 = ({ 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 = ({ 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(() => { diff --git a/src/utils/__tests__/renderer-flex-width.test.ts b/src/utils/__tests__/renderer-flex-width.test.ts new file mode 100644 index 0000000..2ae473a --- /dev/null +++ b/src/utils/__tests__/renderer-flex-width.test.ts @@ -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 { + return { + ...DEFAULT_SETTINGS, + ...overrides, + powerline: { + ...DEFAULT_SETTINGS.powerline, + ...(overrides.powerline ?? {}) + } + }; +} + +function renderLine( + widgets: WidgetItem[], + settingsOverrides: Partial, + contextOverrides: Partial = {} +): 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); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/separator-index.test.ts b/src/utils/__tests__/separator-index.test.ts new file mode 100644 index 0000000..c01673e --- /dev/null +++ b/src/utils/__tests__/separator-index.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/terminal.test.ts b/src/utils/__tests__/terminal.test.ts new file mode 100644 index 0000000..4d74ea2 --- /dev/null +++ b/src/utils/__tests__/terminal.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index bdc5584..546f30d 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -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; diff --git a/src/utils/separator-index.ts b/src/utils/separator-index.ts new file mode 100644 index 0000000..474a747 --- /dev/null +++ b/src/utils/separator-index.ts @@ -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); +} \ No newline at end of file diff --git a/src/utils/terminal.ts b/src/utils/terminal.ts index 738ff91..d815eeb 100644 --- a/src/utils/terminal.ts +++ b/src/utils/terminal.ts @@ -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; } \ No newline at end of file