ctxline-claude 1.1.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 +81 -0
  2. package/package.json +1 -1
  3. package/statusline.js +121 -29
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) |
@@ -112,6 +130,55 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
112
130
  > [!NOTE]
113
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+.)
114
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
+
115
182
  ## How it works
116
183
 
117
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.
@@ -122,6 +189,20 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
122
189
 
123
190
  ## FAQ
124
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
+
125
206
  <details>
126
207
  <summary>Does this use the same data as /usage?</summary>
127
208
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.1.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
 
@@ -36,6 +47,13 @@ const FRESH_TTL_MS = 30000; // 30 seconds
36
47
  // visible through transient timeouts/errors instead of disappearing.
37
48
  const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
38
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
+
39
57
  // ANSI color codes
40
58
  const colors = {
41
59
  reset: '\x1b[0m',
@@ -76,30 +94,35 @@ function truncateBranch(name) {
76
94
  return name.length > MAX_BRANCH_LEN ? name.slice(0, MAX_BRANCH_LEN - 1) + '…' : name;
77
95
  }
78
96
 
79
- // Resolve the current git branch by reading .git/HEAD directly (no `git` subprocess
80
- // keeps the render fast and dependency-free). Walks up from `dir` to find the repo,
81
- // 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.
82
122
  function getGitBranch(dir) {
83
123
  try {
84
- let cur = dir;
85
- let gitPath = '';
86
- for (let i = 0; i < 50 && cur; i++) {
87
- const candidate = path.join(cur, '.git');
88
- if (fs.existsSync(candidate)) { gitPath = candidate; break; }
89
- const parent = path.dirname(cur);
90
- if (parent === cur) break; // reached filesystem root
91
- cur = parent;
92
- }
93
- if (!gitPath) return '';
94
-
95
- let gitDir = gitPath;
96
- if (fs.statSync(gitPath).isFile()) {
97
- // Worktree/submodule: ".git" is a file like "gitdir: /path/to/.git/worktrees/x".
98
- const m = fs.readFileSync(gitPath, 'utf8').match(/gitdir:\s*(.+)/);
99
- if (!m) return '';
100
- gitDir = path.resolve(path.dirname(gitPath), m[1].trim());
101
- }
102
-
124
+ const gitDir = resolveGitDir(dir);
125
+ if (!gitDir) return '';
103
126
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
104
127
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
105
128
  if (ref) return truncateBranch(ref[1]);
@@ -110,6 +133,72 @@ function getGitBranch(dir) {
110
133
  }
111
134
  }
112
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
+
113
202
  function getContextBar(remaining) {
114
203
  const effectiveRemaining = remaining ?? 100;
115
204
  const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
@@ -425,18 +514,21 @@ function outputStatus(data, usage) {
425
514
  const model = shortenModel(data?.model?.display_name || 'Claude');
426
515
  const dir = data?.workspace?.current_dir || process.cwd();
427
516
  const dirname = path.basename(dir);
428
- const branch = getGitBranch(dir);
429
- 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 || '');
430
520
  const sessionId = data?.session_id || '';
431
521
  const remaining = data?.context_window?.remaining_percentage;
432
522
 
433
523
  const contextBar = getContextBar(remaining);
434
- const cost = getCostSegment(data);
435
- const task = getCurrentTask(sessionId);
524
+ const cost = DISABLED.has('cost') ? '' : getCostSegment(data);
525
+ const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
436
526
 
437
527
  // line1 = identity + context (always); line2 = usage/cost/task (wrap target).
438
528
  const line1 = [];
439
- line1.push(branch ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}` : dirname);
529
+ line1.push(branch
530
+ ? `${dirname} ${colors.dim}⎇ ${branch}${colors.reset}${sync ? ' ' + sync : ''}`
531
+ : dirname);
440
532
  line1.push(effort ? `${model}${getEffortColor(effort)} · ${effort}${colors.reset}` : model);
441
533
  line1.push(contextBar);
442
534
 
@@ -464,7 +556,7 @@ function outputFallback(usage) {
464
556
  // Order: API-key users get none; otherwise prefer stdin `rate_limits` (no network),
465
557
  // then fall back to the cache+API flow when stdin lacks it (cold start / non-Pro/Max).
466
558
  function resolveUsage(data, callback) {
467
- if (IS_API_KEY) {
559
+ if (IS_API_KEY || DISABLED.has('usage')) {
468
560
  return callback(null);
469
561
  }
470
562
  const fromStdin = buildUsageFromStdin(data);