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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ bun install
# Run in interactive TUI mode
bun run start

# Test with piped input (use [1m] suffix for 1M context models)
echo '{"model":{"id":"claude-sonnet-4-5-20250929[1m]"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts

# Or use example payload
# Test with example payload
bun run example

# Test with session start payload
bun run example:start

# Build for npm distribution
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility

Expand Down Expand Up @@ -73,6 +73,13 @@ The project has dual runtime compatibility - works with both Bun and Node.js:
- Sonnet 4.5 WITH [1m] suffix: 1M tokens (800k usable at 80%) - requires long context beta access
- Sonnet 4.5 WITHOUT [1m] suffix: 200k tokens (160k usable at 80%)
- Legacy models: 200k tokens (160k usable at 80%)
- **input-parsers.ts**: Parses token metrics from status JSON input
- `extractTokenMetricsFromContextWindow()`: Extracts token metrics from the `context_window` object
- `formatDurationMs()`: Formats duration in milliseconds to human-readable string
- **jsonl.ts**: JSONL transcript parsing for token metrics (fallback)
- `getTokenMetrics()`: Gets token metrics from JSONL transcript files
- `getSessionDuration()`: Gets session duration from JSONL transcript files
- `getBlockMetrics()`: Gets 5-hour block metrics from JSONL files

### Widgets (src/widgets/)
Custom widgets implementing the Widget interface defined in src/types/Widget.ts:
Expand Down Expand Up @@ -107,7 +114,7 @@ All widgets must implement:
## Key Implementation Details

