Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ With caching enabled, both output and failure markers are reused until the TTL e

> ⚠️ **Important:** Commands should complete quickly to avoid delays. Long-running commands are terminated at the configured timeout, and inherited output pipes cannot keep capture waiting indefinitely after that deadline. On Linux/macOS, timeout termination targets the command's process group; on Windows, it targets the shell process. If you're not seeing output from your custom command, try increasing the timeout value (press 't' in the editor).

Rendered widget output filters terminal controls such as clipboard writes, title changes, cursor movement, and screen clearing. With **preserve colors** enabled, ANSI styling and OSC 8 hyperlinks remain supported, including their 8-bit control forms; Powerline backgrounds are restored after command output resets its styling.
Rendered widget output filters terminal controls such as clipboard writes, title changes, cursor movement, and screen clearing. With **preserve colors** enabled, ANSI styling and OSC 8 hyperlinks remain supported, including their 8-bit control forms; the widget's background, bold and dim are restored after each reset in the command's output, in both plain and Powerline mode.

Output capture is limited to 1 MiB; exceeding it displays `[Error]`. Successful output is limited to 16,384 characters before color handling and optional max-width truncation.

Expand Down
11 changes: 7 additions & 4 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async function readStdin(): Promise<string | null> {
}
}

async function renderMultipleLines(data: StatusJSON) {
async function renderMultipleLines(data: StatusJSON, rawData: Record<string, unknown>) {
const settings = await loadSettings();
const configError = getConfigLoadError();

Expand Down Expand Up @@ -173,6 +173,7 @@ async function renderMultipleLines(data: StatusJSON) {
// Create render context
const context: RenderContext = {
data,
rawData,
tokenMetrics,
speedMetrics,
windowedSpeedMetrics,
Expand Down Expand Up @@ -362,14 +363,16 @@ async function main() {
return;
}

// Parse and validate JSON in one step
const result = StatusJSONSchema.safeParse(parsed);
// Widgets read the validated copy; custom commands get the JSON
// as it arrived. Validation fails for anything but an object.
const rawData = parsed as Record<string, unknown>;
const result = StatusJSONSchema.safeParse(rawData);
if (!result.success) {
console.error('Invalid status JSON format:', result.error.message);
process.exit(1);
}

await renderMultipleLines(result.data);
await renderMultipleLines(result.data, rawData);
} catch (error) {
console.error('Error parsing JSON:', error);
process.exit(1);
Expand Down
4 changes: 4 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ export interface CompactionData {

export interface RenderContext {
data?: StatusJSON;
// Claude Code's JSON as it arrived, for Custom Command's stdin. The
// validated `data` drops nested fields the schema doesn't list and turns
// number strings into numbers.
rawData?: Record<string, unknown>;
tokenMetrics?: TokenMetrics | null;
speedMetrics?: SpeedMetrics | null;
windowedSpeedMetrics?: Record<string, SpeedMetrics> | null;
Expand Down
45 changes: 38 additions & 7 deletions src/utils/__tests__/ansi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import {
getVisibleText,
getVisibleWidth,
restoreBackgroundAfterResets,
restoreStyleAfterResets,
stripOscCodes,
stripSgrCodes,
truncateStyledText
Expand Down Expand Up @@ -126,23 +126,23 @@ describe('stripSgrCodes', () => {
});
});

describe('restoreBackgroundAfterResets', () => {
describe('restoreStyleAfterResets', () => {
const BG = '\x1b[44m';

it('re-applies the background after every sequence that clears it', () => {
expect(restoreBackgroundAfterResets('\x1b[31ma\x1b[0mb\x1b[mc\x1b[00md\x1b[;1me\x1b[39;49mf', BG))
expect(restoreStyleAfterResets('\x1b[31ma\x1b[0mb\x1b[mc\x1b[00md\x1b[;1me\x1b[39;49mf', { background: BG }))
.toBe(`\x1b[31ma\x1b[0m${BG}b\x1b[m${BG}c\x1b[00m${BG}d\x1b[;1m${BG}e\x1b[39;49m${BG}f`);
});

it('re-applies the background after an 8-bit reset', () => {
expect(restoreBackgroundAfterResets('\x9b31ma\x9b0mb\x9b49mc', BG))
expect(restoreStyleAfterResets('\x9b31ma\x9b0mb\x9b49mc', { background: BG }))
.toBe(`\x9b31ma\x9b0m${BG}b\x9b49m${BG}c`);
});

it('leaves other styling, and the arguments of extended colors, alone', () => {
const text = '\x1b[1;31ma\x1b[38;5;0mb\x1b[48;2;0;49;0mc\x1b[58;5;49md\x1b[39me';

expect(restoreBackgroundAfterResets(text, BG)).toBe(text);
expect(restoreStyleAfterResets(text, { background: BG })).toBe(text);
});

it.each([
Expand All @@ -152,14 +152,45 @@ describe('restoreBackgroundAfterResets', () => {
])('keeps the explicit background after a reset in SGR %s', (params) => {
const text = `\x1b[${params}mtext`;

expect(restoreBackgroundAfterResets(text, BG)).toBe(text);
expect(restoreStyleAfterResets(text, { background: BG })).toBe(text);
});

it.each([
'41;0', '107;49', '48;5;49;0', '48;2;0;49;0;49',
'0;41;49', '0;41;', '0;38;5;0', '49;58;2;0;49;0'
])('restores the background when the final background operation clears it in SGR %s', (params) => {
expect(restoreBackgroundAfterResets(`\x1b[${params}mtext`, BG))
expect(restoreStyleAfterResets(`\x1b[${params}mtext`, { background: BG }))
.toBe(`\x1b[${params}m${BG}text`);
});

it('re-applies bold and dim after every sequence that clears them', () => {
expect(restoreStyleAfterResets('\x1b[32ma\x1b[0mb\x1b[1mc\x1b[22md\x1b[me', { bold: true, dim: true }))
.toBe('\x1b[32ma\x1b[0m\x1b[1m\x1b[2mb\x1b[1mc\x1b[22m\x1b[1m\x1b[2md\x1b[m\x1b[1m\x1b[2me');
});

it('re-applies only what each sequence clears', () => {
expect(restoreStyleAfterResets('a\x1b[0mb\x1b[49mc\x1b[22md', { background: BG, bold: true }))
.toBe(`a\x1b[0m\x1b[1m${BG}b\x1b[49m${BG}c\x1b[22m\x1b[1md`);
});

it('re-applies only dim when the sequence sets bold again itself', () => {
expect(restoreStyleAfterResets('a\x1b[22;1mb', { bold: true, dim: true }))
.toBe('a\x1b[22;1m\x1b[2mb');
});

it.each([
'22;1', '0;1', '1', '2', '49',
'38;5;22', '48;2;0;22;0', '58;5;0'
])('re-applies bold after a reset but not after SGR %s, which leaves it on', (params) => {
expect(restoreStyleAfterResets(`a\x1b[0mb\x1b[${params}mc`, { bold: true }))
.toBe(`a\x1b[0m\x1b[1mb\x1b[${params}mc`);
});

it.each([
'0', '', '22', '1;22', '2;0', '0;2', '22;2',
'38;5;1;22', '0;38;5;1'
])('restores bold when the last operation on it in SGR %s clears it', (params) => {
expect(restoreStyleAfterResets(`\x1b[${params}mtext`, { bold: true }))
.toBe(`\x1b[${params}m\x1b[1mtext`);
});
});
24 changes: 24 additions & 0 deletions src/utils/__tests__/renderer-ansi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,30 @@ describe('renderer ANSI/OSC handling', () => {
});
});

describe('renderer widgets that preserve their own colors', () => {
const BG = '\x1b[48;2;17;34;51m';
const BOLD = '\x1b[1m';
const OUTPUT = '\x1b[32m+5\x1b[0m \x1b[31m-3\x1b[0m';

function renderCommand(widget: Partial<WidgetItem>, settings: Partial<Settings> = {}): string {
const item: WidgetItem = { id: 'cmd', type: 'custom-command', preserveColors: true, ...widget };
const context: RenderContext = { isPreview: false, terminalWidth: 200 };
const preRenderedWidgets = [{ content: OUTPUT, plainLength: getVisibleWidth(OUTPUT), widget: item }];

return renderStatusLine([item], createSettings({ colorLevel: 3, ...settings }), context, preRenderedWidgets, []);
}

it('keeps the widget background and bold after the command\'s own resets', () => {
expect(renderCommand({ backgroundColor: 'hex:112233', bold: true }))
.toBe(`${BOLD}${BG}\x1b[32m+5\x1b[0m${BOLD}${BG} \x1b[31m-3\x1b[0m${BOLD}${BG}\x1b[49m\x1b[22m`);
});

it('keeps a global background override after the command\'s own resets', () => {
expect(renderCommand({}, { overrideBackgroundColor: 'hex:112233' }))
.toBe(`${BG}\x1b[32m+5\x1b[0m${BG} \x1b[31m-3\x1b[0m${BG}\x1b[49m`);
});
});

describe('renderer minimalist mode', () => {
it('renders widget as raw value when minimalist mode is enabled', () => {
const widgets: WidgetItem[] = [{ id: 'model1', type: 'model' }];
Expand Down
16 changes: 16 additions & 0 deletions src/utils/__tests__/renderer-powerline-separators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,22 @@ describe('powerline widgets that preserve their own colors', () => {
expect(line).toBe(`${A_BG_CODE}\x1b[0;32m${A_BG_CODE}ok\x1b[49m${A_BG_CODE} then\x1b[m${A_BG_CODE}\x1b[0m`);
});

it('re-applies bold and dim after the command\'s own resets too', () => {
const widgets: WidgetItem[] = [{ id: 'cmd', type: 'custom-command', preserveColors: true, backgroundColor: A_BG, bold: true, dim: true }];

const line = render(widgets, { 0: '\x1b[32m+5\x1b[0m \x1b[31m-3\x1b[22m!' });

expect(line).toBe(
`${BOLD}\x1b[2m${A_BG_CODE}\x1b[32m+5\x1b[0m${BOLD}\x1b[2m${A_BG_CODE} \x1b[31m-3\x1b[22m${BOLD}\x1b[2m!\x1b[0m`
);
});

it('re-applies bold after the command\'s own reset in a segment without a background', () => {
const widgets: WidgetItem[] = [{ id: 'cmd', type: 'custom-command', preserveColors: true, bold: true }];

expect(render(widgets, { 0: '\x1b[32m+5\x1b[0m -3' })).toBe(`${BOLD}\x1b[32m+5\x1b[0m${BOLD} -3\x1b[0m`);
});

it('does not mistake a 0 or 49 inside an extended color for a reset', () => {
const widgets: WidgetItem[] = [{ id: 'cmd', type: 'custom-command', preserveColors: true, backgroundColor: A_BG }];
const content = '\x1b[38;5;0mk\x1b[38;2;0;49;0mg\x1b[48;5;49mb';
Expand Down
82 changes: 67 additions & 15 deletions src/utils/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,31 +396,83 @@ export function stripSgrCodes(text: string): string {
return text.replace(SGR_REGEX, '');
}

// Track background operations in order: a later explicit color overrides a
// reset (0, an empty parameter, or 49). Skip extended color arguments so their
// values aren't mistaken for resets or background operations.
function sgrClearsBackground(sequence: string): boolean {
// What an SGR parameter does to an attribute: clears it, sets it, or leaves
// it alone (undefined)
type SgrEffect = 'clear' | 'set' | undefined;

// The background is cleared by 0 (also an empty parameter) and 49
function getBackgroundEffect(param: number | undefined): SgrEffect {
if (param === 0 || param === 49) {
return 'clear';
}
if (param !== undefined && ((param >= 40 && param <= 47) || (param >= 100 && param <= 107) || param === 48)) {
return 'set';
}
return undefined;
}

// Bold (set by 1) and dim (set by 2) are both cleared by 0 and 22
function getIntensityEffect(param: number | undefined, setBy: 1 | 2): SgrEffect {
if (param === 0 || param === 22) {
return 'clear';
}
return param === setBy ? 'set' : undefined;
}

interface SgrClears {
background: boolean;
bold: boolean;
dim: boolean;
}

// Track each attribute's operations in order: a later explicit setting
// overrides a reset. Skip extended color arguments so their values aren't
// mistaken for resets or background operations.
function getSgrClears(sequence: string): SgrClears {
const introducerLength = sequence.startsWith(C1_CSI) ? 1 : 2;
const params = sequence.slice(introducerLength, -1).split(';').map(param => Number.parseInt(param || '0', 10));
let clearsBackground = false;
const clears: SgrClears = { background: false, bold: false, dim: false };
const track = (attribute: keyof SgrClears, effect: SgrEffect) => {
if (effect) {
clears[attribute] = effect === 'clear';
}
};
for (let i = 0; i < params.length; i++) {
const param = params[i];
if (param === 0 || param === 49) {
clearsBackground = true;
} else if (param !== undefined && ((param >= 40 && param <= 47) || (param >= 100 && param <= 107) || param === 48)) {
clearsBackground = false;
}
track('background', getBackgroundEffect(param));
track('bold', getIntensityEffect(param, 1));
track('dim', getIntensityEffect(param, 2));
if (param === 38 || param === 48 || param === 58) {
i += params[i + 1] === 5 ? 2 : 4;
}
}
return clearsBackground;
return clears;
}

// The styling restoreStyleAfterResets re-applies, with the background as the
// SGR code that sets it
export interface RestoredStyle {
background?: string;
bold?: boolean;
dim?: boolean;
}

// Re-apply a background after each SGR sequence in the text that clears it,
// so text that resets its own styling stays on the background it's drawn on.
export function restoreBackgroundAfterResets(text: string, backgroundCode: string): string {
return text.replace(SGR_REGEX, sequence => (sgrClearsBackground(sequence) ? sequence + backgroundCode : sequence));
// Re-apply a widget's own styling after each SGR sequence in the text that
// clears it, so text that resets its own styling keeps the background, bold
// and dim it's drawn with.
export function restoreStyleAfterResets(text: string, style: RestoredStyle): string {
const background = style.background ?? '';
if (!background && !style.bold && !style.dim) {
return text;
}

return text.replace(SGR_REGEX, (sequence) => {
const clears = getSgrClears(sequence);
return sequence
+ (style.bold && clears.bold ? '\x1b[1m' : '')
+ (style.dim && clears.dim ? '\x1b[2m' : '')
+ (clears.background ? background : '');
});
}

export function stripOscCodes(text: string): string {
Expand Down
33 changes: 24 additions & 9 deletions src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
applyLineGradientSegment,
getVisibleText,
getVisibleWidth,
restoreBackgroundAfterResets,
restoreStyleAfterResets,
stripSgrCodes,
truncateStyledText
} from './ansi';
Expand Down Expand Up @@ -525,9 +525,14 @@ function renderPowerlineStatusLine(
? overrideForegroundGradientStops
: null;
// Resets in preserved content would clear the segment background up
// to the separator, so it is re-applied after each one.
const segmentContent = isPreserveColors && widget.bgColor
? restoreBackgroundAfterResets(widget.content, getColorAnsiCode(widget.bgColor, colorLevel, true))
// to the separator, and the widget's bold and dim, so they are
// re-applied after each one.
const segmentContent = isPreserveColors
? restoreStyleAfterResets(widget.content, {
background: getColorAnsiCode(widget.bgColor, colorLevel, true),
bold: shouldBold,
dim: shouldDim
})
: widget.content;
const styledContent = widget.widget.dim === 'parens'
? applyParensDim(segmentContent, shouldBold)
Expand Down Expand Up @@ -1064,6 +1069,13 @@ export function renderStatusLine(
preCalculatedMaxWidths
);

// Override background color takes precedence over EVERYTHING, including passed background color
const resolveBackgroundColor = (backgroundColor?: string): string | undefined => (
settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none'
? settings.overrideBackgroundColor
: backgroundColor
);

// Helper to apply colors with optional background, bold, and dim
const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean, dim?: boolean | 'parens'): string => {
// Override foreground color takes precedence over EVERYTHING, including passed foreground
Expand All @@ -1081,11 +1093,7 @@ export function renderStatusLine(
}
}

// Override background color takes precedence over EVERYTHING, including passed background color
let bgColor = backgroundColor;
if (settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') {
bgColor = settings.overrideBackgroundColor;
}
const bgColor = resolveBackgroundColor(backgroundColor);

const shouldBold = (settings.globalBold) || bold;
return applyColors(text, fgColor, bgColor, shouldBold, colorLevel, dim);
Expand Down Expand Up @@ -1229,6 +1237,13 @@ export function renderStatusLine(
if (hasForegroundOverride(settings)) {
finalOutput = stripSgrCodes(finalOutput);
}
// Resets in the command's output would clear the widget's
// background, bold and dim, so they are re-applied after each one
finalOutput = restoreStyleAfterResets(finalOutput, {
background: getColorAnsiCode(resolveBackgroundColor(widget.backgroundColor), colorLevel, true),
bold: settings.globalBold || widget.bold,
dim: widget.dim === true
});
// Preserve intrinsic foregrounds only when no global
// foreground override is active. Bold, dim, backgrounds,
// and global overrides still wrap the widget normally.
Expand Down
11 changes: 9 additions & 2 deletions src/widgets/CustomCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ export class CustomCommandWidget implements Widget {
if (context.isPreview) {
return item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]';
} else if (item.commandPath && context.data) {
const payload = context.rawData ?? context.data;
const jsonInput = JSON.stringify(
typeof context.terminalWidth === 'number'
? { ...context.data, terminal_width: context.terminalWidth }
: context.data
? { ...payload, terminal_width: context.terminalWidth }
: payload
);
const result = runCustomCommand({
command: item.commandPath,
Expand All @@ -90,6 +91,12 @@ export class CustomCommandWidget implements Widget {
output = getVisibleText(output);
}

// Output with no visible text, such as color codes around an empty
// value, renders empty whether or not its colors are preserved
if (!getVisibleText(output)) {
return null;
}

// Truncate by display columns, skipping escape sequences and never
// splitting a grapheme. A cut can drop the command's own trailing
// reset, so close any SGR styling it left open; otherwise the
Expand Down
Loading
Loading