@alyibrahim/claude-statusline 1.5.4 → 1.6.0

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 CHANGED
@@ -64,6 +64,7 @@ claude-statusline setup
64
64
  | Git branch | Detected automatically, silently absent if not a git repo |
65
65
  | Session commits | Shows `+N` next to the branch for commits made during the current session |
66
66
  | Directory label | Displays as `~/parent/dir` so you always know which project you're in |
67
+ | Terminal wrapping | Lines wrap to fit terminal width — reads `COLUMNS` or `stdout.columns` automatically |
67
68
 
68
69
  ---
69
70
 
@@ -162,6 +163,43 @@ Rows are color-coded by exit reason: green = normal, yellow = interrupted, orang
162
163
 
163
164
  <div align="center">
164
165
 
166
+ ## Realtime Renderer
167
+
168
+ </div>
169
+
170
+ An optional background process that maintains a persistent Unix socket per terminal, enabling terminal-resize awareness and faster state access. **Disabled by default.**
171
+
172
+ **Enable** by setting the environment variable (add to your shell profile to persist):
173
+
174
+ ```bash
175
+ export CLAUDE_STATUSLINE_REALTIME=1
176
+ ```
177
+
178
+ Accepted values: `1`, `true`, `TRUE`.
179
+
180
+ When enabled, the native binary auto-spawns a renderer process the first time it runs in a terminal session. The renderer listens on a Unix socket and persists state to `~/.claude/statusline-state-{tty}.json`. It exits automatically on `session_end` or when explicitly stopped.
181
+
182
+ **Terminal identification**
183
+
184
+ Each terminal gets its own renderer, identified by a *TTY slug* derived in priority order from:
185
+
186
+ 1. `CLAUDE_STATUSLINE_TTY` — set this explicitly for a stable, human-readable slug
187
+ 2. `TERM_SESSION_ID` — used automatically by terminal emulators that set it
188
+ 3. `pid-{PID}` — fallback, changes on each shell restart
189
+
190
+ **Commands:**
191
+
192
+ ```bash
193
+ claude-statusline realtime-status # Show renderer state and paths for current terminal
194
+ claude-statusline realtime-stop # Request renderer shutdown for current terminal
195
+ ```
196
+
197
+ > The realtime renderer is Unix-only. On Windows, the feature flag is silently ignored.
198
+
199
+ ---
200
+
201
+ <div align="center">
202
+
165
203
  ## Platform support
166
204
 
167
205
  </div>
package/bin/cli.js CHANGED
@@ -6,6 +6,7 @@ const config = require('../scripts/config');
6
6
  const { getSettingsPath } = config;
7
7
  const { spawnSync } = require('child_process');
8
8
  const path = require('path');
9
+ const fs = require('fs');
9
10
 
10
11
  const USAGE = `
11
12
  claude-statusline <command>
@@ -16,6 +17,8 @@ Commands:
16
17
  download-binary Download the native binary for this platform
17
18
  enable-history Enable tracking session analytics to JSONL (default on setup)
18
19
  disable-history Remove history tracking hooks from Claude settings
20
+ realtime-status Show realtime renderer state for current terminal
21
+ realtime-stop Request realtime renderer shutdown for current terminal
19
22
  history Open the session analytics dashboard
20
23
  --mode web|terminal (persist dashboard mode preference)
21
24
  `.trim();
@@ -100,6 +103,72 @@ function runHistory() {
100
103
  process.exit(child.status || 0);
101
104
  }
102
105
 
