Add git insertions / deletions count. Fixed context length calculation.

This commit is contained in:
Matthew Breedlove
2025-08-08 13:47:44 -04:00
parent d7a7a40437
commit 11f1bce6fd
3 changed files with 110 additions and 35 deletions
+98 -32
View File
@@ -44,9 +44,9 @@ async function readStdin(): Promise<string | null> {
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) {
+3 -1
View File
@@ -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',
+9 -2
View File
@@ -25,6 +25,10 @@ const StatusLinePreview: React.FC<StatusLinePreviewProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ItemsEditorProps> = ({ 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<ColorMenuProps> = ({ 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';