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
+1 -1
View File
@@ -204,7 +204,7 @@ On user "approved":
4. **If more phases:** "NEXT STEP: Start NEW conversation and run: `/plan2code-3-implement`"
5. **If final phase:** "NEXT STEP: Start NEW conversation and run: `/plan2code-4-finalize`"
6. Mention `/plan2code-1b-revise-plan` option
7. Suggest: "Optional: run `/plan2code-3b-review` for a post-phase code review -- recommended after key features or milestones."
7. Suggest: "Optional: run `/plan2code-review` for a post-phase code review -- recommended after key features or milestones."
Planny (continuing):
```
+2 -2
View File
@@ -10,7 +10,7 @@ Start all CREATE AGENTS MODE responses with '💡'
╰───╯
```
Analyze this codebase and create `AGENTS.md` to guide future AI coding agents (Claude Code, Codex, Gemini CLI, Devin, etc.).
Analyze this codebase and create `AGENTS.md` to guide future AI coding agents (Claude Code, Codex, Gemini CLI, Devin, Zed, etc.).
## Content
@@ -31,7 +31,7 @@ Prefix the file with:
```
# AGENTS.md
This file provides guidance to AI coding agents like Claude Code (claude.ai/code), Cursor AI, Codex, Gemini CLI, GitHub Copilot, Devin, and other AI coding assistants when working with code in this repository.
This file provides guidance to AI coding agents like Claude Code (claude.ai/code), Cursor AI, Codex, Gemini CLI, GitHub Copilot, Devin, Zed, and other AI coding assistants when working with code in this repository.
```
---
@@ -1,5 +1,5 @@
# Dimension Checklists
> Part of plan2code-3b-review — loaded during Step 3 (Analyze).
> Part of plan2code-review — loaded during Step 3 (Analyze).
Non-obvious check items, anti-patterns, and "don't flag" guidance for each review dimension. Focus on what LLMs commonly miss.
@@ -1,5 +1,5 @@
# False-Positive Catalog
> Part of plan2code-3b-review — loaded before presenting findings in Step 3 (Analyze).
> Part of plan2code-review — loaded before presenting findings in Step 3 (Analyze).
Check every finding against this catalog before presenting it. These patterns represent common categories of false findings that waste reviewer and developer time. Each entry includes the bias, a concrete example, why it's wrong, and how to verify before flagging.
@@ -1,5 +1,5 @@
# Verification Protocol
> Part of plan2code-3b-review — loaded during Step 3 (Analyze).
> Part of plan2code-review — loaded during Step 3 (Analyze).
Apply this protocol to every finding before presenting it. Findings that fail verification are dropped. No exceptions.
+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);
}