Fix build issue with patching Ink, bump version to 1.0.15

This reverts commit 256b562cca.
This commit is contained in:
Matthew Breedlove
2025-08-12 03:08:18 -04:00
parent 256b562cca
commit e0012bc0eb
9 changed files with 1130 additions and 1778 deletions
+23 -752
View File
@@ -1,73 +1,13 @@
#!/usr/bin/env node
import chalk from 'chalk';
import { execSync } from 'child_process';
import { runTUI } from './tui.tsx';
import { loadSettings, type StatusItem } from './config';
import * as fs from 'fs';
import { promisify } from 'util';
// Ensure fs.promises compatibility for older Node versions
const readFile = fs.promises?.readFile || promisify(fs.readFile);
// Force chalk to use colors even when piped
chalk.level = 3;
// Helper function to apply foreground, background colors and bold
function applyColors(text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string {
let result = text;
// Standard color application
// Ignore 'dim' color - it causes issues with terminal rendering
if (foregroundColor && foregroundColor !== 'dim') {
const fgFunc = (chalk as any)[foregroundColor];
if (fgFunc) {
result = fgFunc(result);
}
}
if (backgroundColor && backgroundColor !== 'none') {
const bgFunc = (chalk as any)[backgroundColor];
if (bgFunc) {
result = bgFunc(result);
}
}
if (bold) {
result = chalk.bold(result);
}
return result;
}
interface StatusJSON {
session_id: string;
transcript_path: string;
cwd: string;
model: {
id: string;
display_name: string;
};
workspace: {
current_dir: string;
project_dir: string;
};
version?: string;
}
interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
interface TranscriptLine {
message?: {
usage?: TokenUsage;
};
isSidechain?: boolean;
timestamp?: string;
}
import { loadSettings } from './utils/config';
import {
type StatusJSON,
renderStatusLine,
getTokenMetrics,
getSessionDuration,
type RenderContext
} from './utils/renderer';
async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
@@ -98,687 +38,11 @@ async function readStdin(): Promise<string | null> {
}
}
// Helper to get default color for an item type
function getItemDefaultColor(type: string): string {
switch (type) {
case 'model': return 'cyan';
case 'git-branch': return 'magenta';
case 'git-changes': return 'yellow';
case 'session-clock': return 'yellow';
case 'version': return 'green';
case 'tokens-input': return 'blue';
case 'tokens-output': return 'white';
case 'tokens-cached': return 'cyan';
case 'tokens-total': return 'cyan';
case 'context-length': return 'gray';
case 'context-percentage': return 'blue';
case 'context-percentage-usable': return 'green';
case 'terminal-width': return 'gray';
case 'custom-text': return 'white';
case 'custom-command': return 'white';
case 'separator': return 'gray';
default: return 'white';
}
}
function getTerminalWidth(): number | null {
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 (not ?? which means no tty)
if (tty && tty !== '??' && tty !== '?') {
// Now get the terminal size
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 parsed;
}
}
} catch {
// Command failed, width detection not available
}
// Fallback: try tput cols which might work in some environments
try {
const width = execSync('tput cols 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const parsed = parseInt(width, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed;
}
} catch {
// tput also failed
}
return null;
}
function getGitBranch(): string | null {
try {
const branch = execSync('git branch --show-current 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
return branch || null;
} catch {
return null;
}
}
function getGitChanges(): { insertions: number; deletions: number } | null {
try {
let totalInsertions = 0;
let totalDeletions = 0;
// Get unstaged changes
const unstagedStat = execSync('git diff --shortstat 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Get staged changes
const stagedStat = execSync('git diff --cached --shortstat 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Parse unstaged changes
if (unstagedStat) {
const insertMatch = unstagedStat.match(/(\d+) insertion/);
const deleteMatch = unstagedStat.match(/(\d+) deletion/);
totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0;
totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0;
}
// Parse staged changes
if (stagedStat) {
const insertMatch = stagedStat.match(/(\d+) insertion/);
const deleteMatch = stagedStat.match(/(\d+) deletion/);
totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0;
totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0;
}
// Always return the changes, even if they're zero
return { insertions: totalInsertions, deletions: totalDeletions };
} catch {
return null;
}
}
async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) {
return null;
}
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
// Find first valid timestamp
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const data = JSON.parse(lines[i]!);
if (data.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
if (totalMinutes < 1) {
return '<1m';
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
}
async function getTokenMetrics(transcriptPath: string): Promise<{
inputTokens: number;
outputTokens: number;
cachedTokens: number;
totalTokens: number;
contextLength: number;
}> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
for (const line of lines) {
try {
const data: TranscriptLine = JSON.parse(line);
if (data.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens || 0;
cachedTokens += data.message.usage.cache_creation_input_tokens || 0;
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
if (data.isSidechain !== true && data.timestamp) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
} catch {
// Skip invalid JSON lines
}
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.cache_creation_input_tokens || 0);
}
const totalTokens = inputTokens + outputTokens + cachedTokens;
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
function renderSingleLine(items: StatusItem[], settings: any, data: StatusJSON, tokenMetrics: any, sessionDuration: string | null): string {
// Helper to apply colors with optional background and bold override
const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => {
// Override foreground color takes precedence over EVERYTHING, including passed foreground color
let fgColor = foregroundColor;
if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none') {
fgColor = settings.overrideForegroundColor;
}
// Override background color takes precedence over EVERYTHING, including passed background color
let bgColor = backgroundColor;
if (settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') {
bgColor = settings.overrideBackgroundColor;
}
const shouldBold = settings.globalBold || bold;
return applyColors(text, fgColor, bgColor, shouldBold);
};
const detectedWidth = getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode || 'full-minus-40';
if (flexMode === 'full') {
// Use full width minus 4 for terminal padding
terminalWidth = detectedWidth - 4;
} else if (flexMode === 'full-minus-40') {
// Always subtract 40 for auto-compact message
terminalWidth = detectedWidth - 40;
} else if (flexMode === 'full-until-compact') {
// Check context percentage to decide
const threshold = settings.compactThreshold || 60;
const contextPercentage = tokenMetrics ?
Math.min(100, (tokenMetrics.contextLength / 200000) * 100) : 0;
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 - 4;
}
}
}
const elements: { content: string, type: string, item?: StatusItem }[] = [];
let hasFlexSeparator = false;
// Helper function to format token counts
const formatTokens = (count: number): string => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
};
// Build elements based on configured items
for (const item of items) {
switch (item.type) {
case 'model':
if (data.model) {
const text = item.rawValue ? data.model.display_name : `Model: ${data.model.display_name}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'model', item });
}
break;
case 'git-branch':
const branch = getGitBranch();
if (branch) {
const text = item.rawValue ? branch : `${branch}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'magenta', item.backgroundColor, item.bold), type: 'git-branch', item });
}
break;
case 'git-changes':
const changes = getGitChanges();
if (changes !== null) {
// Compact format: (+42,-10)
const changeStr = `(+${changes.insertions},-${changes.deletions})`;
elements.push({ content: applyColorsWithOverride(changeStr, item.color || 'yellow', item.backgroundColor, item.bold), type: 'git-changes', item });
}
break;
case 'tokens-input':
if (tokenMetrics) {
const text = item.rawValue ? formatTokens(tokenMetrics.inputTokens) : `In: ${formatTokens(tokenMetrics.inputTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'blue', item.backgroundColor, item.bold), type: 'tokens-input', item });
}
break;
case 'tokens-output':
if (tokenMetrics) {
const text = item.rawValue ? formatTokens(tokenMetrics.outputTokens) : `Out: ${formatTokens(tokenMetrics.outputTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'white', item.backgroundColor, item.bold), type: 'tokens-output', item });
}
break;
case 'tokens-cached':
if (tokenMetrics) {
const text = item.rawValue ? formatTokens(tokenMetrics.cachedTokens) : `Cached: ${formatTokens(tokenMetrics.cachedTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-cached', item });
}
break;
case 'tokens-total':
if (tokenMetrics) {
const text = item.rawValue ? formatTokens(tokenMetrics.totalTokens) : `Total: ${formatTokens(tokenMetrics.totalTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-total', item });
}
break;
case 'context-length':
if (tokenMetrics) {
const text = item.rawValue ? formatTokens(tokenMetrics.contextLength) : `Ctx: ${formatTokens(tokenMetrics.contextLength)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'gray', item.backgroundColor, item.bold), type: 'context-length', item });
}
break;
case 'context-percentage':
if (tokenMetrics) {
const percentage = Math.min(100, (tokenMetrics.contextLength / 200000) * 100);
const text = item.rawValue ? `${percentage.toFixed(1)}%` : `Ctx: ${percentage.toFixed(1)}%`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'blue', item.backgroundColor, item.bold), type: 'context-percentage', item });
}
break;
case 'context-percentage-usable':
if (tokenMetrics) {
// Calculate percentage out of 160,000 (80% of full context for auto-compact)
const percentage = Math.min(100, (tokenMetrics.contextLength / 160000) * 100);
const text = item.rawValue ? `${percentage.toFixed(1)}%` : `Ctx(u): ${percentage.toFixed(1)}%`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'green', item.backgroundColor, item.bold), type: 'context-percentage-usable', item });
}
break;
case 'terminal-width':
const width = terminalWidth || getTerminalWidth();
if (width) {
const text = item.rawValue ? `${width}` : `Term: ${width}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'gray', item.backgroundColor, item.bold), type: 'terminal-width', item });
}
break;
case 'session-clock':
if (sessionDuration) {
const text = item.rawValue ? sessionDuration : `Session: ${sessionDuration}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'yellow', item.backgroundColor, item.bold), type: 'session-clock', item });
}
break;
case 'version':
const versionString = data.version || 'Unknown';
const versionText = item.rawValue ? versionString : `Version: ${versionString}`;
elements.push({ content: applyColorsWithOverride(versionText, item.color || 'green', item.backgroundColor, item.bold), type: 'version', item });
break;
case 'separator':
// Only add separator if there are already elements and the last one isn't a separator
const lastElement = elements[elements.length - 1];
if (elements.length > 0 && lastElement && lastElement.type !== 'separator') {
const sepChar = item.character || '|';
// Handle special separator cases
let sepText;
if (sepChar === ',') {
sepText = `${sepChar} `;
} else if (sepChar === ' ') {
sepText = ' ';
} else {
sepText = ` ${sepChar} `;
}
// Use item color if specified, otherwise default to gray
const sepContent = applyColorsWithOverride(sepText, item.color || 'gray', item.backgroundColor, item.bold);
elements.push({ content: sepContent, type: 'separator', item });
}
break;
case 'flex-separator':
elements.push({ content: 'FLEX', type: 'flex-separator', item });
hasFlexSeparator = true;
break;
case 'custom-text':
const customText = item.customText || '';
elements.push({ content: applyColorsWithOverride(customText, item.color || 'white', item.backgroundColor, item.bold), type: 'custom-text', item });
break;
case 'custom-command':
if (item.commandPath) {
try {
// Execute the command with JSON input via stdin
const timeout = item.timeout || 1000; // Default to 1000ms if not specified
const output = execSync(item.commandPath, {
encoding: 'utf8',
input: JSON.stringify(data),
stdio: ['pipe', 'pipe', 'ignore'],
timeout: timeout
}).trim();
if (output) {
let finalOutput = output;
// Handle max width truncation
if (item.maxWidth && item.maxWidth > 0) {
// Remove ANSI codes to measure actual length
const plainLength = output.replace(/\x1b\[[0-9;]*m/g, '').length;
if (plainLength > item.maxWidth) {
// Truncate while preserving ANSI codes
let truncated = '';
let currentLength = 0;
let inAnsiCode = false;
let ansiBuffer = '';
for (let i = 0; i < output.length; i++) {
const char = output[i];
if (char === '\x1b') {
inAnsiCode = true;
ansiBuffer = char;
} else if (inAnsiCode) {
ansiBuffer += char;
if (char === 'm') {
truncated += ansiBuffer;
inAnsiCode = false;
ansiBuffer = '';
}
} else {
if (currentLength < item.maxWidth) {
truncated += char;
currentLength++;
} else {
break;
}
}
}
finalOutput = truncated;
}
}
// Apply color if not preserving original colors
if (!item.preserveColors) {
// Strip existing ANSI codes and apply new color
const stripped = finalOutput.replace(/\x1b\[[0-9;]*m/g, '');
elements.push({ content: applyColorsWithOverride(stripped, item.color || 'white', item.backgroundColor, item.bold), type: 'custom-command', item });
} else {
// Preserve original colors from command output - ignore any color property
elements.push({ content: finalOutput, type: 'custom-command', item });
}
}
} catch {
// Command failed or timed out - silently skip
}
}
break;
}
}
if (elements.length === 0) return '';
// Remove trailing separators
while (elements.length > 0 && elements[elements.length - 1]?.type === 'separator') {
elements.pop();
}
// Apply default padding and separators
const finalElements: string[] = [];
const padding = settings.defaultPadding || '';
const defaultSep = settings.defaultSeparator || '';
elements.forEach((elem, index) => {
// Add default separator between any two items (but not before first item)
if (defaultSep && index > 0) {
// Check if we should inherit colors from the previous element
if (settings.inheritSeparatorColors && index > 0) {
const prevElem = elements[index - 1];
if (prevElem && prevElem.item) {
// Apply the previous element's colors to the separator (already handles override)
// Use the item's color if set, otherwise get the default color for that item type
const itemColor = prevElem.item.color || getItemDefaultColor(prevElem.item.type);
const coloredSep = applyColorsWithOverride(defaultSep, itemColor, prevElem.item.backgroundColor, prevElem.item.bold);
finalElements.push(coloredSep);
} else {
finalElements.push(defaultSep);
}
} else if ((settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') ||
(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none')) {
// Apply override colors even when not inheriting colors
const coloredSep = applyColorsWithOverride(defaultSep, undefined, undefined);
finalElements.push(coloredSep);
} else {
finalElements.push(defaultSep);
}
}
// Add element with padding (separators don't get padding)
if (elem.type === 'separator' || elem.type === 'flex-separator') {
finalElements.push(elem.content);
} else {
// Apply padding with colors (using overrides if set)
const hasColorOverride = (settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') ||
(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none');
if (padding && (elem.item?.backgroundColor || hasColorOverride)) {
// Apply colors to padding - applyColorsWithOverride will handle the overrides
const paddedContent = applyColorsWithOverride(padding, undefined, elem.item?.backgroundColor) +
elem.content +
applyColorsWithOverride(padding, undefined, elem.item?.backgroundColor);
finalElements.push(paddedContent);
} else {
// No colors or no padding
finalElements.push(padding + elem.content + padding);
}
}
});
// Build the final status line
let statusLine = '';
if (hasFlexSeparator && terminalWidth) {
// Split elements by flex separators
const parts: string[][] = [[]];
let currentPart = 0;
for (let i = 0; i < finalElements.length; i++) {
const elem = finalElements[i];
if (elem === 'FLEX' || (elements[i] && elements[i]?.type === 'flex-separator')) {
currentPart++;
parts[currentPart] = [];
} else {
parts[currentPart]?.push(elem!);
}
}
// Calculate total length of all non-flex content
const partLengths = parts.map(part => {
const joined = part.join('');
return joined.replace(/\x1b\[[0-9;]*m/g, '').length;
});
const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0);
// Calculate space to distribute among flex separators
const flexCount = parts.length - 1; // Number of flex separators
const totalSpace = Math.max(0, terminalWidth - totalContentLength);
const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0;
const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0;
// Build the status line with distributed spacing
statusLine = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part) {
statusLine += part.join('');
}
if (i < parts.length - 1) {
// Add flex spacing
const spaces = spacePerFlex + (i < extraSpace ? 1 : 0);
statusLine += ' '.repeat(spaces);
}
}
} else {
// No flex separator OR no width detected
if (hasFlexSeparator && !terminalWidth) {
// Treat flex separators as normal separators when width detection fails
statusLine = finalElements.map(e => e === 'FLEX' ? chalk.gray(' | ') : e).join('');
} else {
// Just join all elements normally
statusLine = finalElements.join('');
}
}
// Truncate if the line exceeds the terminal width
if (detectedWidth && detectedWidth > 0) {
// Remove ANSI escape codes to get actual length
const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length;
if (plainLength > detectedWidth) {
// Need to truncate - preserve ANSI codes while truncating
let truncated = '';
let currentLength = 0;
let inAnsiCode = false;
let ansiBuffer = '';
const targetLength = detectedWidth - 7; // Truncate to width-7 for proper fit
for (let i = 0; i < statusLine.length; i++) {
const char = statusLine[i];
if (char === '\x1b') {
inAnsiCode = true;
ansiBuffer = char;
} else if (inAnsiCode) {
ansiBuffer += char;
if (char === 'm') {
truncated += ansiBuffer;
inAnsiCode = false;
ansiBuffer = '';
}
} else {
if (currentLength < targetLength) {
truncated += char;
currentLength++;
} else {
break;
}
}
}
statusLine = truncated + '...';
}
}
return statusLine;
}
async function renderStatusLine(data: StatusJSON) {
async function renderMultipleLines(data: StatusJSON) {
const settings = await loadSettings();
// Get all lines to render (support both old items format and new lines format)
let lines: StatusItem[][] = [];
if (settings.lines) {
lines = settings.lines;
} else if (settings.items) {
// Legacy support for single line
lines = [settings.items];
} else {
lines = [[]];
}
let lines = settings.lines || (settings.items ? [settings.items] : [[]]);
// Get token metrics if needed (check all lines)
const hasTokenItems = lines.some(line =>
@@ -792,21 +56,28 @@ async function renderStatusLine(data: StatusJSON) {
line.some(item => item.type === 'session-clock')
);
let tokenMetrics: any = null;
let tokenMetrics = null;
if (hasTokenItems && data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
}
let sessionDuration: string | null = null;
let sessionDuration = null;
if (hasSessionClock && data.transcript_path) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
// Create render context
const context: RenderContext = {
data,
tokenMetrics,
sessionDuration,
isPreview: false
};
// Render each line
for (let i = 0; i < lines.length; i++) {
const lineItems = lines[i];
for (const lineItems of lines) {
if (lineItems && lineItems.length > 0) {
const line = renderSingleLine(lineItems, settings, data, tokenMetrics, sessionDuration);
const line = renderStatusLine(lineItems, settings, context);
// Replace all spaces with non-breaking spaces to prevent VSCode trimming
const outputLine = line.replace(/ /g, '\u00A0');
console.log(outputLine);
@@ -822,7 +93,7 @@ async function main() {
if (input && input.trim() !== '') {
try {
const data: StatusJSON = JSON.parse(input);
await renderStatusLine(data);
await renderMultipleLines(data);
} catch (error) {
console.error('Error parsing JSON:', error);
process.exit(1);
+116 -398
View File
@@ -3,10 +3,16 @@ import { render, Box, Text, useInput, useApp } from 'ink';
import SelectInput from 'ink-select-input';
import chalk from 'chalk';
import { execSync } from 'child_process';
import { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType, type FlexMode } from './config';
import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './claude-settings';
import { loadSettings, saveSettings, type Settings, type StatusItem, type StatusItemType, type FlexMode } from './utils/config';
import { isInstalled, installStatusLine, uninstallStatusLine, getExistingStatusLine } from './utils/claude-settings';
import * as fs from 'fs';
import * as path from 'path';
import {
renderStatusLine as renderLine,
getItemDefaultColor,
applyColors,
type RenderContext
} from './utils/renderer';
// Get package version
function getPackageVersion(): string {
@@ -20,27 +26,8 @@ function getPackageVersion(): string {
}
// Get default color for each item type (matching ccstatusline.ts defaults)
function getDefaultColor(type: string): string {
switch (type) {
case 'model': return 'cyan';
case 'git-branch': return 'magenta';
case 'git-changes': return 'yellow';
case 'session-clock': return 'yellow';
case 'version': return 'green';
case 'tokens-input': return 'blue';
case 'tokens-output': return 'white';
case 'tokens-cached': return 'cyan';
case 'tokens-total': return 'cyan';
case 'context-length': return 'gray';
case 'context-percentage': return 'blue';
case 'context-percentage-usable': return 'green';
case 'terminal-width': return 'gray';
case 'custom-text': return 'white';
case 'custom-command': return 'white';
case 'separator': return 'gray';
default: return 'white';
}
}
// Re-export from shared renderer module
const getDefaultColor = getItemDefaultColor;
// Check if terminal width detection is available
function canDetectTerminalWidth(): boolean {
@@ -92,337 +79,31 @@ interface StatusLinePreviewProps {
settings?: Settings;
}
// Helper function to apply foreground, background colors and bold
const applyColors = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => {
let result = text;
// Ignore 'dim' color - it causes issues with terminal rendering
if (foregroundColor && foregroundColor !== 'dim') {
const fgFunc = (chalk as any)[foregroundColor];
if (fgFunc) {
result = fgFunc(result);
}
}
if (backgroundColor && backgroundColor !== 'none') {
const bgFunc = (chalk as any)[backgroundColor];
if (bgFunc) {
result = bgFunc(result);
}
}
if (bold) {
result = chalk.bold(result);
}
return result;
};
const renderSingleLine = (items: StatusItem[], terminalWidth: number, widthDetectionAvailable: boolean, settings?: Settings): string => {
// Helper to apply colors with optional background and foreground override
const applyItemColors = (text: string, item: StatusItem, defaultColor?: string): string => {
// Override foreground color takes precedence over EVERYTHING
let fgColor = item.color || defaultColor;
if (settings?.overrideForegroundColor && settings.overrideForegroundColor !== 'none') {
fgColor = settings.overrideForegroundColor;
}
// Override background color takes precedence over EVERYTHING
let bgColor = item.backgroundColor;
if (settings?.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') {
bgColor = settings.overrideBackgroundColor;
}
const shouldBold = settings?.globalBold || item.bold;
return applyColors(text, fgColor, bgColor, shouldBold);
// Create render context for preview
const context: RenderContext = {
terminalWidth,
isPreview: true
};
// Calculate effective width based on flex mode settings
let effectiveWidth: number | null = null;
if (widthDetectionAvailable && terminalWidth) {
const flexMode = settings?.flexMode || 'full-minus-40';
if (flexMode === 'full') {
effectiveWidth = terminalWidth - 2; // Subtract 2 for terminal padding in preview
} else if (flexMode === 'full-minus-40') {
effectiveWidth = terminalWidth - 40;
} else if (flexMode === 'full-until-compact') {
// For preview, always show full width
effectiveWidth = terminalWidth - 2; // Subtract 2 for terminal padding in preview
}
}
// Always ensure we have a width for truncation
const width = terminalWidth; // Always use terminal width for truncation
const flexWidth = effectiveWidth; // Use this for flex separator calculations
const rawElements: { content: string; type: string; item?: StatusItem }[] = [];
let hasFlexSeparator = false;
items.forEach((item, index) => {
switch (item.type) {
case 'model':
const modelText = item.rawValue ? 'Claude' : 'Model: Claude';
rawElements.push({ content: applyItemColors(modelText, item, 'cyan'), type: 'model', item });
break;
case 'git-branch':
const branchText = '⎇ main';
rawElements.push({ content: applyItemColors(branchText, item, 'magenta'), type: 'git-branch', item });
break;
case 'git-changes':
const changesText = '(+42,-10)';
rawElements.push({ content: applyItemColors(changesText, item, 'yellow'), type: 'git-changes', item });
break;
case 'tokens-input':
const inputText = item.rawValue ? '15.2k' : 'In: 15.2k';
rawElements.push({ content: applyItemColors(inputText, item, 'blue'), type: 'tokens-input', item });
break;
case 'tokens-output':
const outputText = item.rawValue ? '3.4k' : 'Out: 3.4k';
rawElements.push({ content: applyItemColors(outputText, item, 'white'), type: 'tokens-output', item });
break;
case 'tokens-cached':
const cachedText = item.rawValue ? '12k' : 'Cached: 12k';
rawElements.push({ content: applyItemColors(cachedText, item, 'cyan'), type: 'tokens-cached', item });
break;
case 'tokens-total':
const totalText = item.rawValue ? '30.6k' : 'Total: 30.6k';
rawElements.push({ content: applyItemColors(totalText, item, 'cyan'), type: 'tokens-total', item });
break;
case 'context-length':
const ctxText = item.rawValue ? '18.6k' : 'Ctx: 18.6k';
rawElements.push({ content: applyItemColors(ctxText, item, 'gray'), type: 'context-length', item });
break;
case 'context-percentage':
const ctxPctText = item.rawValue ? '9.3%' : 'Ctx: 9.3%';
rawElements.push({ content: applyItemColors(ctxPctText, item, 'blue'), type: 'context-percentage', item });
break;
case 'context-percentage-usable':
const ctxUsableText = item.rawValue ? '11.6%' : 'Ctx(u): 11.6%';
rawElements.push({ content: applyItemColors(ctxUsableText, item, 'green'), type: 'context-percentage-usable', item });
break;
case 'session-clock':
const sessionText = item.rawValue ? '2hr 15m' : 'Session: 2hr 15m';
rawElements.push({ content: applyItemColors(sessionText, item, 'yellow'), type: 'session-clock', item });
break;
case 'version':
const versionText = item.rawValue ? '1.0.72' : 'Version: 1.0.72';
rawElements.push({ content: applyItemColors(versionText, item, 'green'), type: 'version', item });
break;
case 'terminal-width':
const detectedWidth = canDetectTerminalWidth() ? terminalWidth : '??';
const termText = item.rawValue ? `${detectedWidth}` : `Term: ${detectedWidth}`;
rawElements.push({ content: applyItemColors(termText, item, 'gray'), type: 'terminal-width', item });
break;
case 'separator':
const sepChar = item.character || '|';
// Handle special separator cases
let sepContent;
if (sepChar === ',') {
sepContent = `${sepChar} `;
} else if (sepChar === ' ') {
sepContent = ' ';
} else {
sepContent = ` ${sepChar} `;
}
rawElements.push({ content: applyItemColors(sepContent, item, 'gray'), type: 'separator', item });
break;
case 'flex-separator':
rawElements.push({ content: 'FLEX', type: 'flex-separator', item });
hasFlexSeparator = true;
break;
case 'custom-text':
const customText = item.customText || '';
rawElements.push({ content: applyItemColors(customText, item, 'white'), type: 'custom-text', item });
break;
case 'custom-command':
const cmdText = item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]';
// Only apply color if not preserving colors
if (!item.preserveColors) {
rawElements.push({ content: applyItemColors(cmdText, item, 'white'), type: 'custom-command', item });
} else {
// When preserving colors, just show the text without applying our colors
rawElements.push({ content: cmdText, type: 'custom-command', item });
}
break;
}
});
// Apply default padding and separators
const elements: string[] = [];
const padding = settings?.defaultPadding || '';
const defaultSep = settings?.defaultSeparator || '';
// Debug: Check if we have a default separator
// console.log('Default separator:', JSON.stringify(defaultSep), 'Length:', defaultSep.length);
rawElements.forEach((elem, index) => {
// Add default separator between any two items (but not before first item)
if (defaultSep && index > 0) {
// Check if we should inherit colors from the previous element
if (settings?.inheritSeparatorColors && index > 0) {
const prevElem = rawElements[index - 1];
if (prevElem && prevElem.item) {
// Apply the previous element's colors to the separator (with overrides)
const fgColor = settings?.overrideForegroundColor && settings.overrideForegroundColor !== 'none'
? settings.overrideForegroundColor
: (prevElem.item.color || getDefaultColor(prevElem.item.type));
const bgColor = settings?.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none'
? settings.overrideBackgroundColor
: prevElem.item.backgroundColor;
const shouldBold = settings?.globalBold || prevElem.item.bold;
const coloredSep = applyColors(defaultSep, fgColor, bgColor, shouldBold);
elements.push(coloredSep);
} else {
elements.push(defaultSep);
}
} else if (settings?.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none' ||
settings?.overrideForegroundColor && settings.overrideForegroundColor !== 'none') {
// Apply override colors even when not inheriting colors
const fgColor = settings?.overrideForegroundColor && settings.overrideForegroundColor !== 'none'
? settings.overrideForegroundColor
: undefined;
const coloredSep = applyColors(defaultSep, fgColor, settings.overrideBackgroundColor, settings?.globalBold);
elements.push(coloredSep);
} else {
elements.push(defaultSep);
}
}
// Add the element itself
if (elem.type === 'separator' || elem.type === 'flex-separator') {
elements.push(elem.content);
} else {
// Apply padding with the same colors as the item (or overrides)
const fgColor = settings?.overrideForegroundColor && settings.overrideForegroundColor !== 'none'
? settings.overrideForegroundColor
: undefined;
const bgColor = settings?.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none'
? settings.overrideBackgroundColor
: elem.item?.backgroundColor;
if (padding && (bgColor || fgColor)) {
// Apply colors to padding
const paddedContent = applyColors(padding, fgColor, bgColor, settings?.globalBold) +
elem.content +
applyColors(padding, fgColor, bgColor, settings?.globalBold);
elements.push(paddedContent);
} else {
// No colors or no padding
elements.push(padding + elem.content + padding);
}
}
});
// Build the status line with flex separator support
let statusLine = '';
if (hasFlexSeparator && flexWidth) {
const parts: string[][] = [[]];
let currentPart = 0;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type === 'flex-separator') {
currentPart++;
parts[currentPart] = [];
} else {
const element = elements[i];
if (element !== 'FLEX' && parts[currentPart]) {
parts[currentPart]!.push(element as string);
}
}
}
// Calculate total length of all non-flex content
const partLengths = parts.map(part => {
const joined = part.join('');
return joined.replace(/\x1b\[[0-9;]*m/g, '').length;
});
const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0);
// Calculate space to distribute among flex separators
const flexCount = parts.length - 1; // Number of flex separators
const totalSpace = Math.max(0, flexWidth - totalContentLength);
const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0;
const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0;
// Build the status line with distributed spacing
statusLine = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part) {
statusLine += part.join('');
}
if (i < parts.length - 1) {
// Add flex spacing
const spaces = spacePerFlex + (i < extraSpace ? 1 : 0);
statusLine += ' '.repeat(spaces);
}
}
} else {
// No width detection available - treat flex separators as normal separators
statusLine = elements.map(e => e === 'FLEX' ? chalk.gray(' | ') : e).join('');
}
// Truncate if the line exceeds the maximum width
if (width && width > 0) {
// Remove ANSI escape codes to get actual length
const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length;
if (plainLength > width) {
// Need to truncate - preserve ANSI codes while truncating
let truncated = '';
let currentLength = 0;
let inAnsiCode = false;
let ansiBuffer = '';
const targetLength = width - 5; // Reserve 3 chars for ellipsis + 2 for proper fit in preview
for (let i = 0; i < statusLine.length; i++) {
const char = statusLine[i];
if (char === '\x1b') {
inAnsiCode = true;
ansiBuffer = char;
} else if (inAnsiCode) {
ansiBuffer += char;
if (char === 'm') {
truncated += ansiBuffer;
inAnsiCode = false;
ansiBuffer = '';
}
} else {
if (currentLength < targetLength) {
truncated += char;
currentLength++;
} else {
break;
}
}
}
statusLine = truncated + '...';
}
}
return statusLine;
return renderLine(items, settings || {}, context);
};
const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ lines, terminalWidth, settings }) => {
const widthDetectionAvailable = canDetectTerminalWidth();
// Build the Claude Code input box - account for ink's padding
const boxWidth = Math.min(terminalWidth - 4, process.stdout.columns - 4 || 76);
const topLine = chalk.gray('╭' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╮');
const middleLine = chalk.gray('│') + ' > ' + ' '.repeat(Math.max(0, boxWidth - 5)) + chalk.gray('│');
const bottomLine = chalk.gray('╰' + '─'.repeat(Math.max(0, boxWidth - 2)) + '╯');
// Render each configured line - account for 2-space prefix in display
const availableWidth = boxWidth - 2; // Account for 2-space indent
// Render each configured line
// Pass the full terminal width - the renderer will handle preview adjustments
const renderedLines = lines.map(lineItems =>
lineItems.length > 0 ? renderSingleLine(lineItems, availableWidth, widthDetectionAvailable, settings) : ''
lineItems.length > 0 ? renderSingleLine(lineItems, terminalWidth, widthDetectionAvailable, settings) : ''
).filter(line => line !== ''); // Remove empty lines
return (
<Box flexDirection='column'>
<Text>{topLine}</Text>
<Text>{middleLine}</Text>
<Text>{bottomLine}</Text>
<Box borderStyle="round" borderColor="gray" width="100%" paddingLeft={1}>
<Text>&gt;</Text>
</Box>
{renderedLines.map((line, index) => (
<Text key={index}> {line}</Text>
))}
@@ -518,7 +199,7 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasCha
const items = [
{ label: '📝 Edit Lines', value: 'lines' },
{ label: '🎨 Configure Colors', value: 'colors' },
{ label: '⚡ Flex Options', value: 'flex' },
{ label: '📏 Terminal Width Options', value: 'terminalWidth' },
{ label: '🔧 Global Options', value: 'globalOptions' },
{ label: isClaudeInstalled ? '🗑️ Uninstall from Claude Code' : '📦 Install to Claude Code', value: 'install' },
];
@@ -536,9 +217,9 @@ const MainMenu: React.FC<MainMenuProps> = ({ onSelect, isClaudeInstalled, hasCha
<Box flexDirection='column'>
<Text bold>Main Menu</Text>
<Box marginTop={1}>
<SelectInput
items={items}
onSelect={(item) => onSelect(item.value)}
<SelectInput
items={items}
onSelect={(item) => onSelect(item.value)}
initialIndex={Math.min(initialSelection, items.length - 1)}
/>
</Box>
@@ -597,11 +278,15 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
} else if (key.ctrl && key.rightArrow) {
// Move to end
setTextCursorPos(textInput.length);
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
if (textCursorPos > 0) {
setTextInput(textInput.slice(0, textCursorPos - 1) + textInput.slice(textCursorPos));
setTextCursorPos(textCursorPos - 1);
}
} else if (key.delete) {
if (textCursorPos < textInput.length) {
setTextInput(textInput.slice(0, textCursorPos) + textInput.slice(textCursorPos + 1));
}
} else if (input && input.length === 1) {
setTextInput(textInput.slice(0, textCursorPos) + input + textInput.slice(textCursorPos));
setTextCursorPos(textCursorPos + 1);
@@ -634,11 +319,15 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
} else if (key.ctrl && key.rightArrow) {
// Move to end
setCommandCursorPos(commandInput.length);
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
if (commandCursorPos > 0) {
setCommandInput(commandInput.slice(0, commandCursorPos - 1) + commandInput.slice(commandCursorPos));
setCommandCursorPos(commandCursorPos - 1);
}
} else if (key.delete) {
if (commandCursorPos < commandInput.length) {
setCommandInput(commandInput.slice(0, commandCursorPos) + commandInput.slice(commandCursorPos + 1));
}
} else if (input) {
setCommandInput(commandInput.slice(0, commandCursorPos) + input + commandInput.slice(commandCursorPos));
setCommandCursorPos(commandCursorPos + input.length);
@@ -666,8 +355,10 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
// Cancel editing
setEditingMaxWidth(false);
setMaxWidthInput('');
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
setMaxWidthInput(maxWidthInput.slice(0, -1));
} else if (key.delete) {
// For simple number inputs, forward delete does nothing since there's no cursor position
} else if (input && /\d/.test(input)) {
setMaxWidthInput(maxWidthInput + input);
}
@@ -694,8 +385,10 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
// Cancel editing
setEditingTimeout(false);
setTimeoutInput('');
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
setTimeoutInput(timeoutInput.slice(0, -1));
} else if (key.delete) {
// For simple number inputs, forward delete does nothing since there's no cursor position
} else if (input && /\d/.test(input)) {
setTimeoutInput(timeoutInput + input);
}
@@ -864,7 +557,7 @@ const ItemsEditor: React.FC<ItemsEditorProps> = ({ items, onUpdate, onBack, line
// Get the color for this item (use custom color if set, otherwise default)
const colorName = item.color || getDefaultColor(item.type);
const colorFunc = (chalk as any)[colorName] || chalk.white;
switch (item.type) {
case 'model':
return colorFunc('Model');
@@ -1012,10 +705,16 @@ interface ColorMenuProps {
}
const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
const colorableItems = items.filter(item =>
['model', 'git-branch', 'git-changes', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable', 'session-clock', 'terminal-width', 'version', 'custom-text', 'custom-command'].includes(item.type) &&
!(item.type === 'custom-command' && item.preserveColors) // Exclude custom-command items with preserveColors
);
const [showSeparators, setShowSeparators] = useState(false);
const colorableItems = items.filter(item => {
// Include separators only if showSeparators is true
if (item.type === 'separator') {
return showSeparators;
}
return ['model', 'git-branch', 'git-changes', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable', 'session-clock', 'terminal-width', 'version', 'custom-text', 'custom-command'].includes(item.type) &&
!(item.type === 'custom-command' && item.preserveColors); // Exclude custom-command items with preserveColors
});
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(colorableItems[0]?.id || null);
const [editingBackground, setEditingBackground] = useState(false);
@@ -1028,7 +727,7 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
onBack();
return;
}
// Normal keyboard handling when there are items
if (key.escape) {
if (editingBackground) {
@@ -1036,6 +735,20 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
} else {
onBack();
}
} else if (input === 's' || input === 'S') {
// Toggle show separators
setShowSeparators(!showSeparators);
// Reset highlight to first item if there are items
const newColorableItems = items.filter(item => {
if (item.type === 'separator') {
return !showSeparators; // Will be toggled
}
return ['model', 'git-branch', 'git-changes', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable', 'session-clock', 'terminal-width', 'version', 'custom-text', 'custom-command'].includes(item.type) &&
!(item.type === 'custom-command' && item.preserveColors);
});
if (newColorableItems.length > 0) {
setHighlightedItemId(newColorableItems[0]!.id);
}
} else if (input === 'f' || input === 'F') {
if (colorableItems.length > 0) {
setEditingBackground(!editingBackground);
@@ -1099,6 +812,7 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
case 'session-clock': return 'Session Clock';
case 'terminal-width': return 'Terminal Width';
case 'version': return 'Version';
case 'separator': return `Separator (${item.character || '|'})`;
case 'custom-text': return `Custom Text (${item.customText || 'Empty'})`;
case 'custom-command': {
const cmd = item.commandPath ? item.commandPath.substring(0, 20) + (item.commandPath.length > 20 ? '...' : '') : 'No command';
@@ -1167,25 +881,25 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
const colors = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright',
'magentaBright', 'cyanBright', 'whiteBright'];
// Background colors list (includes 'none' as first option)
// Note: chalk doesn't support bgDim, so we exclude it from background colors
const bgColors = ['none', 'bgBlack', 'bgRed', 'bgGreen', 'bgYellow', 'bgBlue', 'bgMagenta',
'bgCyan', 'bgWhite', 'bgGray', 'bgRedBright', 'bgGreenBright', 'bgYellowBright',
const bgColors = ['none', 'bgBlack', 'bgRed', 'bgGreen', 'bgYellow', 'bgBlue', 'bgMagenta',
'bgCyan', 'bgWhite', 'bgGray', 'bgRedBright', 'bgGreenBright', 'bgYellowBright',
'bgBlueBright', 'bgMagentaBright', 'bgCyanBright', 'bgWhiteBright'];
// Get current color for highlighted item
const selectedItem = highlightedItemId && highlightedItemId !== 'back'
? colorableItems.find(item => item.id === highlightedItemId)
const selectedItem = highlightedItemId && highlightedItemId !== 'back'
? colorableItems.find(item => item.id === highlightedItemId)
: null;
const currentColor = editingBackground
const currentColor = editingBackground
? (selectedItem?.backgroundColor || 'none')
: (selectedItem ? (selectedItem.color || getDefaultColor(selectedItem.type)) : 'white');
const colorList = editingBackground ? bgColors : colors;
const colorIndex = colorList.indexOf(currentColor);
const colorNumber = colorIndex === -1 ? (editingBackground ? 1 : 8) : colorIndex + 1;
let colorDisplay;
if (editingBackground) {
if (currentColor === 'none') {
@@ -1203,6 +917,7 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
<Box flexDirection='column'>
<Text bold>Configure Colors {editingBackground && chalk.yellow('[Background Mode]')}</Text>
<Text dimColor> to select, Enter to cycle {editingBackground ? 'background' : 'foreground'}, (f) to toggle bg/fg, (b)old, (r)eset, ESC to go back</Text>
<Text dimColor>(s)how separators: {showSeparators ? chalk.green('ON') : chalk.gray('OFF')}</Text>
{selectedItem && (
<Box marginTop={1}>
<Text>
@@ -1221,21 +936,19 @@ const ColorMenu: React.FC<ColorMenuProps> = ({ items, onUpdate, onBack }) => {
</Box>
<Box marginTop={1} flexDirection='column'>
<Text color='yellow'> VSCode Users: </Text>
<Text dimColor wrap='wrap'>If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio"</Text>
<Text dimColor wrap='wrap'>(`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors.</Text>
<Text dimColor wrap='wrap'>You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.</Text>
<Text dimColor wrap='wrap'>If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.</Text>
</Box>
</Box>
);
};
interface FlexOptionsProps {
interface TerminalWidthOptionsProps {
settings: Settings;
onUpdate: (settings: Settings) => void;
onBack: () => void;
}
const FlexOptions: React.FC<FlexOptionsProps> = ({ settings, onUpdate, onBack }) => {
const TerminalWidthOptions: React.FC<TerminalWidthOptionsProps> = ({ settings, onUpdate, onBack }) => {
const [selectedOption, setSelectedOption] = useState<FlexMode>(settings.flexMode || 'full-minus-40');
const [compactThreshold, setCompactThreshold] = useState(settings.compactThreshold || 60);
const [editingThreshold, setEditingThreshold] = useState(false);
@@ -1267,9 +980,11 @@ const FlexOptions: React.FC<FlexOptionsProps> = ({ settings, onUpdate, onBack })
setThresholdInput(String(compactThreshold));
setEditingThreshold(false);
setValidationError(null);
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
setThresholdInput(thresholdInput.slice(0, -1));
setValidationError(null);
} else if (key.delete) {
// For simple number inputs, forward delete does nothing since there's no cursor position
} else if (input && /\d/.test(input)) {
const newValue = thresholdInput + input;
if (newValue.length <= 2) {
@@ -1292,7 +1007,7 @@ const FlexOptions: React.FC<FlexOptionsProps> = ({ settings, onUpdate, onBack })
},
{
value: 'full-minus-40' as FlexMode,
label: 'Full width minus 40',
label: 'Full width minus 40 (default)',
description: 'Leaves a gap to the right of the status line to accommodate the auto-compact message. This prevents wrapping but may leave unused space. This limitation exists because we cannot detect when the message will appear.'
},
{
@@ -1330,8 +1045,9 @@ const FlexOptions: React.FC<FlexOptionsProps> = ({ settings, onUpdate, onBack })
return (
<Box flexDirection='column'>
<Text bold>Flex Separator Width Options</Text>
<Text dimColor>Select how flex separators calculate available width</Text>
<Text bold>Terminal Width Options</Text>
<Text color="white">These settings affect where long lines are truncated, and where right-alignment occurs when using flex separators</Text>
<Text dimColor wrap="wrap">These settings are necessary because claude code does not currently provide an available width variable for the statusline and features like IDE integration, auto-compaction notices, and rate limit messages can all cause the statusline to wrap if we do not truncate it</Text>
{editingThreshold ? (
<Box marginTop={1} flexDirection='column'>
@@ -1393,13 +1109,13 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
const [separatorInput, setSeparatorInput] = useState(settings.defaultSeparator || '');
const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors || false);
const [globalBold, setGlobalBold] = useState(settings.globalBold || false);
// Background color override
const bgColors = ['none', 'bgBlack', 'bgRed', 'bgGreen', 'bgYellow', 'bgBlue', 'bgMagenta',
'bgCyan', 'bgWhite', 'bgGray', 'bgRedBright', 'bgGreenBright', 'bgYellowBright',
const bgColors = ['none', 'bgBlack', 'bgRed', 'bgGreen', 'bgYellow', 'bgBlue', 'bgMagenta',
'bgCyan', 'bgWhite', 'bgGray', 'bgRedBright', 'bgGreenBright', 'bgYellowBright',
'bgBlueBright', 'bgMagentaBright', 'bgCyanBright', 'bgWhiteBright'];
const currentBgIndex = bgColors.indexOf(settings.overrideBackgroundColor || 'none');
// Foreground color override
const fgColors = ['none', 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
'gray', 'redBright', 'greenBright', 'yellowBright', 'blueBright',
@@ -1418,8 +1134,10 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
} else if (key.escape) {
setPaddingInput(settings.defaultPadding || '');
setEditingPadding(false);
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
setPaddingInput(paddingInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (input) {
setPaddingInput(paddingInput + input);
}
@@ -1434,8 +1152,10 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
} else if (key.escape) {
setSeparatorInput(settings.defaultSeparator || '');
setEditingSeparator(false);
} else if (key.backspace || key.delete) {
} else if (key.backspace) {
setSeparatorInput(separatorInput.slice(0, -1));
} else if (key.delete) {
// For simple text inputs without cursor, forward delete does nothing
} else if (input) {
setSeparatorInput(separatorInput + input);
}
@@ -1504,7 +1224,7 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
<Text bold>Global Options</Text>
<Text dimColor>Configure automatic padding and separators between items</Text>
<Box marginTop={1} />
{editingPadding ? (
<Box flexDirection="column">
<Box>
@@ -1528,25 +1248,25 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
<Text color="cyan">{settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'}</Text>
<Text dimColor> - Press (p) to edit</Text>
</Box>
<Box>
<Text>Default Separator: </Text>
<Text color="cyan">{settings.defaultSeparator ? `"${settings.defaultSeparator}"` : '(none)'}</Text>
<Text dimColor> - Press (s) to edit</Text>
</Box>
<Box>
<Text> Inherit Colors: </Text>
<Text color={inheritColors ? "green" : "red"}>{inheritColors ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (i) to toggle</Text>
</Box>
<Box>
<Text> Global Bold: </Text>
<Text color={globalBold ? "green" : "red"}>{globalBold ? '✓ Enabled' : '✗ Disabled'}</Text>
<Text dimColor> - Press (o) to toggle</Text>
</Box>
<Box>
<Text>Override BG Color: </Text>
{(() => {
@@ -1562,7 +1282,7 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
})()}
<Text dimColor> - (b) cycle, (c) clear</Text>
</Box>
<Box>
<Text>Override FG Color: </Text>
{(() => {
@@ -1577,11 +1297,11 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
})()}
<Text dimColor> - (f) cycle, (g) clear</Text>
</Box>
<Box marginTop={2}>
<Text dimColor>Press ESC to go back</Text>
</Box>
<Box marginTop={1} flexDirection='column'>
<Text dimColor wrap='wrap'>
Note: These settings are applied during rendering and don't add items to your widget list.
@@ -1597,9 +1317,7 @@ const GlobalOptionsMenu: React.FC<GlobalOptionsMenuProps> = ({ settings, onUpdat
</Text>
<Box marginTop={1} flexDirection='column'>
<Text color='yellow'>⚠ VSCode Users: </Text>
<Text dimColor wrap='wrap'>If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio"</Text>
<Text dimColor wrap='wrap'>(`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors.</Text>
<Text dimColor wrap='wrap'>You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.</Text>
<Text dimColor wrap='wrap'>If colors appear incorrect in the VSCode integrated terminal, the "Terminal Integrated: Minimum Contrast Ratio" (`terminal.integrated.minimumContrastRatio`) setting is forcing a minimum contrast between foreground and background colors. You can adjust this setting to 1 to disable the contrast enforcement, or use a standalone terminal for accurate colors.</Text>
</Box>
</Box>
</>
@@ -1613,7 +1331,7 @@ const App: React.FC = () => {
const [settings, setSettings] = useState<Settings | null>(null);
const [originalSettings, setOriginalSettings] = useState<Settings | null>(null);
const [hasChanges, setHasChanges] = useState(false);
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colors' | 'flex' | 'globalOptions' | 'confirm'>('main');
const [screen, setScreen] = useState<'main' | 'lines' | 'items' | 'colors' | 'terminalWidth' | 'globalOptions' | 'confirm'>('main');
const [selectedLine, setSelectedLine] = useState(0);
const [menuSelections, setMenuSelections] = useState<Record<string, number>>({});
const [confirmDialog, setConfirmDialog] = useState<{ message: string; action: () => Promise<void> } | null>(null);
@@ -1709,8 +1427,8 @@ const App: React.FC = () => {
case 'colors':
setScreen('colors');
break;
case 'flex':
setScreen('flex');
case 'terminalWidth':
setScreen('terminalWidth');
break;
case 'globalOptions':
setScreen('globalOptions');
@@ -1754,14 +1472,14 @@ const App: React.FC = () => {
<Box marginTop={2}>
{screen === 'main' && (
<MainMenu
<MainMenu
onSelect={(value) => {
// Only persist menu selection if not exiting
if (value !== 'save' && value !== 'exit') {
const menuMap: Record<string, number> = {
'lines': 0,
'colors': 1,
'flex': 2,
'terminalWidth': 2,
'globalOptions': 3,
'install': 4
};
@@ -1769,7 +1487,7 @@ const App: React.FC = () => {
}
handleMainMenuSelect(value);
}}
isClaudeInstalled={isClaudeInstalled}
isClaudeInstalled={isClaudeInstalled}
hasChanges={hasChanges}
initialSelection={menuSelections.main || 0}
/>
@@ -1831,14 +1549,14 @@ const App: React.FC = () => {
}}
/>
)}
{screen === 'flex' && (
<FlexOptions
{screen === 'terminalWidth' && (
<TerminalWidthOptions
settings={settings}
onUpdate={(updatedSettings) => {
setSettings(updatedSettings);
}}
onBack={() => {
// Save that we came from 'flex' menu (index 2)
// Save that we came from 'terminalWidth' menu (index 2)
setMenuSelections({ ...menuSelections, main: 2 });
setScreen('main');
}}
+826
View File
@@ -0,0 +1,826 @@
import chalk from 'chalk';
import { execSync } from 'child_process';
import * as fs from 'fs';
import { promisify } from 'util';
import type { StatusItem } from './config';
// Ensure fs.promises compatibility for older Node versions
const readFile = fs.promises?.readFile || promisify(fs.readFile);
// Force chalk to use colors even when piped
chalk.level = 3;
export interface StatusJSON {
session_id: string;
transcript_path: string;
cwd: string;
model: {
id: string;
display_name: string;
};
workspace: {
current_dir: string;
project_dir: string;
};
version?: string;
}
export interface TokenUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
export interface TranscriptLine {
message?: {
usage?: TokenUsage;
};
isSidechain?: boolean;
timestamp?: string;
}
export interface TokenMetrics {
inputTokens: number;
outputTokens: number;
cachedTokens: number;
totalTokens: number;
contextLength: number;
}
export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
sessionDuration?: string | null;
gitBranch?: string | null;
gitChanges?: { insertions: number; deletions: number } | null;
terminalWidth?: number | null;
isPreview?: boolean;
}
// Helper function to apply foreground, background colors and bold
export function applyColors(text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string {
let result = text;
// Standard color application
// Ignore 'dim' color - it causes issues with terminal rendering
if (foregroundColor && foregroundColor !== 'dim') {
const fgFunc = (chalk as any)[foregroundColor];
if (fgFunc) {
result = fgFunc(result);
}
}
if (backgroundColor && backgroundColor !== 'none') {
const bgFunc = (chalk as any)[backgroundColor];
if (bgFunc) {
result = bgFunc(result);
}
}
if (bold) {
result = chalk.bold(result);
}
return result;
}
// Helper to get default color for an item type
export function getItemDefaultColor(type: string): string {
switch (type) {
case 'model': return 'cyan';
case 'git-branch': return 'magenta';
case 'git-changes': return 'yellow';
case 'session-clock': return 'yellow';
case 'version': return 'green';
case 'tokens-input': return 'blue';
case 'tokens-output': return 'white';
case 'tokens-cached': return 'cyan';
case 'tokens-total': return 'cyan';
case 'context-length': return 'gray';
case 'context-percentage': return 'blue';
case 'context-percentage-usable': return 'green';
case 'terminal-width': return 'gray';
case 'custom-text': return 'white';
case 'custom-command': return 'white';
case 'separator': return 'gray';
default: return 'white';
}
}
// Helper function to format token counts
export function formatTokens(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
}
export function getTerminalWidth(): number | null {
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 (not ?? which means no tty)
if (tty && tty !== '??' && tty !== '?') {
// Now get the terminal size
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 parsed;
}
}
} catch {
// Command failed, width detection not available
}
// Fallback: try tput cols which might work in some environments
try {
const width = execSync('tput cols 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
const parsed = parseInt(width, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed;
}
} catch {
// tput also failed
}
return null;
}
export function getGitBranch(): string | null {
try {
const branch = execSync('git branch --show-current 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
return branch || null;
} catch {
return null;
}
}
export function getGitChanges(): { insertions: number; deletions: number } | null {
try {
let totalInsertions = 0;
let totalDeletions = 0;
// Get unstaged changes
const unstagedStat = execSync('git diff --shortstat 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Get staged changes
const stagedStat = execSync('git diff --cached --shortstat 2>/dev/null', {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
// Parse unstaged changes
if (unstagedStat) {
const insertMatch = unstagedStat.match(/(\d+) insertion/);
const deleteMatch = unstagedStat.match(/(\d+) deletion/);
totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0;
totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0;
}
// Parse staged changes
if (stagedStat) {
const insertMatch = stagedStat.match(/(\d+) insertion/);
const deleteMatch = stagedStat.match(/(\d+) deletion/);
totalInsertions += insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0;
totalDeletions += deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0;
}
// Always return the changes, even if they're zero
return { insertions: totalInsertions, deletions: totalDeletions };
} catch {
return null;
}
}
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
try {
if (!fs.existsSync(transcriptPath)) {
return null;
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) {
return null;
}
let firstTimestamp: Date | null = null;
let lastTimestamp: Date | null = null;
// Find first valid timestamp
for (const line of lines) {
try {
const data = JSON.parse(line);
if (data.timestamp) {
firstTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
// Find last valid timestamp (iterate backwards)
for (let i = lines.length - 1; i >= 0; i--) {
try {
const data = JSON.parse(lines[i]!);
if (data.timestamp) {
lastTimestamp = new Date(data.timestamp);
break;
}
} catch {
// Skip invalid lines
}
}
if (!firstTimestamp || !lastTimestamp) {
return null;
}
// Calculate duration in milliseconds
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
// Convert to minutes
const totalMinutes = Math.floor(durationMs / (1000 * 60));
if (totalMinutes < 1) {
return '<1m';
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}hr`;
} else {
return `${hours}hr ${minutes}m`;
}
} catch {
return null;
}
}
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
try {
// Use Node.js-compatible file reading
if (!fs.existsSync(transcriptPath)) {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
const content = await readFile(transcriptPath, 'utf-8');
const lines = content.trim().split('\n');
let inputTokens = 0;
let outputTokens = 0;
let cachedTokens = 0;
let contextLength = 0;
// Parse each line and sum up token usage for totals
let mostRecentMainChainEntry: TranscriptLine | null = null;
let mostRecentTimestamp: Date | null = null;
for (const line of lines) {
try {
const data: TranscriptLine = JSON.parse(line);
if (data.message?.usage) {
inputTokens += data.message.usage.input_tokens || 0;
outputTokens += data.message.usage.output_tokens || 0;
cachedTokens += data.message.usage.cache_read_input_tokens || 0;
cachedTokens += data.message.usage.cache_creation_input_tokens || 0;
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
if (data.isSidechain !== true && data.timestamp) {
const entryTime = new Date(data.timestamp);
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
mostRecentTimestamp = entryTime;
mostRecentMainChainEntry = data;
}
}
}
} catch {
// Skip invalid JSON lines
}
}
// Calculate context length from the most recent main chain message
if (mostRecentMainChainEntry?.message?.usage) {
const usage = mostRecentMainChainEntry.message.usage;
contextLength = (usage.input_tokens || 0) +
(usage.cache_read_input_tokens || 0) +
(usage.cache_creation_input_tokens || 0);
}
const totalTokens = inputTokens + outputTokens + cachedTokens;
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
} catch {
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
}
}
export function renderStatusLine(
items: StatusItem[],
settings: any,
context: RenderContext
): string {
// Helper to apply colors with optional background and bold override
const applyColorsWithOverride = (text: string, foregroundColor?: string, backgroundColor?: string, bold?: boolean): string => {
// Override foreground color takes precedence over EVERYTHING, including passed foreground color
let fgColor = foregroundColor;
if (settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none') {
fgColor = settings.overrideForegroundColor;
}
// Override background color takes precedence over EVERYTHING, including passed background color
let bgColor = backgroundColor;
if (settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') {
bgColor = settings.overrideBackgroundColor;
}
const shouldBold = settings.globalBold || bold;
return applyColors(text, fgColor, bgColor, shouldBold);
};
const detectedWidth = context.terminalWidth || getTerminalWidth();
// Calculate terminal width based on flex mode settings
let terminalWidth: number | null = null;
if (detectedWidth) {
const flexMode = settings.flexMode || 'full-minus-40';
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 - 43; // -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 - 4;
} else if (flexMode === 'full-minus-40') {
// Always subtract 41 for auto-compact message
terminalWidth = detectedWidth - 41;
} else if (flexMode === 'full-until-compact') {
// Check context percentage to decide
const threshold = settings.compactThreshold || 60;
const contextPercentage = context.tokenMetrics ?
Math.min(100, (context.tokenMetrics.contextLength / 200000) * 100) : 0;
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 - 4;
}
}
}
}
const elements: { content: string, type: string, item?: StatusItem }[] = [];
let hasFlexSeparator = false;
// Build elements based on configured items
for (const item of items) {
switch (item.type) {
case 'model':
if (context.isPreview) {
const modelText = item.rawValue ? 'Claude' : 'Model: Claude';
elements.push({ content: applyColorsWithOverride(modelText, item.color || 'cyan', item.backgroundColor, item.bold), type: 'model', item });
} else if (context.data?.model) {
const text = item.rawValue ? context.data.model.display_name : `Model: ${context.data.model.display_name}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'model', item });
}
break;
case 'git-branch':
if (context.isPreview) {
const branchText = item.rawValue ? 'main' : '⎇ main';
elements.push({ content: applyColorsWithOverride(branchText, item.color || 'magenta', item.backgroundColor, item.bold), type: 'git-branch', item });
} else {
const branch = context.gitBranch || getGitBranch();
if (branch) {
const text = item.rawValue ? branch : `${branch}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'magenta', item.backgroundColor, item.bold), type: 'git-branch', item });
}
}
break;
case 'git-changes':
if (context.isPreview) {
const changesText = '(+42,-10)';
elements.push({ content: applyColorsWithOverride(changesText, item.color || 'yellow', item.backgroundColor, item.bold), type: 'git-changes', item });
} else {
const changes = context.gitChanges || getGitChanges();
if (changes !== null) {
const changeStr = `(+${changes.insertions},-${changes.deletions})`;
elements.push({ content: applyColorsWithOverride(changeStr, item.color || 'yellow', item.backgroundColor, item.bold), type: 'git-changes', item });
}
}
break;
case 'tokens-input':
if (context.isPreview) {
const inputText = item.rawValue ? '15.2k' : 'In: 15.2k';
elements.push({ content: applyColorsWithOverride(inputText, item.color || 'blue', item.backgroundColor, item.bold), type: 'tokens-input', item });
} else if (context.tokenMetrics) {
const text = item.rawValue ? formatTokens(context.tokenMetrics.inputTokens) : `In: ${formatTokens(context.tokenMetrics.inputTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'blue', item.backgroundColor, item.bold), type: 'tokens-input', item });
}
break;
case 'tokens-output':
if (context.isPreview) {
const outputText = item.rawValue ? '3.4k' : 'Out: 3.4k';
elements.push({ content: applyColorsWithOverride(outputText, item.color || 'white', item.backgroundColor, item.bold), type: 'tokens-output', item });
} else if (context.tokenMetrics) {
const text = item.rawValue ? formatTokens(context.tokenMetrics.outputTokens) : `Out: ${formatTokens(context.tokenMetrics.outputTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'white', item.backgroundColor, item.bold), type: 'tokens-output', item });
}
break;
case 'tokens-cached':
if (context.isPreview) {
const cachedText = item.rawValue ? '12k' : 'Cached: 12k';
elements.push({ content: applyColorsWithOverride(cachedText, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-cached', item });
} else if (context.tokenMetrics) {
const text = item.rawValue ? formatTokens(context.tokenMetrics.cachedTokens) : `Cached: ${formatTokens(context.tokenMetrics.cachedTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-cached', item });
}
break;
case 'tokens-total':
if (context.isPreview) {
const totalText = item.rawValue ? '30.6k' : 'Total: 30.6k';
elements.push({ content: applyColorsWithOverride(totalText, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-total', item });
} else if (context.tokenMetrics) {
const text = item.rawValue ? formatTokens(context.tokenMetrics.totalTokens) : `Total: ${formatTokens(context.tokenMetrics.totalTokens)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'cyan', item.backgroundColor, item.bold), type: 'tokens-total', item });
}
break;
case 'context-length':
if (context.isPreview) {
const ctxText = item.rawValue ? '18.6k' : 'Ctx: 18.6k';
elements.push({ content: applyColorsWithOverride(ctxText, item.color || 'gray', item.backgroundColor, item.bold), type: 'context-length', item });
} else if (context.tokenMetrics) {
const text = item.rawValue ? formatTokens(context.tokenMetrics.contextLength) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength)}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'gray', item.backgroundColor, item.bold), type: 'context-length', item });
}
break;
case 'context-percentage':
if (context.isPreview) {
const ctxPctText = item.rawValue ? '9.3%' : 'Ctx: 9.3%';
elements.push({ content: applyColorsWithOverride(ctxPctText, item.color || 'blue', item.backgroundColor, item.bold), type: 'context-percentage', item });
} else if (context.tokenMetrics) {
const percentage = Math.min(100, (context.tokenMetrics.contextLength / 200000) * 100);
const text = item.rawValue ? `${percentage.toFixed(1)}%` : `Ctx: ${percentage.toFixed(1)}%`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'blue', item.backgroundColor, item.bold), type: 'context-percentage', item });
}
break;
case 'context-percentage-usable':
if (context.isPreview) {
const ctxUsableText = item.rawValue ? '11.6%' : 'Ctx(u): 11.6%';
elements.push({ content: applyColorsWithOverride(ctxUsableText, item.color || 'green', item.backgroundColor, item.bold), type: 'context-percentage-usable', item });
} else if (context.tokenMetrics) {
// Calculate percentage out of 160,000 (80% of full context for auto-compact)
const percentage = Math.min(100, (context.tokenMetrics.contextLength / 160000) * 100);
const text = item.rawValue ? `${percentage.toFixed(1)}%` : `Ctx(u): ${percentage.toFixed(1)}%`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'green', item.backgroundColor, item.bold), type: 'context-percentage-usable', item });
}
break;
case 'terminal-width':
const width = terminalWidth || getTerminalWidth();
if (context.isPreview) {
const detectedWidth = width || '??';
const termText = item.rawValue ? `${detectedWidth}` : `Term: ${detectedWidth}`;
elements.push({ content: applyColorsWithOverride(termText, item.color || 'gray', item.backgroundColor, item.bold), type: 'terminal-width', item });
} else if (width) {
const text = item.rawValue ? `${width}` : `Term: ${width}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'gray', item.backgroundColor, item.bold), type: 'terminal-width', item });
}
break;
case 'session-clock':
if (context.isPreview) {
const sessionText = item.rawValue ? '2hr 15m' : 'Session: 2hr 15m';
elements.push({ content: applyColorsWithOverride(sessionText, item.color || 'yellow', item.backgroundColor, item.bold), type: 'session-clock', item });
} else if (context.sessionDuration) {
const text = item.rawValue ? context.sessionDuration : `Session: ${context.sessionDuration}`;
elements.push({ content: applyColorsWithOverride(text, item.color || 'yellow', item.backgroundColor, item.bold), type: 'session-clock', item });
}
break;
case 'version':
if (context.isPreview) {
const versionText = item.rawValue ? '1.0.72' : 'Version: 1.0.72';
elements.push({ content: applyColorsWithOverride(versionText, item.color || 'green', item.backgroundColor, item.bold), type: 'version', item });
} else if (context.data?.version) {
const versionString = context.data.version || 'Unknown';
const versionText = item.rawValue ? versionString : `Version: ${versionString}`;
elements.push({ content: applyColorsWithOverride(versionText, item.color || 'green', item.backgroundColor, item.bold), type: 'version', item });
}
break;
case 'separator':
// Always add separators - users should be able to add multiple if they want
const sepChar = item.character || '|';
// Handle special separator cases
let sepText;
if (sepChar === ',') {
sepText = `${sepChar} `;
} else if (sepChar === ' ') {
sepText = ' ';
} else {
sepText = ` ${sepChar} `;
}
// Use item color if specified, otherwise default to gray
const sepContent = applyColorsWithOverride(sepText, item.color || 'gray', item.backgroundColor, item.bold);
elements.push({ content: sepContent, type: 'separator', item });
break;
case 'flex-separator':
elements.push({ content: 'FLEX', type: 'flex-separator', item });
hasFlexSeparator = true;
break;
case 'custom-text':
const customText = item.customText || '';
elements.push({ content: applyColorsWithOverride(customText, item.color || 'white', item.backgroundColor, item.bold), type: 'custom-text', item });
break;
case 'custom-command':
if (context.isPreview) {
const cmdText = item.commandPath ? `[cmd: ${item.commandPath.substring(0, 20)}${item.commandPath.length > 20 ? '...' : ''}]` : '[No command]';
// Only apply color if not preserving colors
if (!item.preserveColors) {
elements.push({ content: applyColorsWithOverride(cmdText, item.color || 'white', item.backgroundColor, item.bold), type: 'custom-command', item });
} else {
// When preserving colors, just show the text without applying our colors
elements.push({ content: cmdText, type: 'custom-command', item });
}
} else if (item.commandPath && context.data) {
try {
// Execute the command with JSON input via stdin
const timeout = item.timeout || 1000; // Default to 1000ms if not specified
const output = execSync(item.commandPath, {
encoding: 'utf8',
input: JSON.stringify(context.data),
stdio: ['pipe', 'pipe', 'ignore'],
timeout: timeout
}).trim();
if (output) {
let finalOutput = output;
// Handle max width truncation
if (item.maxWidth && item.maxWidth > 0) {
// Remove ANSI codes to measure actual length
const plainLength = output.replace(/\x1b\[[0-9;]*m/g, '').length;
if (plainLength > item.maxWidth) {
// Truncate while preserving ANSI codes
let truncated = '';
let currentLength = 0;
let inAnsiCode = false;
let ansiBuffer = '';
for (let i = 0; i < output.length; i++) {
const char = output[i];
if (char === '\x1b') {
inAnsiCode = true;
ansiBuffer = char;
} else if (inAnsiCode) {
ansiBuffer += char;
if (char === 'm') {
truncated += ansiBuffer;
inAnsiCode = false;
ansiBuffer = '';
}
} else {
if (currentLength < item.maxWidth) {
truncated += char;
currentLength++;
} else {
break;
}
}
}
finalOutput = truncated;
}
}
// Apply color if not preserving original colors
if (!item.preserveColors) {
// Strip existing ANSI codes and apply new color
const stripped = finalOutput.replace(/\x1b\[[0-9;]*m/g, '');
elements.push({ content: applyColorsWithOverride(stripped, item.color || 'white', item.backgroundColor, item.bold), type: 'custom-command', item });
} else {
// Preserve original colors from command output - ignore any color property
elements.push({ content: finalOutput, type: 'custom-command', item });
}
}
} catch {
// Command failed or timed out - silently skip
}
}
break;
}
}
if (elements.length === 0) return '';
// Remove trailing separators
while (elements.length > 0 && elements[elements.length - 1]?.type === 'separator') {
elements.pop();
}
// Apply default padding and separators
const finalElements: string[] = [];
const padding = settings.defaultPadding || '';
const defaultSep = settings.defaultSeparator || '';
elements.forEach((elem, index) => {
// Add default separator between any two items (but not before first item, and not around flex separators)
const prevElem = index > 0 ? elements[index - 1] : null;
const shouldAddSeparator = defaultSep && index > 0 &&
elem.type !== 'flex-separator' &&
prevElem?.type !== 'flex-separator';
if (shouldAddSeparator) {
// Check if we should inherit colors from the previous element
if (settings.inheritSeparatorColors && index > 0) {
const prevElem = elements[index - 1];
if (prevElem && prevElem.item) {
// Apply the previous element's colors to the separator (already handles override)
// Use the item's color if set, otherwise get the default color for that item type
const itemColor = prevElem.item.color || getItemDefaultColor(prevElem.item.type);
const coloredSep = applyColorsWithOverride(defaultSep, itemColor, prevElem.item.backgroundColor, prevElem.item.bold);
finalElements.push(coloredSep);
} else {
finalElements.push(defaultSep);
}
} else if ((settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') ||
(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none')) {
// Apply override colors even when not inheriting colors
const coloredSep = applyColorsWithOverride(defaultSep, undefined, undefined);
finalElements.push(coloredSep);
} else {
finalElements.push(defaultSep);
}
}
// Add element with padding (separators don't get padding)
if (elem.type === 'separator' || elem.type === 'flex-separator') {
finalElements.push(elem.content);
} else {
// Apply padding with colors (using overrides if set)
const hasColorOverride = (settings.overrideBackgroundColor && settings.overrideBackgroundColor !== 'none') ||
(settings.overrideForegroundColor && settings.overrideForegroundColor !== 'none');
if (padding && (elem.item?.backgroundColor || hasColorOverride)) {
// Apply colors to padding - applyColorsWithOverride will handle the overrides
const paddedContent = applyColorsWithOverride(padding, undefined, elem.item?.backgroundColor) +
elem.content +
applyColorsWithOverride(padding, undefined, elem.item?.backgroundColor);
finalElements.push(paddedContent);
} else {
// No colors or no padding
finalElements.push(padding + elem.content + padding);
}
}
});
// Build the final status line
let statusLine = '';
if (hasFlexSeparator && terminalWidth) {
// Split elements by flex separators
const parts: string[][] = [[]];
let currentPart = 0;
for (let i = 0; i < finalElements.length; i++) {
const elem = finalElements[i];
if (elem === 'FLEX') {
currentPart++;
parts[currentPart] = [];
} else {
parts[currentPart]?.push(elem!);
}
}
// Calculate total length of all non-flex content
const partLengths = parts.map(part => {
const joined = part.join('');
return joined.replace(/\x1b\[[0-9;]*m/g, '').length;
});
const totalContentLength = partLengths.reduce((sum, len) => sum + len, 0);
// Calculate space to distribute among flex separators
const flexCount = parts.length - 1; // Number of flex separators
const totalSpace = Math.max(0, terminalWidth - totalContentLength);
const spacePerFlex = flexCount > 0 ? Math.floor(totalSpace / flexCount) : 0;
const extraSpace = flexCount > 0 ? totalSpace % flexCount : 0;
// Build the status line with distributed spacing
statusLine = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part) {
statusLine += part.join('');
}
if (i < parts.length - 1) {
// Add flex spacing
const spaces = spacePerFlex + (i < extraSpace ? 1 : 0);
statusLine += ' '.repeat(spaces);
}
}
} else {
// No flex separator OR no width detected
if (hasFlexSeparator && !terminalWidth) {
// Treat flex separators as normal separators when width detection fails
statusLine = finalElements.map(e => e === 'FLEX' ? chalk.gray(' | ') : e).join('');
} else {
// Just join all elements normally
statusLine = finalElements.join('');
}
}
// Truncate if the line exceeds the terminal width
// Use terminalWidth if available (already accounts for flex mode adjustments), otherwise use detectedWidth
const maxWidth = terminalWidth || detectedWidth;
if (maxWidth && maxWidth > 0) {
// Remove ANSI escape codes to get actual length
const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length;
if (plainLength > maxWidth) {
// Need to truncate - preserve ANSI codes while truncating
let truncated = '';
let currentLength = 0;
let inAnsiCode = false;
let ansiBuffer = '';
const targetLength = context.isPreview ? maxWidth - 3 : maxWidth - 3; // Reserve 3 chars for ellipsis
for (let i = 0; i < statusLine.length; i++) {
const char = statusLine[i];
if (char === '\x1b') {
inAnsiCode = true;
ansiBuffer = char;
} else if (inAnsiCode) {
ansiBuffer += char;
if (char === 'm') {
truncated += ansiBuffer;
inAnsiCode = false;
ansiBuffer = '';
}
} else {
if (currentLength < targetLength) {
truncated += char;
currentLength++;
} else {
break;
}
}
}
statusLine = truncated + '...';
}
}
return statusLine;
}