v1.15.2 — add Claude CLI status line, Zed support, rename 3b-review to review

- Status line: new src/statusline-claude/ (Planny box-star mascot icon),
  install/uninstall wiring in install.js, Install All (A) + Custom (S)
- Zed agent support across README, AGENTS, docs, init prompt, installer
- Rename /plan2code-3b-review to /plan2code-review (now a standalone
  utility workflow); update file, reference dir, and all cross-references
- install.js: fs.rmSync simplifications, async install, summary cleanup
- Bump to v1.15.2; CHANGELOG entries for v1.15.0/1/2
This commit is contained in:
2026-06-01 16:39:35 -07:00
parent 5e48e98293
commit 25d874d87d
21 changed files with 1029 additions and 136 deletions
+167
View File
@@ -0,0 +1,167 @@
# Plan2Code Status Line (Claude CLI)
A persistent three-line status bar for Claude Code that displays model info, project context, context window usage, usage stats (rate limits or token counts), and git diff stats.
## Example Output
**Pro / Max / Teams** — rate limits segment:
```
╭─╮ Opus 4.6 │ plan2code │ feature/statusline │ +12 -3
│★│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
╰─╯ 3h 5m ($4.62) │ ▰▰▰▰▰▱▱▱▱▱▱▱ 42% │ 5h: 28% · 7d: 61%
```
**Enterprise / Bedrock / Vertex / PAYG** — session token counts (no `rate_limits` in stdin):
```
╭─╮ Sonnet 4.5 │ plan2code │ main │ +12 -3
│★│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
╰─╯ 2m ($0.18) │ ▰▰▱▱▱▱▱▱▱▱▱▱ 18% │ 88k in · 3k out
```
**Line 1:** Planny icon | Model name | Project name | Git branch | Uncommitted changes
**Line 2:** Planny icon | Gray separator
**Line 3:** Planny icon | Session duration + cost | Context window bar | Usage (rate limits or token counts)
## Installation
### Via install.js (Recommended)
```bash
node install.js
# Select A (Install All + dev tools) or C (Custom) → S (Status Line)
```
This automatically:
1. Copies `statusline.js``~/.claude/plan2code-statusline.js`
2. Creates default config at `~/.claude/statusline-config.json` (preserves existing)
3. Registers the status line in `~/.claude/settings.json` (atomic write)
### Manual Setup
1. Copy `statusline.js``~/.claude/plan2code-statusline.js`
2. Copy `statusline-config.json``~/.claude/statusline-config.json`
3. Add to `~/.claude/settings.json`:
```json
{
"statusLine": {
"type": "command",
"command": "node /path/to/home/.claude/plan2code-statusline.js"
}
}
```
## Configuration
Edit `~/.claude/statusline-config.json`:
```json
{
"autocompactBuffer": 33000,
"color": true,
"compact": false,
"items": {
"model": true,
"project": true,
"branch": true,
"contextBar": true,
"planUsage": true,
"linesChanged": true,
"duration": true,
"sessionCost": true
}
}
```
| Option | Default | Description |
|--------|---------|-------------|
| `autocompactBuffer` | `33000` | Autocompact buffer size in tokens. Context bar scales usable window = total buffer. Adjust to match your model's autocompact buffer. |
| `color` | `true` | Enable ANSI color output. Set `false` for monochrome. The `NO_COLOR` environment variable (per [no-color.org](https://no-color.org)) also disables color. |
| `compact` | `false` | Two-line mode — drops the mascot icons and the separator line. Goes from 3 lines to 2, no horizontal truncation. |
| `items.model` | `true` | Show model name (Opus, Sonnet, Haiku) |
| `items.project` | `true` | Show project directory name |
| `items.branch` | `true` | Show current git branch (falls back to short SHA when detached HEAD) |
| `items.contextBar` | `true` | Show context window usage bar with percentage |
| `items.planUsage` | `true` | Show usage info: rate limits (Pro/Max) or token counts (Bedrock/Vertex/PAYG) |
| `items.linesChanged` | `true` | Show uncommitted git diff stats (+added -removed) |
| `items.duration` | `true` | Show session duration |
| `items.sessionCost` | `true` | Append estimated session cost in parens next to duration (e.g. `49m ($4.62)`). Reads `cost.total_cost_usd` from Claude Code stdin — client-side estimate, not billing-exact. Session-scoped only; monthly enterprise totals are not exposed by Claude Code. Hidden when cost is 0 or unavailable. |
Malformed config falls back to defaults silently. Type errors on individual keys also fall back to defaults.
## Platform Support
| Platform | Status | Notes |
|----------|--------|-------|
| Claude Code | Supported | Full feature set via `settings.json` registration |
| Copilot CLI | Planned | Deferred — CLI lacks status line script support as of v0.0.421 |
| Others | Not supported | Codex CLI, Gemini CLI have built-in status, not scriptable |
## Usage Display
The usage segment reads data directly from Claude Code's stdin JSON — no API calls, no auth needed, no background processes.
Two display modes are automatically selected based on the data present in stdin:
| User Type | Condition | Display Format | Data Source |
|-----------|-----------|----------------|-------------|
| Pro/Max/Teams | `rate_limits` present in stdin | `5h: 28% · 7d: 61%` | `rate_limits.five_hour.used_percentage`, `rate_limits.seven_day.used_percentage` |
| Bedrock/Vertex/PAYG | No `rate_limits` in stdin | `12k in · 4k out` | `context_window.total_input_tokens`, `context_window.total_output_tokens` |
### Color Thresholds
- Rate limits: green < 60%, yellow 6079%, red 80%+
- Token counts: neutral/white (no thresholds)
- Context bar: green < 50%, yellow 5069%, red 70%+
## Debugging
The bundled script accepts two diagnostic flags for local testing:
```bash
# Print parsed stdin and merged config to stderr
echo '{"model":{"display_name":"Opus 4.6"}}' | node ~/.claude/plan2code-statusline.js --debug
# Load a fixture file instead of reading from stdin
node ~/.claude/plan2code-statusline.js --fixture ./fixture.json
node ~/.claude/plan2code-statusline.js --fixture ./fixture.json --debug
```
Fixture format = whatever Claude Code sends on stdin (JSON with `model`, `workspace`, `context_window`, `rate_limits`, `cost.total_duration_ms`).
## Troubleshooting
| Issue | Solution |
|-------|----------|
| Garbled ANSI codes in output | Set `"color": false` in config, or export `NO_COLOR=1` |
| Status line not appearing | Verify `~/.claude/settings.json` has `statusLine` key with correct path to `plan2code-statusline.js` |
| Slow execution (>100ms) | Git calls are timeout-bounded to 1.5s each; non-repos short-circuit via `.git` directory check. If still slow, disable `branch` or `linesChanged`. |
| Usage segment not showing | Rate limits require Pro/Max/Teams plan. Token counts require at least one assistant message. Use `--debug` to inspect stdin. |
| Wrong context bar percentage | Adjust `autocompactBuffer` to match your model. Default 33000 tokens. |
| Three lines take too much space | Set `"compact": true` to drop the mascot icons + separator line (3 lines → 2). |
## Architecture
```
src/statusline-claude/
├── statusline.js # Self-contained: config, git, formatters, render
└── statusline-config.json # Default config template
```
Single-file design — `statusline.js` is copied verbatim to `~/.claude/plan2code-statusline.js` on install. No bundler, no runtime dependencies.
## Uninstalling
### Via install.js
```bash
node install.js
# Select U (Uninstall) → confirms removal
```
### Manual Removal
1. Remove `statusLine` key from `~/.claude/settings.json`
2. Delete `~/.claude/plan2code-statusline.js`
3. Optionally delete `~/.claude/statusline-config.json`
@@ -0,0 +1,15 @@
{
"autocompactBuffer": 33000,
"color": true,
"compact": false,
"items": {
"model": true,
"project": true,
"branch": true,
"contextBar": true,
"planUsage": true,
"linesChanged": true,
"duration": true,
"sessionCost": true
}
}
+425
View File
@@ -0,0 +1,425 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
// ============================================================================
// Constants
// ============================================================================
const CONTEXT_BAR_LEN = 12;
const CONTEXT_THRESHOLDS = { green: 50, yellow: 70 };
const RATE_LIMIT_THRESHOLDS = { green: 60, yellow: 80 };
const GIT_TIMEOUT_MS = 1500;
const DEFAULT_WINDOW_SIZE = 200000;
const SEPARATOR_WIDTH = 55;
// ============================================================================
// Config
// ============================================================================
const DEFAULTS = {
autocompactBuffer: 33000,
color: true,
compact: false,
items: {
model: true,
project: true,
branch: true,
contextBar: true,
planUsage: true,
linesChanged: true,
duration: true,
sessionCost: true,
},
};
function loadConfig() {
const configPath = path.join(os.homedir(), '.claude', 'statusline-config.json');
let merged = { ...DEFAULTS, items: { ...DEFAULTS.items } };
try {
const raw = fs.readFileSync(configPath, 'utf8');
const user = JSON.parse(raw);
merged = {
...DEFAULTS,
...user,
items: { ...DEFAULTS.items, ...(user.items || {}) },
};
} catch {
// Fall through to defaults
}
// Type validation with fallback
if (!Number.isInteger(merged.autocompactBuffer) || merged.autocompactBuffer < 0 || merged.autocompactBuffer >= 1_000_000) {
merged.autocompactBuffer = DEFAULTS.autocompactBuffer;
}
if (typeof merged.color !== 'boolean') merged.color = DEFAULTS.color;
if (typeof merged.compact !== 'boolean') merged.compact = DEFAULTS.compact;
for (const key of Object.keys(DEFAULTS.items)) {
if (typeof merged.items[key] !== 'boolean') merged.items[key] = DEFAULTS.items[key];
}
// NO_COLOR convention (https://no-color.org) — env trumps config
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
merged.color = false;
}
return merged;
}
// ============================================================================
// Colors
// ============================================================================
const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
cyan: '\x1b[36m',
white: '\x1b[37m',
gray: '\x1b[90m',
// Plan2Code brand
teal: rgb(190, 192, 200), // Silver — repo
sky: rgb(120, 195, 255), // Light sky blue — branch
muted: rgb(40, 120, 200), // Mid blue — model
label: rgb(130, 135, 150), // Mid gray — field labels (5h, 7d)
// Bar & quota thresholds
barGreen: rgb(50, 220, 100),
barYellow: rgb(220, 180, 50),
barRed: rgb(220, 80, 70),
// Planny mascot
sGreen: rgb(60, 200, 120),
sBlue: rgb(50, 100, 200),
sEye: rgb(255, 255, 255),
};
const SEP = (config) => config.color ? `${C.gray}${C.reset}` : ' │ ';
const DOT = (config) => config.color ? `${C.gray} · ${C.reset}` : ' · ';
// ============================================================================
// Git helpers
// ============================================================================
function gitExec(args, cwd) {
try {
return execFileSync('git', args, {
cwd,
timeout: GIT_TIMEOUT_MS,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
} catch {
return null;
}
}
function isGitRepo(cwd) {
try {
// Walk up parent dirs (bounded) — monorepos open Claude Code in a subdir
// where .git lives at the repo root. .git can be a directory or a file.
let dir = cwd;
for (let i = 0; i < 10; i++) {
if (fs.existsSync(path.join(dir, '.git'))) return true;
const parent = path.dirname(dir);
if (parent === dir) return false;
dir = parent;
}
return false;
} catch {
return false;
}
}
function getGitBranch(stdinData) {
const cwd = stdinData?.workspace?.project_dir || process.cwd();
if (!isGitRepo(cwd)) return '';
const branch = gitExec(['symbolic-ref', '--short', 'HEAD'], cwd);
if (branch) return branch;
// Detached HEAD fallback — show short SHA so branch segment isn't empty
const sha = gitExec(['rev-parse', '--short', 'HEAD'], cwd);
return sha || '';
}
// ============================================================================
// Formatters
// ============================================================================
function getModelName(stdinData) {
const raw = stdinData?.model;
if (!raw) return '';
if (typeof raw === 'object' && raw.display_name) {
return raw.display_name.replace(/ context\)/gi, ')');
}
const model = typeof raw === 'object' ? raw.id : raw;
if (!model || typeof model !== 'string') return '';
const claudeMatch = model.match(/^claude-(\w+)-/);
if (claudeMatch) {
return claudeMatch[1].charAt(0).toUpperCase() + claudeMatch[1].slice(1);
}
const gptMatch = model.match(/^(gpt-[\w.]+)/i);
if (gptMatch) {
return gptMatch[1].toUpperCase();
}
return model;
}
function getProjectName(stdinData) {
const projectDir = stdinData?.workspace?.project_dir;
if (!projectDir || typeof projectDir !== 'string') return '';
return path.basename(projectDir);
}
function calculateContextPercent(stdinData, config) {
const cw = stdinData?.context_window;
if (!cw) return null;
const windowSize = cw.context_window_size || DEFAULT_WINDOW_SIZE;
const buffer = config?.autocompactBuffer ?? DEFAULTS.autocompactBuffer;
const usableTokens = windowSize - buffer;
if (usableTokens <= 0) return 100;
const rawUsedPct = cw.used_percentage ?? 0;
const rawUsedTokens = (rawUsedPct / 100) * windowSize;
return Math.round(Math.min(100, (rawUsedTokens / usableTokens) * 100));
}
function formatContextBar(percent, config) {
if (percent == null) return null;
const clamped = Math.max(0, Math.min(100, percent));
const filled = Math.round((clamped / 100) * CONTEXT_BAR_LEN);
const filledStr = '▰'.repeat(filled);
const emptyStr = '▱'.repeat(CONTEXT_BAR_LEN - filled);
if (!config.color) return `${filledStr}${emptyStr} ${clamped}%`;
const barColor = clamped >= CONTEXT_THRESHOLDS.yellow ? C.barRed
: clamped >= CONTEXT_THRESHOLDS.green ? C.barYellow
: C.barGreen;
return `${barColor}${filledStr}${C.gray}${emptyStr}${C.reset} ${barColor}${clamped}%${C.reset}`;
}
function formatLinesChanged(stdinData, config) {
const cwd = stdinData?.workspace?.project_dir || process.cwd();
if (!isGitRepo(cwd)) return null;
const raw = gitExec(['diff', 'HEAD', '--numstat'], cwd);
if (raw == null) return null;
let added = 0, removed = 0;
for (const line of raw.split('\n')) {
if (!line) continue;
const [a, r] = line.split('\t');
if (a !== '-') added += parseInt(a, 10) || 0;
if (r !== '-') removed += parseInt(r, 10) || 0;
}
if (added === 0 && removed === 0) return null;
if (!config.color) return `+${added} -${removed}`;
return `${C.green}+${added}${C.reset} ${C.red}-${removed}${C.reset}`;
}
function formatSessionCost(stdinData) {
const usd = stdinData?.cost?.total_cost_usd;
if (usd == null || isNaN(usd) || usd <= 0) return null;
if (usd < 0.01) return '<$0.01';
if (usd < 100) return `$${usd.toFixed(2)}`;
return `$${Math.round(usd)}`;
}
function formatDuration(stdinData, config) {
const ms = stdinData?.cost?.total_duration_ms;
if (!ms) return null;
const totalSec = Math.round(ms / 1000);
let text;
if (totalSec < 60) text = `${totalSec}s`;
else {
const hours = Math.floor(totalSec / 3600);
const mins = Math.floor((totalSec % 3600) / 60);
text = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
// Append session cost in parens when enabled: "49m ($4.62)"
if (config.items.sessionCost) {
const cost = formatSessionCost(stdinData);
if (cost) {
text = config.color
? `${C.label}${text} (${cost})${C.reset}`
: `${text} (${cost})`;
return text;
}
}
return config.color ? `${C.label}${text}${C.reset}` : text;
}
function colorize(text, percent, thresholds, config) {
if (!config.color || percent == null) return text;
let code;
if (percent >= thresholds.yellow) code = C.barRed;
else if (percent >= thresholds.green) code = C.barYellow;
else code = C.barGreen;
return `${code}${text}${C.reset}`;
}
function formatTokenCount(n) {
if (n == null || isNaN(n) || n < 0) return '0';
if (n < 1000) return String(Math.round(n));
if (n < 999500) return Math.round(n / 1000) + 'k';
return (n / 1_000_000).toFixed(1) + 'M';
}
function formatRateLimits(stdinData, config) {
if (!config.items.planUsage) return null;
const fiveHour = stdinData?.rate_limits?.five_hour?.used_percentage;
const sevenDay = stdinData?.rate_limits?.seven_day?.used_percentage;
if (fiveHour == null && sevenDay == null) return null;
const parts = [];
if (fiveHour != null) {
const pct = Math.round(fiveHour);
const val = colorize(`${pct}%`, pct, RATE_LIMIT_THRESHOLDS, config);
parts.push(config.color ? `${C.label}5h:${C.reset} ${val}` : `5h: ${pct}%`);
}
if (sevenDay != null) {
const pct = Math.round(sevenDay);
const val = colorize(`${pct}%`, pct, RATE_LIMIT_THRESHOLDS, config);
parts.push(config.color ? `${C.label}7d:${C.reset} ${val}` : `7d: ${pct}%`);
}
return parts.join(DOT(config));
}
function formatTokenUsage(stdinData, config) {
if (!config.items.planUsage) return null;
const inp = stdinData?.context_window?.total_input_tokens;
const out = stdinData?.context_window?.total_output_tokens;
if ((!inp || inp === 0) && (!out || out === 0)) return null;
const inStr = formatTokenCount(inp || 0);
const outStr = formatTokenCount(out || 0);
if (!config.color) return `${inStr} in${DOT(config)}${outStr} out`;
return `${C.white}${inStr}${C.reset} ${C.label}in${C.reset}${DOT(config)}${C.white}${outStr}${C.reset} ${C.label}out${C.reset}`;
}
function formatLine1(stdinData, config) {
const segments = [];
if (config.items.model) {
const model = getModelName(stdinData);
if (model) segments.push(config.color ? `${C.muted}${model}${C.reset}` : model);
}
if (config.items.project) {
const project = getProjectName(stdinData);
if (project) segments.push(config.color ? `${C.bold}${C.teal}${project}${C.reset}` : project);
}
if (config.items.branch) {
const branch = getGitBranch(stdinData);
if (branch) segments.push(config.color ? `${C.sky}${branch}${C.reset}` : branch);
}
if (config.items.linesChanged) {
const lines = formatLinesChanged(stdinData, config);
if (lines) segments.push(lines);
}
return segments.join(SEP(config));
}
function formatLine2(stdinData, config) {
const segments = [];
if (config.items.duration) {
const dur = formatDuration(stdinData, config);
if (dur) segments.push(dur);
}
if (config.items.contextBar) {
const percent = calculateContextPercent(stdinData, config);
const bar = formatContextBar(percent, config);
if (bar) segments.push(bar);
}
if (config.items.planUsage) {
const usage = formatRateLimits(stdinData, config) || formatTokenUsage(stdinData, config);
if (usage) segments.push(usage);
}
return segments.join(SEP(config));
}
// ============================================================================
// Main
// ============================================================================
function loadStdin() {
// --fixture <path> for local diagnostics (bypasses stdin)
const fixtureIdx = process.argv.indexOf('--fixture');
if (fixtureIdx !== -1 && process.argv[fixtureIdx + 1]) {
try {
return JSON.parse(fs.readFileSync(process.argv[fixtureIdx + 1], 'utf8'));
} catch (err) {
process.stderr.write(`[statusline] fixture load failed: ${err.message}\n`);
process.exit(1);
}
}
try {
return JSON.parse(fs.readFileSync(0, 'utf8'));
} catch {
return null;
}
}
function main() {
const debug = process.argv.includes('--debug');
const stdinData = loadStdin();
if (!stdinData) process.exit(0);
const config = loadConfig();
if (debug) {
process.stderr.write('[statusline debug] parsed stdin:\n');
process.stderr.write(JSON.stringify(stdinData, null, 2) + '\n');
process.stderr.write('[statusline debug] merged config:\n');
process.stderr.write(JSON.stringify(config, null, 2) + '\n');
}
const line1 = formatLine1(stdinData, config);
const line2 = formatLine2(stdinData, config);
if (!line1 && !line2) return;
if (config.compact) {
if (line1) process.stdout.write(line1 + '\n');
if (line2) process.stdout.write(line2 + '\n');
return;
}
if (config.color) {
const ico1 = `${C.sGreen}╭─╮${C.reset} `;
const ico2 = `${C.sGreen}${C.sEye}${C.sGreen}${C.reset} `;
const ico3 = `${C.sGreen}╰─╯${C.reset} `;
const pad = `${C.gray}${'┄'.repeat(SEPARATOR_WIDTH)}${C.reset}`;
process.stdout.write(ico1 + line1 + '\n' + ico2 + pad + '\n' + ico3 + line2 + '\n');
} else {
const ico1 = '╭─╮ ';
const ico2 = '│★│ ';
const ico3 = '╰─╯ ';
const pad = '┄'.repeat(SEPARATOR_WIDTH);
process.stdout.write(ico1 + line1 + '\n' + ico2 + pad + '\n' + ico3 + line2 + '\n');
}
}
try {
main();
} catch {
process.exit(0);
}