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
198 changes: 70 additions & 128 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,148 +4,90 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

ccstatusline is a customizable status line formatter for Claude Code CLI that displays model info, git branch, token usage, and other metrics. It functions as both:
1. A piped command processor for Claude Code status lines
2. An interactive TUI configuration tool when run without input
ccstatusline is a customizable status line formatter for Claude Code CLI. It operates in two modes:
1. **Piped mode**: Reads JSON from stdin (Claude Code session data), renders formatted status line to stdout
2. **Interactive TUI mode**: React/Ink configuration UI when run without piped input (TTY detected)

Fork of [sirmalloc/ccstatusline](https://github.com/sirmalloc/ccstatusline). Published to npm as `ccstatusline` (v2.2.7).

## Development Commands

```bash
# Install dependencies
bun install

# Run in interactive TUI mode
bun run start
bun install # Install dependencies
bun run start # Interactive TUI mode
bun run example # Pipe example payload through renderer
bun test # Run all tests (Vitest)
bun test --watch # Watch mode
bun test src/utils/__tests__/git.test.ts # Single test file
bun run lint # TypeScript type check + ESLint (no modifications)
bun run lint:fix # Apply ESLint auto-fixes
bun run build # Bundle to dist/ccstatusline.js (Node 14+)
```

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

# Or use example payload
bun run example
## Architecture

# Build for npm distribution
bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility
### Data Flow (Piped Mode)

# Run tests
bun test
```
stdin JSON → StatusJSONSchema.safeParse() → RenderContext enrichment → Widget rendering → ANSI stdout
```

# Run tests in watch mode
bun test --watch
1. **Input**: `StatusJSON` (Zod-validated) — model info, transcript path, context window, cost, vim mode, rate_limits
2. **Enrichment**: Supplementary data fetched in parallel — token metrics from transcript JSONL, usage API quotas, session duration, speed metrics, skills tracking
3. **RenderContext**: Combined data object passed to all widgets — contains `data` (original JSON), `tokenMetrics`, `speedMetrics`, `usageData`, `sessionDuration`, `blockMetrics`, `skillsMetrics`, `terminalWidth`
4. **Rendering**: Pre-render all widgets once for width calculation, then assemble up to 3 lines with separators/powerline styling, truncate to terminal width

# Lint and type check
bun run lint # Runs TypeScript type checking and ESLint without modifying files
### Entry Point

# Apply ESLint auto-fixes intentionally
bun run lint:fix
```
**src/ccstatusline.ts** — Checks `process.stdin.isTTY` to choose piped vs TUI mode. Cross-platform stdin reading (Bun vs Node.js). Windows UTF-8 code page setup. `--config` flag for custom settings path, `--hook` mode for Claude Code hook integration.

## Architecture
### Widget System

Widgets implement the `Widget` interface (src/types/Widget.ts): `render(item, context, settings)` returns a string or null. Stateless — all state comes from config and render context.

**Registry**: `src/utils/widget-manifest.ts` — Map-based registry mapping type strings to widget instances. `src/utils/widgets.ts` provides lookup, catalog, and search.

**Widget categories**: Core metadata (model, version), Git (branch, changes, insertions, deletions, root-dir, worktree), Tokens (input, output, cached, total), Context (length, percentage, bar), Speed (input, output, total with configurable windows), Session (clock, cost, block-timer, reset-timer, weekly timers, usage), Environment (cwd, terminal-width, free-memory, vim-mode), Integration (session-id, session-name, skills, thinking-effort), User-defined (custom-text, custom-command, link), Layout (separator, flex-separator).

**Widget config** (`WidgetItem`): Each widget instance has `id`, `type`, `color`, `backgroundColor`, `bold`, `rawValue`, `maxWidth`, `merge`, `metadata` (widget-specific flags like `hideNoGit`, `showRemaining`, speed window config).

### Settings

Stored at `~/.config/ccstatusline/settings.json`. Schema version 3 with automatic migrations from v1/v2.

Key settings: `lines` (up to 3 arrays of WidgetItem), `flexMode` (full/full-minus-40/full-until-compact), `colorLevel` (0-3), `powerline` config (separators, themes, caps), global overrides (colors, bold).

### Rendering Pipeline (src/utils/renderer.ts)

- Terminal width detection with three flex modes
- Pre-rendering optimization: all widgets rendered once, reused across lines
- Standard mode: separator chars between widgets with padding
- Powerline mode: Unicode arrow/block characters, background color cycling, start/end caps
- ANSI stripping for width calculation, non-breaking space replacement (prevents VSCode trimming)

### Key Utilities

- **jsonl.ts / jsonl-metrics.ts / jsonl-blocks.ts**: Parse Claude Code transcript JSONL for token counts, speed, block usage. Results cached with hashed filenames
- **usage-fetch.ts / usage-prefetch.ts**: Anthropic usage API client with 180s caching, proxy support (`HTTPS_PROXY`), macOS keychain auth lookup. Only fetches if widgets need it
- **git.ts**: Cached git command execution (branch, status, remote URL, worktree detection)
- **hyperlink.ts**: OSC8 terminal hyperlink support for GitBranch and GitRootDir widgets
- **model-context.ts**: Model ID → context window size mapping. `[1m]` suffix = 1M tokens, otherwise 200k
- **claude-settings.ts**: Read/write Claude Code's settings.json, detect installation, respects `CLAUDE_CONFIG_DIR`
- **config.ts / migrations.ts**: Settings load/save with version migration and backup on corruption

### TUI (src/tui/)

The project has dual runtime compatibility - works with both Bun and Node.js:

### Core Structure
- **src/ccstatusline.ts**: Main entry point that detects piped vs interactive mode
- Piped mode: Parses JSON from stdin and renders formatted status line
- Interactive mode: Launches React/Ink TUI for configuration

### TUI Components (src/tui/)
- **index.tsx**: Main TUI entry point that handles React/Ink initialization
- **App.tsx**: Root component managing navigation and state
- **components/**: Modular UI components for different configuration screens
- MainMenu, LineSelector, ItemsEditor, ColorMenu, GlobalOverridesMenu
- PowerlineSetup, TerminalOptionsMenu, StatusLinePreview

### Utilities (src/utils/)
- **config.ts**: Settings management
- Loads from `~/.config/ccstatusline/settings.json`
- Handles migration from old settings format
- Default configuration if no settings exist
- **renderer.ts**: Core rendering logic for status lines
- Handles terminal width detection and truncation
- Applies colors, padding, and separators
- Manages flex separator expansion
- **powerline.ts**: Powerline font detection and installation
- **claude-settings.ts**: Integration with Claude Code settings.json
- Respects `CLAUDE_CONFIG_DIR` environment variable with fallback to `~/.claude`
- Provides installation command constants (NPM, BUNX, self-managed)
- Detects installation status and manages settings.json updates
- Validates config directory paths with proper error handling
- **colors.ts**: Color definitions and ANSI code mapping
- **model-context.ts**: Model-to-context-window mapping
- Maps model IDs to their context window sizes based on [1m] suffix
- 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%)

### Widgets (src/widgets/)
Custom widgets implementing the Widget interface defined in src/types/Widget.ts:

**Widget Interface:**
All widgets must implement:
- `getDefaultColor()`: Default color for the widget
- `getDescription()`: Description shown in TUI
- `getDisplayName()`: Display name shown in TUI
- `getEditorDisplay()`: How the widget appears in the editor
- `render()`: Core rendering logic that produces the widget output
- `supportsRawValue()`: Whether widget supports raw value mode
- `supportsColors()`: Whether widget supports color customization
- Optional: `renderEditor()`, `getCustomKeybinds()`, `handleEditorAction()`

**Widget Registry Pattern:**
- Located in src/utils/widgets.ts
- Uses a Map-based registry (`widgetRegistry`) that maps widget type strings to widget instances
- `getWidget(type)`: Retrieves widget instance by type
- `getAllWidgetTypes()`: Returns all available widget types
- `isKnownWidgetType()`: Validates if a type is registered

**Available Widgets:**
- Model, Version, OutputStyle - Claude Code metadata display
- GitBranch, GitChanges, GitInsertions, GitDeletions, GitWorktree - Git repository status
- TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics
- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models)
- BlockTimer, SessionClock, SessionCost - Time and cost tracking
- CurrentWorkingDir, TerminalWidth - Environment info
- CustomText, CustomCommand - User-defined widgets

## 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
- **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
- **Powerline mode**: Optional Powerline-style rendering with arrow separators
- **Custom commands**: Execute shell commands and display output in status line
- **Mergeable items**: Items can be merged together with or without padding

## Bun Usage Preferences

Default to using Bun instead of Node.js:
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun install` instead of `npm install`
- Use `bun run <script>` instead of `npm run <script>`
- Use `bun build` with appropriate options for building
- Bun automatically loads .env, so don't use dotenv
React 19 + Ink 6.2.0 interactive configuration. Screens: MainMenu, LineSelector, ItemsEditor (add/remove/reorder widgets), ColorMenu, GlobalOverridesMenu, PowerlineSetup, TerminalOptionsMenu, InstallMenu. Live preview updates as user configures.

## Important Notes

- **ink@6.2.0 patch**: The project uses a patch for ink@6.2.0 to fix backspace key handling on macOS
- Issue: ink treats `\x7f` (backspace on macOS) as delete key instead of backspace
- Fix: Patches `build/parse-keypress.js` to correctly map `\x7f` to backspace
- Applied automatically during `bun install` via `patchedDependencies` in package.json
- Patch file: `patches/ink@6.2.0.patch`
- **Build process**: Two-step build using `bun run build`
1. `bun build`: Bundles src/ccstatusline.ts into dist/ccstatusline.js targeting Node.js 14+
2. `postbuild`: Runs scripts/replace-version.ts to replace `__PACKAGE_VERSION__` placeholder with actual version from package.json
- **ESLint configuration**: Uses flat config format (eslint.config.js) with TypeScript and React plugins
- **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package
- **Type checking and linting**: Run checks via `bun run lint` and use `bun run lint:fix` only when you intentionally want ESLint auto-fixes. Never use `npx eslint`, `eslint`, `tsx`, `bun tsc`, or any other variation directly
- **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)
- Context percentage calculations (src/utils/__tests__/context-percentage.test.ts)
- JSONL transcript 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
- Manual testing also available via piped input and TUI interaction
- **Bun-first**: Use `bun` for all commands. Bun auto-loads .env. Don't use `node`, `npm`, `ts-node`, or call ESLint/tsc directly — always go through `bun run lint` / `bun run lint:fix`
- **ink@6.2.0 patch**: Fixes backspace key handling on macOS (`\x7f` mapped to backspace instead of delete). Applied via `patchedDependencies` in package.json
- **Build**: Two-step — `bun build` bundles to dist/, then `postbuild` replaces `__PACKAGE_VERSION__` placeholder with actual version
- **Lint rules**: Never disable a lint rule via comment. ESLint flat config (eslint.config.js) with TypeScript strict checking, React plugins, import ordering (alphabetic, grouped), single quotes, 4-space indent
- **Testing**: Vitest via Bun. 33+ test files in src/utils/__tests__/ and src/widgets/__tests__/
60 changes: 46 additions & 14 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,24 @@ async function ensureWindowsUtf8CodePage() {
async function renderMultipleLines(data: StatusJSON) {
const settings = await loadSettings();

// Trigger background tip-update pipeline if CC version changed.
// Sync compare only — heavy work runs detached so render is not blocked.
if (data.version && settings.tips.enabled) {
const { readLastVersion } = await import('./utils/tips');
const lastVersion = readLastVersion();
if (!lastVersion || lastVersion.version !== data.version) {
try {
const { spawn } = await import('child_process');
const child = spawn(
process.execPath,
[process.argv[1]!, '--update-tips', data.version],
{ detached: true, stdio: 'ignore', windowsHide: true }
);
child.unref();
} catch { /* swallow — render must not fail */ }
}
}

// Set global chalk level based on settings
chalk.level = settings.colorLevel;

Expand Down Expand Up @@ -230,6 +248,7 @@ interface HookInput {
tool_name?: string;
tool_input?: { skill?: string };
prompt?: string;
version?: string;
}

async function handleHook(): Promise<void> {
Expand All @@ -246,6 +265,7 @@ async function handleHook(): Promise<void> {
return;
}

// Track skill invocations
let skillName = '';
if (data.hook_event_name === 'PreToolUse' && data.tool_name === 'Skill') {
skillName = data.tool_input?.skill ?? '';
Expand All @@ -255,22 +275,20 @@ async function handleHook(): Promise<void> {
skillName = match[1] ?? '';
}
}
if (!skillName) {
console.log('{}');
return;
if (skillName) {
const filePath = getSkillsFilePath(sessionId);
const fs = await import('fs');
const path = await import('path');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const entry = JSON.stringify({
timestamp: new Date().toISOString(),
session_id: sessionId,
skill: skillName,
source: data.hook_event_name
});
fs.appendFileSync(filePath, entry + '\n');
}

const filePath = getSkillsFilePath(sessionId);
const fs = await import('fs');
const path = await import('path');
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const entry = JSON.stringify({
timestamp: new Date().toISOString(),
session_id: sessionId,
skill: skillName,
source: data.hook_event_name
});
fs.appendFileSync(filePath, entry + '\n');
} catch { /* ignore parse errors */ }
console.log('{}');
}
Expand All @@ -279,6 +297,20 @@ async function main() {
// Parse --config before anything else
initConfigPath(parseConfigArg());

// Handle --update-tips mode (background pipeline runner)
const updateIdx = process.argv.indexOf('--update-tips');
if (updateIdx !== -1) {
const version = process.argv[updateIdx + 1];
if (version) {
const settings = await loadSettings();
if (settings.tips.enabled) {
const { checkVersionAndGenerateTips } = await import('./utils/tips');
await checkVersionAndGenerateTips(version, settings);
}
}
return;
}

// Handle --hook mode (cross-platform hook handler for widgets)
if (process.argv.includes('--hook')) {
await handleHook();
Expand Down
32 changes: 32 additions & 0 deletions src/test-helpers/tips.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import type { TipFile } from '../types/TipData';
import { DEFAULT_SETTINGS, type Settings } from '../types/Settings';

export function createTipsTmpDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}

export function tipsSettings(tmpDir: string, overrides: Partial<Settings['tips']> = {}): Settings {
return {
...DEFAULT_SETTINGS,
tips: {
...DEFAULT_SETTINGS.tips,
tipDir: path.join(tmpDir, 'tips'),
...overrides
}
};
}

export function makeTipFile(version: string, previousVersion: string, tips: string[], daysAgo = 0): TipFile {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
return {
version,
previousVersion,
generatedAt: date.toISOString(),
tips
};
}
15 changes: 15 additions & 0 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
StatusLinePreview,
TerminalOptionsMenu,
TerminalWidthMenu,
TipsMenu,
type MainMenuOption
} from './components';

Expand All @@ -71,6 +72,7 @@ type AppScreen = 'main'
| 'terminalWidth'
| 'terminalConfig'
| 'globalOverrides'
| 'tips'
| 'confirm'
| 'powerline'
| 'install';
Expand Down Expand Up @@ -264,6 +266,9 @@ export const App: React.FC = () => {
case 'powerline':
setScreen('powerline');
break;
case 'tips':
setScreen('tips');
break;
case 'install':
handleInstallUninstall();
break;
Expand Down Expand Up @@ -475,6 +480,16 @@ export const App: React.FC = () => {
}}
/>
)}
{screen === 'tips' && (
<TipsMenu
settings={settings}
onUpdate={(updatedSettings) => { setSettings(updatedSettings); }}
onBack={() => {
setMenuSelections(prev => ({ ...prev, main: 5 }));
setScreen('main');
}}
/>
)}
{screen === 'confirm' && confirmDialog && (
<ConfirmDialog
message={confirmDialog.message}
Expand Down
Loading