ctxline-claude 1.0.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +84 -0
  2. package/package.json +1 -1
  3. package/statusline.js +161 -39
package/README.md CHANGED
@@ -29,6 +29,15 @@
29
29
 
30
30
  See your **current directory**, **active model**, **context window usage**, and **Claude usage limits** at a glance — including both your **current 5-hour session** and **weekly allowance**.
31
31
 
32
+ ## Contents
33
+
34
+ - [Install](#install)
35
+ - [Uninstall](#uninstall)
36
+ - [What it shows](#what-it-shows)
37
+ - [Configuration](#configuration)
38
+ - [How it works](#how-it-works)
39
+ - [FAQ](#faq)
40
+
32
41
  ## Install
33
42
 
34
43
  ```bash
@@ -67,6 +76,14 @@ chmod +x ~/.claude/hooks/statusline.js
67
76
 
68
77
  </details>
69
78
 
79
+ ## Update
80
+
81
+ ```bash
82
+ npx ctxline-claude@latest
83
+ ```
84
+
85
+ Re-runs the installer with the latest published version. Restart Claude Code or start a new session for the update to take effect.
86
+
70
87
  ## Uninstall
71
88
 
72
89
  ```bash
@@ -99,6 +116,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
99
116
  | Segment | Detail |
100
117
  |---|---|
101
118
  | **Directory** | Current working directory |
119
+ | **Branch** | Active git branch, with `↑N↓M` commits ahead / behind your upstream when it diverges |
102
120
  | **Model** | Active Claude model (Opus / Sonnet / Haiku) |
103
121
  | **Context** | Visual bar of context-window usage |
104
122
  | **Current** | Live 5-hour session limit + reset countdown (subscription users) |
@@ -109,6 +127,58 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
109
127
  > [!NOTE]
110
128
  > Usage bars change color automatically as you approach your limits.
111
129
 
130
+ > [!NOTE]
131
+ > **Responsive.** On a narrow terminal the line wraps to two — directory, model, and context on the first line; usage, cost, and task on the second. Wide terminals stay on a single line. (Auto-sizing needs Claude Code v2.1.153+.)
132
+
133
+ > [!TIP]
134
+ > Don't want every segment? You can hide any of them — see [Configuration](#configuration).
135
+
136
+ ## Configuration
137
+
138
+ The statusline is zero-config by default. To **hide segments you don't want**, set the `CTXLINE_DISABLE` environment variable to a comma-separated list of any of:
139
+
140
+ `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly)
141
+
142
+ Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
143
+
144
+ ### Option A — `settings.json` (recommended)
145
+
146
+ Works on every OS and survives restarts. Add a top-level `env` block to `~/.claude/settings.json` — Claude Code passes it to every command it spawns, including the statusline:
147
+
148
+ ```json
149
+ {
150
+ "env": {
151
+ "CTXLINE_DISABLE": "cost,task"
152
+ },
153
+ "statusLine": {
154
+ "type": "command",
155
+ "command": "node ~/.claude/hooks/statusline.js"
156
+ }
157
+ }
158
+ ```
159
+
160
+ Restart Claude Code (or start a new session) to apply.
161
+
162
+ ### Option B — shell environment
163
+
164
+ Set the variable **before** launching `claude` (the statusline inherits Claude Code's environment):
165
+
166
+ ```bash
167
+ # macOS / Linux
168
+ export CTXLINE_DISABLE="cost,task"
169
+ claude
170
+ ```
171
+
172
+ ```powershell
173
+ # Windows (PowerShell) — this terminal only
174
+ $env:CTXLINE_DISABLE = "cost,task"; claude
175
+
176
+ # Windows — persist for future sessions (then open a NEW terminal)
177
+ setx CTXLINE_DISABLE "cost,task"
178
+ ```
179
+
180
+ To re-enable a segment, remove it from the list (or delete the variable) and restart Claude Code.
181
+
112
182
  ## How it works
113
183
 
114
184
  - **Source** — context comes from Claude Code's session data. Usage bars are read straight from the `rate_limits` field Claude Code pipes in (no network), falling back to `https://api.anthropic.com/api/oauth/usage` (the same `/usage` data — 5-hour and weekly limits) when that field isn't present yet. API-key users skip usage entirely.
@@ -119,6 +189,20 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
119
189
 
120
190
  ## FAQ
121
191
 
192
+ <details>
193
+ <summary>Does it use extra tokens?</summary>
194
+
195
+ No — zero tokens, ever. The statusline is part of Claude Code's UI; its output is drawn in your terminal and is **never sent to the model**. Nothing it shows (context, usage, cost, git status) enters the conversation or counts toward your context window.
196
+
197
+ </details>
198
+
199
+ <details>
200
+ <summary>Does fetching data slow down Claude Code?</summary>
201
+
202
+ No, it's imperceptible. Almost every render reads a small local cache (sub-millisecond) instead of fetching. The usage API only runs as a fallback (and is cached); the git ahead/behind check runs at most once every ~5s and is hard-capped so it can never hang. The statusline runs as its own background command, so it never blocks your typing or Claude's responses — and in a non-git folder the git check doesn't run at all.
203
+
204
+ </details>
205
+
122
206
  <details>
123
207
  <summary>Does this use the same data as /usage?</summary>
124
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.0.0",
3
+ "version": "1.2.1",
4
4
  "description": "A customizable statusline for Claude Code that tracks context usage and session limits",
5
5
  "bin": {
6
6
  "ctxline-claude": "bin/install.js"
package/statusline.js CHANGED
@@ -8,10 +8,21 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const https = require('https');
11
- const { execSync } = require('child_process');
11
+ const { execSync, execFileSync } = require('child_process');
12
12
 
13
13
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
14
 
15
+ // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
16
+ // Recognized: branch, effort, cost, task, usage (H+W). dir/model/context always render.
17
+ // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
+ // usage fetch).
19
+ const DISABLED = new Set(
20
+ (process.env.CTXLINE_DISABLE || '')
21
+ .split(',')
22
+ .map(s => s.trim().toLowerCase())
23
+ .filter(Boolean)
24
+ );
25
+
15
26
  // Shared width (cells) for all progress bars: context, current, weekly.
16
27
  const BAR_WIDTH = 6;
17
28
 
@@ -19,6 +30,14 @@ const BAR_WIDTH = 6;
19
30
  // Tail-truncation keeps the start (ticket IDs like "TAMA5-32796" live there) visible.
20
31
  const MAX_BRANCH_LEN = 24;
21
32
 
33
+ // Separator between segments on a rendered line.
34
+ const SEGMENT_SEP = ' │ ';
35
+
36
+ // Cells reserved at the terminal edge when deciding to wrap to a second line.
37
+ // 0 = use the full COLUMNS; bump it if Claude Code reserves columns and the line
38
+ // truncates a char or two before wrapping.
39
+ const WIDTH_MARGIN = 0;
40
+
22
41
  // Cache configuration
23
42
  const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
24
43
  const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
@@ -28,6 +47,13 @@ const FRESH_TTL_MS = 30000; // 30 seconds
28
47
  // visible through transient timeouts/errors instead of disappearing.
29
48
  const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
30
49
 
50
+ // Git ahead/behind cache (single repo entry, keyed by git dir). Throttles the one
51
+ // `git rev-list` subprocess so a burst of renders in a turn runs it once, not per render.
52
+ const GIT_CACHE_FILE = path.join(CACHE_DIR, 'git-cache.json');
53
+ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burst
54
+ const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
55
+ const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
56
+
31
57
  // ANSI color codes
32
58
  const colors = {
33
59
  reset: '\x1b[0m',
@@ -68,30 +94,35 @@ function truncateBranch(name) {
68
94
  return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
69
95
  }
70
96
 
71
- // Resolve the current git branch by reading .git/HEAD directly (no `git` subprocess
72
- // keeps the render fast and dependency-free). Walks up from `dir` to find the repo,
73
- // handles worktrees (.git as a file) and detached HEAD (short sha). Best-effort: '' on any failure.
97
+ // Resolve the repo's git dir by walking up from `dir` (no `git` subprocess). Handles
98
+ // worktrees/submodules (".git" as a file pointing at the real dir). '' on any failure.
99
+ function resolveGitDir(dir) {
100
+ let cur = dir;
101
+ let gitPath = '';
102
+ for (let i = 0; i < 50 && cur; i++) {
103
+ const candidate = path.join(cur, '.git');
104
+ if (fs.existsSync(candidate)) { gitPath = candidate; break; }
105
+ const parent = path.dirname(cur);
106
+ if (parent === cur) break; // reached filesystem root
107
+ cur = parent;
108
+ }
109
+ if (!gitPath) return '';
110
+
111
+ if (fs.statSync(gitPath).isFile()) {
112
+ // ".git" is a file like "gitdir: /path/to/.git/worktrees/x".
113
+ const m = fs.readFileSync(gitPath, 'utf8').match(/gitdir:\s*(.+)/);
114
+ if (!m) return '';
115
+ return path.resolve(path.dirname(gitPath), m[1].trim());
116
+ }
117
+ return gitPath;
118
+ }
119
+
120
+ // Current git branch, read straight from .git/HEAD (no `git` subprocess — fast,
121
+ // dependency-free). Detached HEAD -> short sha. Best-effort: '' on any failure.
74
122
  function getGitBranch(dir) {
75
123
  try {
76
- let cur = dir;
77
- let gitPath = '';
78
- for (let i = 0; i < 50 && cur; i++) {
79
- const candidate = path.join(cur, '.git');
80
- if (fs.existsSync(candidate)) { gitPath = candidate; break; }
81
- const parent = path.dirname(cur);
82
- if (parent === cur) break; // reached filesystem root
83
- cur = parent;
84
- }
85
- if (!gitPath) return '';
86
-
87
- let gitDir = gitPath;
88
- if (fs.statSync(gitPath).isFile()) {
89
- // Worktree/submodule: ".git" is a file like "gitdir: /path/to/.git/worktrees/x".
90
- const m = fs.readFileSync(gitPath, 'utf8').match(/gitdir:\s*(.+)/);
91
- if (!m) return '';
92
- gitDir = path.resolve(path.dirname(gitPath), m[1].trim());
93
- }
94
-
124
+ const gitDir = resolveGitDir(dir);
125
+ if (!gitDir) return '';
95
126
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
96
127
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
97
128
  if (ref) return truncateBranch(ref[1]);
@@ -102,6 +133,72 @@ function getGitBranch(dir) {
102
133
  }
103
134
  }
104
135
 
136
+ // Read the cached ahead/behind for `gitDir`. Single-entry file: a different repo
137
+ // invalidates it. Returns { age, ahead, behind } or null.
138
+ function readGitCache(gitDir) {
139
+ try {
140
+ const c = JSON.parse(fs.readFileSync(GIT_CACHE_FILE, 'utf8'));
141
+ if (!c || c.gitDir !== gitDir || !Number.isFinite(c.timestamp)) return null;
142
+ if (!Number.isFinite(c.ahead) || !Number.isFinite(c.behind)) return null;
143
+ return { age: Date.now() - c.timestamp, ahead: c.ahead, behind: c.behind };
144
+ } catch (e) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function writeGitCache(gitDir, ahead, behind) {
150
+ try {
151
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
152
+ fs.writeFileSync(GIT_CACHE_FILE, JSON.stringify({ gitDir, timestamp: Date.now(), ahead, behind }), 'utf8');
153
+ } catch (e) {}
154
+ }
155
+
156
+ // Commits ahead/behind the upstream (@{u}), cache-fronted. The single `git` call in the
157
+ // whole script — gated by GIT_FRESH_TTL_MS so a render burst runs it once. No upstream /
158
+ // detached / no git -> the subprocess errors -> null (segment omitted). On a slow/failed
159
+ // call, falls back to the last counts up to GIT_STALE_TTL_MS so they don't flicker.
160
+ function getGitAheadBehind(dir) {
161
+ const gitDir = resolveGitDir(dir);
162
+ if (!gitDir) return null;
163
+
164
+ const cached = readGitCache(gitDir);
165
+ if (cached && cached.age < GIT_FRESH_TTL_MS) {
166
+ return { ahead: cached.ahead, behind: cached.behind };
167
+ }
168
+
169
+ try {
170
+ // execFileSync (no shell): faster cold spawn than execSync and passes `@{u}` literally.
171
+ // `@{u}...HEAD` with --left-right --count prints "<behind>\t<ahead>" (left = upstream).
172
+ const out = execFileSync('git', ['rev-list', '--left-right', '--count', '@{u}...HEAD'], {
173
+ cwd: dir, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore']
174
+ }).trim();
175
+ const parts = out.split(/\s+/);
176
+ const behind = parseInt(parts[0], 10);
177
+ const ahead = parseInt(parts[1], 10);
178
+ if (Number.isFinite(ahead) && Number.isFinite(behind)) {
179
+ writeGitCache(gitDir, ahead, behind);
180
+ return { ahead, behind };
181
+ }
182
+ return null;
183
+ } catch (e) {
184
+ if (cached && cached.age < GIT_STALE_TTL_MS) {
185
+ return { ahead: cached.ahead, behind: cached.behind };
186
+ }
187
+ return null;
188
+ }
189
+ }
190
+
191
+ // "↑N↓M" from ahead/behind counts: ahead green (commits to push), behind red (missing
192
+ // commits). Each part self-resets so it doesn't inherit the dim branch color. Omit a zero
193
+ // side; '' when in sync or null.
194
+ function formatAheadBehind(ab) {
195
+ if (!ab) return '';
196
+ let s = '';
197
+ if (ab.ahead) s += `${colors.green}↑${ab.ahead}${colors.reset}`;
198
+ if (ab.behind) s += `${colors.red}↓${ab.behind}${colors.reset}`;
199
+ return s;
200
+ }
201
+
105
202
  function getContextBar(remaining) {
106
203
  const effectiveRemaining = remaining ?? 100;
107
204
  const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
@@ -392,31 +489,56 @@ function getCurrentTask(sessionId) {
392
489
  return '';
393
490
  }
394
491
 
492
+ // Visible (printable) width of a segment string: strip ANSI color codes, count code points.
493
+ function visibleWidth(str) {
494
+ return [...str.replace(/\x1b\[[0-9;]*m/g, '')].length;
495
+ }
496
+
497
+ // Responsive layout: one line when it fits the terminal, else line1 (identity + context)
498
+ // on top and line2 (usage/cost/task) below. Splits only when COLUMNS is known (Claude Code
499
+ // v2.1.153+) and the single line overflows — unknown width or an empty line2 stays single,
500
+ // so there is no regression on older clients or wide terminals.
501
+ function layout(line1Parts, line2Parts) {
502
+ const single = [...line1Parts, ...line2Parts].join(SEGMENT_SEP);
503
+ if (line2Parts.length === 0) return single;
504
+ const cols = parseInt(process.env.COLUMNS, 10);
505
+ if (Number.isFinite(cols) && cols > 0 && visibleWidth(single) > cols - WIDTH_MARGIN) {
506
+ return line1Parts.join(SEGMENT_SEP) + '\n' + line2Parts.join(SEGMENT_SEP);
507
+ }
508
+ return single;
509
+ }
510
+
395
511
  // Main
396
512
  function outputStatus(data, usage) {
397
513
  try {
398
514
  const model = shortenModel(data?.model?.display_name || 'Claude');
399
515
  const dir = data?.workspace?.current_dir || process.cwd();
400
516
  const dirname = path.basename(dir);
401
- const branch = getGitBranch(dir);
402
- const effort = data?.effort?.level || '';
517
+ const branch = DISABLED.has('branch') ? '' : getGitBranch(dir);
518
+ const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
519
+ const effort = DISABLED.has('effort') ? '' : (data?.effort?.level || '');
403
520
  const sessionId = data?.session_id || '';
404
521
  const remaining = data?.context_window?.remaining_percentage;
405
522
 
406
523
  const contextBar = getContextBar(remaining);
407
- const cost = getCostSegment(data);
408
- const task = getCurrentTask(sessionId);
409
- const parts = [];
410
- parts.push(branch ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}` : dirname);
411
- parts.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
412
- parts.push(contextBar);
413
-
414
- if (usage?.current) parts.push(usage.current);
415
- if (usage?.weekly) parts.push(usage.weekly);
416
-
417
- if (cost) parts.push(cost);
418
- if (task) parts.push(`${colors.dim}${task}${colors.reset}`);
419
- process.stdout.write(parts.join(' \u2502 '));
524
+ const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
525
+ const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
526
+
527
+ // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
528
+ const line1 = [];
529
+ line1.push(branch
530
+ ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}${sync ? ' ' + sync : ''}`
531
+ : dirname);
532
+ line1.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
533
+ line1.push(contextBar);
534
+
535
+ const line2 = [];
536
+ if (usage?.current) line2.push(usage.current);
537
+ if (usage?.weekly) line2.push(usage.weekly);
538
+ if (cost) line2.push(cost);
539
+ if (task) line2.push(`${colors.dim}${task}${colors.reset}`);
540
+
541
+ process.stdout.write(layout(line1, line2));
420
542
  } catch (e) {
421
543
  process.stdout.write('Status unavailable');
422
544
  }
@@ -434,7 +556,7 @@ function outputFallback(usage) {
434
556
  // Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
435
557
  // then fall back to the cache+API flow when stdin lacks it (cold start / non-Pro/Max).
436
558
  function resolveUsage(data, callback) {
437
- if (IS_API_KEY) {
559
+ if (IS_API_KEY || DISABLED.has('usage')) {
438
560
  return callback(null);
439
561
  }
440
562
  const fromStdin = buildUsageFromStdin(data);