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.
- package/README.md +81 -0
- package/package.json +1 -1
- 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
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
|
|
80
|
-
//
|
|
81
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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);
|