106
+ function runRealtimeStatus() {
107
+ const paths = config.getRealtimePaths();
108
+ let registry = null;
109
+ let state = null;
110
+
111
+ try {
112
+ if (fs.existsSync(paths.registryPath)) {
113
+ registry = JSON.parse(fs.readFileSync(paths.registryPath, 'utf8'));
114
+ }
115
+ } catch (e) {}
116
+
117
+ try {
118
+ if (fs.existsSync(paths.statePath)) {
119
+ state = JSON.parse(fs.readFileSync(paths.statePath, 'utf8'));
120
+ }
121
+ } catch (e) {}
122
+
123
+ const summary = {
124
+ ttySlug: paths.ttySlug,
125
+ registryPath: paths.registryPath,
126
+ statePath: paths.statePath,
127
+ socketPath: paths.socketPath,
128
+ hasRegistry: !!registry,
129
+ hasState: !!state,
130
+ registry,
131
+ stateEventType: state?.event_type || null,
132
+ stateUpdatedAt: state?.updated_at_ms || null,
133
+ };
134
+
135
+ console.log(JSON.stringify(summary, null, 2));
136
+ process.exit(0);
137
+ }
138
+
139
+ function runRealtimeStop() {
140
+ const net = require('net');
141
+ const paths = config.getRealtimePaths();
142
+ const ts = Date.now();
143
+ const event = {
144
+ version: 1,
145
+ event_type: 'shutdown',
146
+ tty_slug: paths.ttySlug,
147
+ updated_at_ms: ts,
148
+ };
149
+
150
+ try {
151
+ config.atomicWrite(paths.statePath, event);
152
+ } catch (e) {}
153
+
154
+ const done = () => {
155
+ console.log('✓ Realtime shutdown event sent');
156
+ process.exit(0);
157
+ };
158
+
159
+ if (fs.existsSync(paths.socketPath)) {
160
+ const client = net.createConnection(paths.socketPath);
161
+ client.on('connect', () => {
162
+ client.write(JSON.stringify(event) + '\n');
163
+ client.end();
164
+ });
165
+ client.on('close', done);
166
+ client.on('error', done);
167
+ } else {
168
+ done();
169
+ }
170
+ }
171
+
103
172
  const cmd = process.argv[2];
104
173
 
