diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 7658564..bfdbc1a 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -44,9 +44,9 @@ async function readStdin(): Promise { if (process.stdin.isTTY) { return null; } - + const chunks: string[] = []; - + try { // Use Node.js compatible approach if (typeof Bun !== 'undefined' && Bun.stdin) { @@ -80,6 +80,50 @@ function getGitBranch(): string | 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; + } + + // Return null if no changes at all (so the element doesn't appear) + if (totalInsertions === 0 && totalDeletions === 0) { + return null; + } + + return { insertions: totalInsertions, deletions: totalDeletions }; + } catch { + return null; + } +} + async function getTokenMetrics(transcriptPath: string): Promise<{ inputTokens: number; outputTokens: number; @@ -92,15 +136,17 @@ async function getTokenMetrics(transcriptPath: string): Promise<{ 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; - - // Parse each line and sum up token usage + let contextLength = 0; + + // Parse each line and sum up token usage for totals + let lastMessageWithUsage: TranscriptLine | null = null; for (const line of lines) { try { const data: TranscriptLine = JSON.parse(line); @@ -109,15 +155,25 @@ async function getTokenMetrics(transcriptPath: string): Promise<{ 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; + + // Keep track of the last message with usage data + lastMessageWithUsage = data; } } catch { // Skip invalid JSON lines } } - + + // Calculate context length from the most recent message + if (lastMessageWithUsage?.message?.usage) { + const usage = lastMessageWithUsage.message.usage; + contextLength = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + + (usage.cache_creation_input_tokens || 0); + } + const totalTokens = inputTokens + outputTokens + cachedTokens; - const contextLength = inputTokens + outputTokens; // Current context size - + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; } catch { return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; @@ -129,24 +185,24 @@ async function renderStatusLine(data: StatusJSON) { const terminalWidth = 80; // Always use 80 chars for Claude Code status line const elements: { content: string, type: string }[] = []; let hasFlexSeparator = false; - + // Get token metrics if needed - const hasTokenItems = settings.items.some(item => + const hasTokenItems = settings.items.some(item => ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage'].includes(item.type) ); - + let tokenMetrics: any = null; if (hasTokenItems && data.transcript_path) { tokenMetrics = await getTokenMetrics(data.transcript_path); } - + // 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 settings.items) { switch (item.type) { @@ -156,7 +212,7 @@ async function renderStatusLine(data: StatusJSON) { elements.push({ content: color(`Model: ${data.model.display_name}`), type: 'model' }); } break; - + case 'git-branch': const branch = getGitBranch(); if (branch) { @@ -164,42 +220,52 @@ async function renderStatusLine(data: StatusJSON) { elements.push({ content: color(`⎇ ${branch}`), type: 'git-branch' }); } break; - + + case 'git-changes': + const changes = getGitChanges(); + if (changes !== null) { + const color = (chalk as any)[item.color || 'yellow'] || chalk.yellow; + // Compact format: (+42,-10) + const changeStr = `(+${changes.insertions},-${changes.deletions})`; + elements.push({ content: color(changeStr), type: 'git-changes' }); + } + break; + case 'tokens-input': if (tokenMetrics) { const color = (chalk as any)[item.color || 'yellow'] || chalk.yellow; elements.push({ content: color(`In: ${formatTokens(tokenMetrics.inputTokens)}`), type: 'tokens-input' }); } break; - + case 'tokens-output': if (tokenMetrics) { const color = (chalk as any)[item.color || 'green'] || chalk.green; elements.push({ content: color(`Out: ${formatTokens(tokenMetrics.outputTokens)}`), type: 'tokens-output' }); } break; - + case 'tokens-cached': if (tokenMetrics) { const color = (chalk as any)[item.color || 'blue'] || chalk.blue; elements.push({ content: color(`Cached: ${formatTokens(tokenMetrics.cachedTokens)}`), type: 'tokens-cached' }); } break; - + case 'tokens-total': if (tokenMetrics) { const color = (chalk as any)[item.color || 'white'] || chalk.white; elements.push({ content: color(`Total: ${formatTokens(tokenMetrics.totalTokens)}`), type: 'tokens-total' }); } break; - + case 'context-length': if (tokenMetrics) { const color = (chalk as any)[item.color || 'cyan'] || chalk.cyan; elements.push({ content: color(`Ctx: ${formatTokens(tokenMetrics.contextLength)}`), type: 'context-length' }); } break; - + case 'context-percentage': if (tokenMetrics) { const percentage = Math.min(100, (tokenMetrics.contextLength / 200000) * 100); @@ -207,7 +273,7 @@ async function renderStatusLine(data: StatusJSON) { elements.push({ content: color(`Ctx: ${percentage.toFixed(1)}%`), type: 'context-percentage' }); } 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]; @@ -216,24 +282,24 @@ async function renderStatusLine(data: StatusJSON) { elements.push({ content: sepColor(' | '), type: 'separator' }); } break; - + case 'flex-separator': elements.push({ content: 'FLEX', type: 'flex-separator' }); hasFlexSeparator = true; break; } } - + if (elements.length === 0) return; - + // Build the final status line let statusLine = ''; - + if (hasFlexSeparator) { // Split elements by flex separators const parts: string[][] = [[]]; let currentPart = 0; - + for (const elem of elements) { if (elem.type === 'flex-separator') { currentPart++; @@ -242,20 +308,20 @@ async function renderStatusLine(data: StatusJSON) { parts[currentPart]!.push(elem.content); } } - + // 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++) { @@ -272,7 +338,7 @@ async function renderStatusLine(data: StatusJSON) { } else { // No flex separator, just join all elements statusLine = elements.map(e => e.content).join(''); - + // Pad to full width with spaces const contentLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; const remainingSpace = terminalWidth - contentLength; @@ -280,7 +346,7 @@ async function renderStatusLine(data: StatusJSON) { statusLine = statusLine + ' '.repeat(remainingSpace); } } - + // Ensure we never exceed 80 chars const plainLength = statusLine.replace(/\x1b\[[0-9;]*m/g, '').length; if (plainLength > 80) { diff --git a/src/config.ts b/src/config.ts index 334f356..8862a7a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,7 +8,7 @@ const readFile = fs.promises?.readFile || promisify(fs.readFile); const writeFile = fs.promises?.writeFile || promisify(fs.writeFile); const mkdir = fs.promises?.mkdir || promisify(fs.mkdir); -export type StatusItemType = 'model' | 'git-branch' | 'separator' | 'flex-separator' | +export type StatusItemType = 'model' | 'git-branch' | 'git-changes' | 'separator' | 'flex-separator' | 'tokens-input' | 'tokens-output' | 'tokens-cached' | 'tokens-total' | 'context-length' | 'context-percentage'; export interface StatusItem { @@ -34,6 +34,8 @@ export const DEFAULT_SETTINGS: Settings = { { id: '1', type: 'model', color: 'cyan' }, { id: '2', type: 'separator' }, { id: '3', type: 'git-branch', color: 'magenta' }, + { id: '4', type: 'separator' }, + { id: '5', type: 'git-changes', color: 'yellow' }, ], colors: { model: 'cyan', diff --git a/src/tui.tsx b/src/tui.tsx index e611e64..6cd9e4b 100644 --- a/src/tui.tsx +++ b/src/tui.tsx @@ -25,6 +25,10 @@ const StatusLinePreview: React.FC = ({ items, terminalWi const branchColor = (chalk as any)[item.color || 'magenta'] || chalk.magenta; elements.push(branchColor('⎇ main')); break; + case 'git-changes': + const changesColor = (chalk as any)[item.color || 'yellow'] || chalk.yellow; + elements.push(changesColor('(+42,-10)')); + break; case 'tokens-input': const inputColor = (chalk as any)[item.color || 'yellow'] || chalk.yellow; elements.push(inputColor('In: 15.2k')); @@ -224,7 +228,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => setSelectedIndex(Math.min(items.length - 1, selectedIndex + 1)); } else if (key.leftArrow && items.length > 0) { // Toggle item type backwards - const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', + const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', 'flex-separator', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; const currentItem = items[selectedIndex]; if (currentItem) { @@ -240,7 +244,7 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => } } else if (key.rightArrow && items.length > 0) { // Toggle item type forwards - const types: StatusItemType[] = ['model', 'git-branch', 'separator', 'flex-separator', + const types: StatusItemType[] = ['model', 'git-branch', 'git-changes', 'separator', 'flex-separator', 'tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage']; const currentItem = items[selectedIndex]; if (currentItem) { @@ -293,6 +297,8 @@ const ItemsEditor: React.FC = ({ items, onUpdate, onBack }) => return chalk.cyan('Model'); case 'git-branch': return chalk.magenta('Git Branch'); + case 'git-changes': + return chalk.yellow('Git Changes'); case 'separator': return chalk.dim('Separator |'); case 'flex-separator': @@ -377,6 +383,7 @@ const ColorMenu: React.FC = ({ items, onUpdate, onBack }) => { switch (item.type) { case 'model': return 'Model'; case 'git-branch': return 'Git Branch'; + case 'git-changes': return 'Git Changes'; case 'tokens-input': return 'Tokens Input'; case 'tokens-output': return 'Tokens Output'; case 'tokens-cached': return 'Tokens Cached';