- **Cross-platform stdin reading**: Detects Bun vs Node.js environment and uses appropriate stdin API
- **Token metrics**: Parses Claude Code transcript files (JSONL format) to calculate token usage
- **Token metrics**: Extracted from `context_window` object in the status JSON input, with JSONL transcript parsing as fallback
- **Git integration**: Uses child_process.execSync to get current branch and changes
- **Terminal width management**: Three modes for handling width (full, full-minus-40, full-until-compact)
- **Flex separators**: Special separator type that expands to fill available space
Expand Down Expand Up @@ -138,10 +145,11 @@ Default to using Bun instead of Node.js:
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
- **Type checking and linting**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation
- **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem
- **Testing**: Uses Vitest (via Bun) with 6 test files and ~40 test cases covering:
- Model context detection and token calculation (src/utils/__tests__/model-context.test.ts)
- **Testing**: Uses Vitest (via Bun) with test files covering:
- Model context detection (src/utils/__tests__/model-context.test.ts)
- Input parsing and token metrics extraction (src/utils/__tests__/input-parsers.test.ts)
- Context percentage calculations (src/utils/__tests__/context-percentage.test.ts)
- JSONL transcript parsing (src/utils/__tests__/jsonl.test.ts)
- JSONL block metrics parsing (src/utils/__tests__/jsonl.test.ts)
- Widget rendering (src/widgets/__tests__/*.test.ts)
- Run tests with `bun test` or `bun test --watch` for watch mode
- Test configuration: vitest.config.ts
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"build": "rm -rf dist/* ; bun build src/ccstatusline.ts --target=node --outfile=dist/ccstatusline.js --target-version=14",
"postbuild": "bun run scripts/replace-version.ts",
"example": "cat scripts/payload.example.json | bun start",
"example:start": "cat scripts/payload.example-start.json | bun start",
"prepublishOnly": "bun run build",
"lint": "bun tsc --noEmit; eslint . --config eslint.config.js --max-warnings=999999 --fix",
"test": "bun vitest",
Expand Down
28 changes: 28 additions & 0 deletions scripts/payload.example-start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"session_id": "abc123...",
"transcript_path": "/path/to/transcript.jsonl",
"cwd": "/current/working/directory",
"model": { "id": "claude-opus-4-5-20251101", "display_name": "Opus 4.5" },
"workspace": {
"current_dir": "/current/working/directory",
"project_dir": "/current/working/directory"
},
"version": "2.1.19",
"output_style": { "name": "default" },
"cost": {
"total_cost_usd": 0,
"total_duration_ms": 2472,
"total_api_duration_ms": 0,
"total_lines_added": 0,
"total_lines_removed": 0
},
"context_window": {
"total_input_tokens": 0,
"total_output_tokens": 0,
"context_window_size": 200000,
"current_usage": null,
"used_percentage": null,
"remaining_percentage": null
},
"exceeds_200k_tokens": false
}
13 changes: 13 additions & 0 deletions scripts/payload.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,18 @@
"total_api_duration_ms": 2300,
"total_lines_added": 156,
"total_lines_removed": 23
},
"context_window": {
"total_input_tokens": 15234,
"total_output_tokens": 4521,
"context_window_size": 200000,
"used_percentage": 42.5,
"remaining_percentage": 57.5,
"current_usage": {
"input_tokens": 8500,
"output_tokens": 1200,
"cache_creation_input_tokens": 5000,
"cache_read_input_tokens": 2000
}
}
}
26 changes: 22 additions & 4 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
loadSettings,
saveSettings
} from './utils/config';
import {
extractTokenMetricsFromContextWindow,
formatDurationMs
} from './utils/input-parsers';
import {
getBlockMetrics,
getSessionDuration,
Expand Down Expand Up @@ -75,14 +79,28 @@ async function renderMultipleLines(data: StatusJSON) {
// Check if block timer is needed
const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer'));

// Get model ID for context config (handle both string and object formats)
const model = data.model;
const modelId = typeof model === 'string' ? model : model?.id;

let tokenMetrics: TokenMetrics | null = null;
if (hasTokenItems && data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
if (hasTokenItems) {
if (data.context_window) {
tokenMetrics = extractTokenMetricsFromContextWindow(data.context_window, modelId);
} else if (data.transcript_path) {
// Fallback to calculating from transcript
tokenMetrics = await getTokenMetrics(data.transcript_path, modelId);
}
}

let sessionDuration: string | null = null;
if (hasSessionClock && data.transcript_path) {
sessionDuration = await getSessionDuration(data.transcript_path);
if (hasSessionClock) {
if (data.cost?.total_duration_ms !== undefined) {
sessionDuration = formatDurationMs(data.cost.total_duration_ms);
} else if (data.transcript_path) {
// Fallback to calculating from transcript
sessionDuration = await getSessionDuration(data.transcript_path);
}
}

let blockMetrics: BlockMetrics | null = null;
Expand Down
66 changes: 46 additions & 20 deletions src/types/StatusJSON.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,56 @@
import { z } from 'zod';

const modelSchema = z.union([
z.string(),
z.object({
id: z.string().optional(),
display_name: z.string().optional()
})
]);

const workspaceSchema = z.object({
current_dir: z.string().optional(),
project_dir: z.string().optional()
});

const outputStyleSchema = z.object({ name: z.string().optional() });

const costSchema = z.object({
total_cost_usd: z.number().optional(),
total_duration_ms: z.number().optional(),
total_api_duration_ms: z.number().optional(),
total_lines_added: z.number().optional(),
total_lines_removed: z.number().optional()
});

const currentUsageSchema = z.object({
input_tokens: z.number().optional(),
output_tokens: z.number().optional(),
cache_creation_input_tokens: z.number().optional(),
cache_read_input_tokens: z.number().optional()
});

const contextWindowSchema = z.object({
total_input_tokens: z.number().optional(),
total_output_tokens: z.number().optional(),
context_window_size: z.number().optional(),
current_usage: currentUsageSchema.nullish(),
used_percentage: z.number().nullish(),
remaining_percentage: z.number().nullish()
});

export const StatusJSONSchema = z.looseObject({
hook_event_name: z.string().optional(),
session_id: z.string().optional(),
transcript_path: z.string().optional(),
cwd: z.string().optional(),
model: z.union([
z.string(),
z.object({
id: z.string().optional(),
display_name: z.string().optional()
})
]).optional(),
workspace: z.object({
current_dir: z.string().optional(),
project_dir: z.string().optional()
}).optional(),
model: modelSchema.optional(),
workspace: workspaceSchema.optional(),
version: z.string().optional(),
output_style: z.object({ name: z.string().optional() }).optional(),
cost: z.object({
total_cost_usd: z.number().optional(),
total_duration_ms: z.number().optional(),
total_api_duration_ms: z.number().optional(),
total_lines_added: z.number().optional(),
total_lines_removed: z.number().optional()
}).optional()
output_style: outputStyleSchema.optional(),
cost: costSchema.optional(),
context_window: contextWindowSchema.optional()
});

export type StatusJSON = z.infer<typeof StatusJSONSchema>;
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
export type ContextWindow = z.infer<typeof contextWindowSchema>;
3 changes: 3 additions & 0 deletions src/types/TokenMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,7 @@ export interface TokenMetrics {
cachedTokens: number;
totalTokens: number;
contextLength: number;
contextWindowSize: number;
usedPercentage: number;
remainingPercentage: number;
}
50 changes: 20 additions & 30 deletions src/utils/__tests__/context-percentage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,29 @@ import {

import type { RenderContext } from '../../types';
import { calculateContextPercentage } from '../context-percentage';
import { getContextConfig } from '../model-context';

function createTokenMetrics(contextLength: number, modelId?: string) {
const contextWindowSize = getContextConfig(modelId);
const usedPercentage = Math.min(100, (contextLength / contextWindowSize) * 100);
return {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength,
contextWindowSize,
usedPercentage,
remainingPercentage: 100 - usedPercentage
};
}

describe('calculateContextPercentage', () => {
describe('Sonnet 4.5 with 1M context window', () => {
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
tokenMetrics: createTokenMetrics(42000, 'claude-sonnet-4-5-20250929[1m]')
};

const percentage = calculateContextPercentage(context);
Expand All @@ -28,13 +38,7 @@ describe('calculateContextPercentage', () => {
it('should cap at 100% with [1m] suffix', () => {
const context: RenderContext = {
data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 2000000
}
tokenMetrics: createTokenMetrics(2000000, 'claude-sonnet-4-5-20250929[1m]')
};

const percentage = calculateContextPercentage(context);
Expand All @@ -46,13 +50,7 @@ describe('calculateContextPercentage', () => {
it('should calculate percentage using 200k denominator', () => {
const context: RenderContext = {
data: { model: { id: 'claude-3-5-sonnet-20241022' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
tokenMetrics: createTokenMetrics(42000, 'claude-3-5-sonnet-20241022')
};

const percentage = calculateContextPercentage(context);
Expand All @@ -67,15 +65,7 @@ describe('calculateContextPercentage', () => {
});

it('should use default 200k context when model ID is undefined', () => {
const context: RenderContext = {
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const context: RenderContext = { tokenMetrics: createTokenMetrics(42000, undefined) };

const percentage = calculateContextPercentage(context);
expect(percentage).toBe(21.0);
Expand Down
Loading