105
174
  if (cmd === 'setup') {
@@ -161,6 +230,12 @@ if (cmd === 'setup') {
161
230
  } else if (cmd === 'history') {
162
231
  runHistory();
163
232
 
233
+ } else if (cmd === 'realtime-status') {
234
+ runRealtimeStatus();
235
+
236
+ } else if (cmd === 'realtime-stop') {
237
+ runRealtimeStop();
238
+
164
239
  } else if (cmd === undefined) {
165
240
  console.log(USAGE);
166
241
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alyibrahim/claude-statusline",
3
- "version": "1.5.4",
3
+ "version": "1.6.0",
4
4
  "description": "Rich statusline for Claude Code — model, context bar, real-time token tracking, git branch, rate limits, and session stats. Rust binary, ~5ms startup.",
5
5
  "keywords": [
6
6
  "claude",
package/scripts/config.js CHANGED
@@ -3,11 +3,43 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
 
6
- function getSettingsPath() {
6
+ function getClaudeConfigDir() {
7
7
  const configDir = process.env.CLAUDE_CONFIG_DIR;
8
- const base = (configDir && configDir.trim())
8
+ return (configDir && configDir.trim())
9
9
  ? configDir
10
10
  : path.join(os.homedir(), '.claude');
11
+ }
12
+
13
+ function sanitizeSlug(s) {
14
+ return String(s || '')
15
+ .replace(/[^a-zA-Z0-9_-]/g, '-')
16
+ .replace(/-+/g, '-')
17
+ .replace(/^-|-$/g, '');
18
+ }
19
+
20
+ function getRealtimeTtySlug() {
21
+ const preferred = [process.env.CLAUDE_STATUSLINE_TTY, process.env.TERM_SESSION_ID];
22
+ for (const raw of preferred) {
23
+ const slug = sanitizeSlug(raw || '');
24
+ if (slug) return slug;
25
+ }
26
+ return sanitizeSlug(`pid-${process.pid}`);
27
+ }
28
+
29
+ function getRealtimePaths() {
30
+ const claudeDir = getClaudeConfigDir();
31
+ const ttySlug = getRealtimeTtySlug();
32
+ return {
33
+ claudeDir,
34
+ ttySlug,
35
+ registryPath: path.join(claudeDir, `statusline-renderer-${ttySlug}.json`),
36
+ statePath: path.join(claudeDir, `statusline-state-${ttySlug}.json`),
37
+ socketPath: path.join(claudeDir, `statusline-rt-${ttySlug}.sock`),
38
+ };
39
+ }
40
+
41
+ function getSettingsPath() {
42
+ const base = getClaudeConfigDir();
11
43
  return path.join(base, 'settings.json');
12
44
  }
13
45
 
@@ -40,4 +72,11 @@ function resolveBinary() {
40
72
  return binaryPath;
41
73
  }
42
74
 
43
- module.exports = { getSettingsPath, atomicWrite, resolveBinary };
75
+ module.exports = {
76
+ getSettingsPath,
77
+ atomicWrite,
78
+ resolveBinary,
79
+ getClaudeConfigDir,
80
+ getRealtimeTtySlug,
81
+ getRealtimePaths,
82
+ };
package/statusline.js CHANGED
@@ -8,6 +8,135 @@ const os = require('os');
8
8
  const { execSync, execFileSync } = require('child_process');
9
9
  const { normalizeProjectSlug } = require('./scripts/slug-utils');
10
10
 
11
+ function realtimeEnabled() {
12
+ const v = process.env.CLAUDE_STATUSLINE_REALTIME;
13
+ return v === '1' || v === 'true' || v === 'TRUE';
14
+ }
15
+
16
+ function realtimeTtySlug() {
17
+ const raw = (process.env.CLAUDE_STATUSLINE_TTY || process.env.TERM_SESSION_ID || `pid-${process.pid}`).trim();
18
+ return raw.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
19
+ }
20
+
21
+ function atomicWrite(filePath, obj) {
22
+ const tmp = `${filePath}.tmp`;
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
+ fs.writeFileSync(tmp, JSON.stringify(obj));
25
+ fs.renameSync(tmp, filePath);
26
+ }
27
+
28
+ function emitRealtimeEvent(eventType, payload) {
29
+ if (!realtimeEnabled()) return;
30
+ try {
31
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
32
+ const ttySlug = realtimeTtySlug();
33
+ const now = Date.now();
34
+ const statePath = path.join(claudeDir, `statusline-state-${ttySlug}.json`);
35
+ const registryPath = path.join(claudeDir, `statusline-renderer-${ttySlug}.json`);
36
+
37
+ atomicWrite(registryPath, {
38
+ version: 1,
39
+ pid: process.pid,
40
+ tty_slug: ttySlug,
41
+ heartbeat_at_ms: now,
42
+ socket_path: path.join(claudeDir, `statusline-rt-${ttySlug}.sock`),
43
+ });
44
+
45
+ atomicWrite(statePath, {
46
+ version: 1,
47
+ event_type: eventType,
48
+ tty_slug: ttySlug,
49
+ updated_at_ms: now,
50
+ payload: payload || {},
51
+ });
52
+ } catch (_) {
53
+ // Silent fail - never break statusline rendering
54
+ }
55
+ }
56
+
57
+ function stripSgr(s) {
58
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '');
59
+ }
60
+
61
+ function visibleLen(s) {
62
+ return [...stripSgr(String(s))].reduce((sum, ch) => sum + (ch.codePointAt(0) > 0xFFFF ? 2 : 1), 0);
63
+ }
64
+
65
+ function terminalColumns() {
66
+ const fromStdout = Number(process.stdout && process.stdout.columns);
67
+ if (Number.isFinite(fromStdout) && fromStdout > 0) return fromStdout;
68
+ const fromEnv = Number(process.env.COLUMNS);
69
+ if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
70
+ return null;
71
+ }
72
+
73
+ function truncateVisible(s, maxVisible) {
74
+ if (!maxVisible || maxVisible <= 0) return '';
75
+ if (visibleLen(s) <= maxVisible) return String(s);
76
+
77
+ const src = String(s);
78
+ let out = '';
79
+ let visible = 0;
80
+ for (let i = 0; i < src.length;) {
81
+ if (src[i] === '\x1b' && src[i + 1] === '[') {
82
+ let j = i + 2;
83
+ while (j < src.length && /[0-9;]/.test(src[j])) j++;
84
+ if (j < src.length && /[mGKHFABCDJ]/.test(src[j])) {
85
+ out += src.slice(i, j + 1);
86
+ i = j + 1;
87
+ continue;
88
+ }
89
+ }
90
+
91
+ const code = src.codePointAt(i);
92
+ const ch = String.fromCodePoint(code);
93
+ const width = code > 0xFFFF ? 2 : 1;
94
+ if (visible + width > maxVisible) break;
95
+ out += ch;
96
+ visible += width;
97
+ i += code > 0xFFFF ? 2 : 1;
98
+ }
99
+ return `${out}…\x1b[0m`;
100
+ }
101
+
102
+ function wrapChunks(chunks, width, sep) {
103
+ if (!width) return [chunks.join(sep)];
104
+ if (width < 8) {
105
+ return chunks.map(c => truncateVisible(c, Math.max(1, width - 1)));
106
+ }
107
+
108
+ const sepLen = visibleLen(sep);
109
+ const lines = [];
110
+ let current = '';
111
+ let currentLen = 0;
112
+
113
+ for (const rawChunk of chunks) {
114
+ const chunk = visibleLen(rawChunk) > width
115
+ ? truncateVisible(rawChunk, Math.max(1, width - 1))
116
+ : rawChunk;
117
+ const chunkLen = visibleLen(chunk);
118
+
119
+ if (!current) {
120
+ current = chunk;
121
+ currentLen = chunkLen;
122
+ continue;
123
+ }
124
+
125
+ const needed = currentLen + sepLen + chunkLen;
126
+ if (needed <= width) {
127
+ current += `${sep}${chunk}`;
128
+ currentLen = needed;
129
+ } else {
130
+ lines.push(current);
131
+ current = chunk;
132
+ currentLen = chunkLen;
133
+ }
134
+ }
135
+
136
+ if (current) lines.push(current);
137
+ return lines;
138
+ }
139
+
11
140
  const cmd = process.argv[2];
12
141
  if (cmd === 'history') {
13
142
  require('./scripts/history').handleHistory().catch(e => {
@@ -18,12 +147,20 @@ if (cmd === 'history') {
18
147
  } else if (cmd === 'hook') {
19
148
  const hookcmd = process.argv[3];
20
149
  if (hookcmd === 'start') {
150
+ emitRealtimeEvent('session_start', {});
21
151
  require('./scripts/history').handleHookStart();
22
152
  return;
23
153
  } else if (hookcmd === 'end') {
154
+ emitRealtimeEvent('session_end', {});
24
155
  require('./scripts/history').handleHookEnd();
25
156
  return;
26
157
  }
158
+ } else if (cmd === 'realtime') {
159
+ const subcmd = process.argv[3];
160
+ if (subcmd === 'shutdown') {
161
+ emitRealtimeEvent('shutdown', {});
162
+ return;
163
+ }
27
164
  }
28
165
 
29
166
  // Reads cumulative token totals from the session JSONL file, using a byte-offset
@@ -89,6 +226,7 @@ process.stdin.on('end', () => {
89
226
  try {
90
227
  const sanitize = s => String(s).replace(/\x1b\[[0-9;]*[mGKHFABCDJ]/g, '');
91
228
  const data = JSON.parse(input);
229
+ emitRealtimeEvent('state_update', data);
92
230
  const model = sanitize(data.model?.display_name || 'Claude');
93
231
  const dir = data.workspace?.current_dir || process.cwd();
94
232
  const session = data.session_id || '';
@@ -230,13 +368,14 @@ process.stdin.on('end', () => {
230
368
  const fmt = n => n >= 1_000_000 ? (n % 1_000_000 === 0 ? `${n / 1_000_000}M` : `${(n / 1_000_000).toFixed(1)}M`)
231
369
  : n >= 1000 ? `${(n / 1000).toFixed(1)}k`
232
370
  : String(n);
233
- tokenDisplay = `\x1b[2m│\x1b[0m \x1b[97m${fmt(totalIn ?? 0)}↓ ${fmt(totalOut ?? 0)}↑\x1b[0m`;
371
+ tokenDisplay = `\x1b[97m${fmt(totalIn ?? 0)}↓ ${fmt(totalOut ?? 0)}↑\x1b[0m`;
234
372
  }
235
373
 
236
374
  const usageContent = [u7d, u5h].filter(Boolean).join(' ');
237
- const line2 = (usageContent || costDisplay || tokenDisplay)
238
- ? `\x1b[0m\x1b[32mUsage\x1b[0m \x1b[2m│\x1b[0m ${[usageContent, costDisplay, tokenDisplay].filter(Boolean).join(' ')}`
239
- : '';
375
+ const line2Chunks = ['\x1b[0m\x1b[32mUsage\x1b[0m'];
376
+ if (usageContent) line2Chunks.push(usageContent);
377
+ if (costDisplay) line2Chunks.push(costDisplay.trimStart());
378
+ if (tokenDisplay) line2Chunks.push(tokenDisplay);
240
379
  // Effort level: read from env var first, then settings.json, then fall back to
241
380
  // model-based default (sonnet-4/opus-4 default to "medium" in Claude Code).
242
381
  const effortLetters = { low: 'L', medium: 'M', high: 'H' };
@@ -262,12 +401,17 @@ process.stdin.on('end', () => {
262
401
 
263
402
  const agentDisplay = activeAgents > 0 ? ` \x1b[0m\x1b[36m↪ ${activeAgents}\x1b[0m` : '';
264
403
  const modelDisplay = `\x1b[0m\x1b[94m${model}\x1b[0m` + effortSuffix + agentDisplay;
265
- const line1 = task
266
- ? `${modelDisplay} \x1b[2m│\x1b[0m \x1b[1m${task}\x1b[0m \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`
267
- : `${modelDisplay} \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`;
268
- const visibleLen = line1.replace(/\x1b\[[0-9;]*m/g, '').length;
269
- const sep = `\x1b[2m${'─'.repeat(visibleLen)}\x1b[0m`;
270
- process.stdout.write(line2 ? `${line1}\n${sep}\n${line2}` : line1);
404
+ const line1Chunks = [modelDisplay];
405
+ if (task) line1Chunks.push(`\x1b[1m${task}\x1b[0m`);
406
+ line1Chunks.push(`${dirDisplay}${ctx}`);
407
+
408
+ const columns = terminalColumns();
409
+ const sepToken = ' \x1b[2m│\x1b[0m ';
410
+ const wrappedLine1 = wrapChunks(line1Chunks, columns, sepToken);
411
+ const sepLen = wrappedLine1.reduce((max, line) => Math.max(max, visibleLen(line)), 0);
412
+ const sep = `\x1b[2m${'─'.repeat(sepLen)}\x1b[0m`;
413
+ const wrappedLine2 = line2Chunks.length > 1 ? wrapChunks(line2Chunks, columns, sepToken) : [];
414
+ process.stdout.write(wrappedLine2.length ? `${wrappedLine1.join('\n')}\n${sep}\n${wrappedLine2.join('\n')}` : wrappedLine1.join('\n'));
271
415
  } catch (e) {
272
416
  // Silent fail - don't break statusline on parse errors
273
417
  }