@alyibrahim/claude-statusline 1.5.3 → 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
@@ -34,6 +34,13 @@ Done. The statusline configures itself automatically. Restart Claude Code to see
34
34
 
35
35
  > If auto-setup didn't run: `claude-statusline setup`
36
36
 
37
+ **Plugin install (Claude Code marketplace):** Search for `claude-statusline` in the Claude Code plugin browser. The plugin auto-configures on first session start. To get the native Rust binary after a plugin install, run:
38
+
39
+ ```bash
40
+ claude-statusline download-binary
41
+ claude-statusline setup
42
+ ```
43
+
37
44
  ---
38
45
 
39
46
  <div align="center">
@@ -57,6 +64,7 @@ Done. The statusline configures itself automatically. Restart Claude Code to see
57
64
  | Git branch | Detected automatically, silently absent if not a git repo |
58
65
  | Session commits | Shows `+N` next to the branch for commits made during the current session |
59
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 |
60
68
 
61
69
  ---
62
70
 
@@ -95,15 +103,17 @@ Data is stored at `~/.claude/statusline-history.jsonl`.
95
103
 
96
104
  ### Claude Code slash commands
97
105
 
98
- History commands are also available directly inside Claude Code as slash commands:
106
+ Commands are also available directly inside Claude Code as slash commands:
99
107
 
100
108
  - `/history`
101
109
  - `/history-enable`
102
110
  - `/history-disable`
103
111
  - `/history-mode <web|terminal>`
112
+ - `/download-binary`
104
113
 
105
114
  Project contributors get these from the repo at `.claude/commands/`.
106
115
  Global npm installs copy them to `~/.claude/commands/` automatically.
116
+ Plugin installs include them via the plugin's `commands/` directory.
107
117
 
108
118
  ### Terminal TUI
109
119
 
@@ -153,12 +163,51 @@ Rows are color-coded by exit reason: green = normal, yellow = interrupted, orang
153
163
 
154
164
  <div align="center">
155
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
+
156
203
  ## Platform support
157
204
 
158
205
  </div>
159
206
 
160
207
  Pre-built Rust binaries are available for **Linux x64/arm64, macOS x64/arm64, and Windows x64**. All Linux distributions (Ubuntu, Arch, Fedora, etc.) are supported. Any other platform falls back to the JS implementation automatically — no action needed.
161
208
 
209
+ Plugin installs skip `npm install`, so the binary is not downloaded automatically. Run `claude-statusline download-binary` once to get it.
210
+
162
211
  See [PLATFORMS.md](PLATFORMS.md) for the full compatibility guide, per-platform install instructions, and feature availability table.
163
212
 
164
213
  ---
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();
@@ -23,7 +26,7 @@ Commands:
23
26
  const TERMINAL_FALLBACK_WARNING = [
24
27
  '[claude-statusline] terminal mode requires the native binary.',
25
28
  'Falling back to web dashboard. To install the binary, run:',
26
- ' npm install -g @alyibrahim/claude-statusline'
29
+ ' claude-statusline download-binary'
27
30
  ].join('\n');
28
31
 
29
32
  function parseHistoryMode(args) {
@@ -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/hooks/hooks.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/statusline.js\" hook start"
9
+ "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/statusline.js\" hook start --marker=${HOOK_MARKER}"
10
10
  }
11
11
  ]
12
12
  }
@@ -17,7 +17,7 @@
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
20
- "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/statusline.js\" hook end"
20
+ "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/statusline.js\" hook end --marker=${HOOK_MARKER}"
21
21
  }
22
22
  ]
23
23
  }
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-autosetup.js\""
9
+ "command": "${CLAUDE_NODE_EXEC} \"${CLAUDE_PLUGIN_ROOT}/scripts/plugin-autosetup.js\" --marker=${HOOK_MARKER}"
10
10
  }
11
11
  ]
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alyibrahim/claude-statusline",
3
- "version": "1.5.3",
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",
@@ -37,7 +37,8 @@
37
37
  "scripts": {
38
38
  "postinstall": "node scripts/postinstall.js",
39
39
  "preuninstall": "node scripts/preuninstall.js",
40
- "test": "jest"
40
+ "test": "jest",
41
+ "check-versions": "node scripts/check-version-alignment.js"
41
42
  },
42
43
  "jest": {
43
44
  "testPathIgnorePatterns": [
@@ -57,11 +58,11 @@
57
58
  "open": "^10.1.0"
58
59
  },
59
60
  "optionalDependencies": {
60
- "@alyibrahim/claude-statusline-linux-x64": "1.5.2",
61
- "@alyibrahim/claude-statusline-linux-arm64": "1.5.2",
62
- "@alyibrahim/claude-statusline-darwin-x64": "1.5.2",
63
- "@alyibrahim/claude-statusline-darwin-arm64": "1.5.2",
64
- "@alyibrahim/claude-statusline-win32-x64": "1.5.2"
61
+ "@alyibrahim/claude-statusline-linux-x64": "1.5.4",
62
+ "@alyibrahim/claude-statusline-linux-arm64": "1.5.4",
63
+ "@alyibrahim/claude-statusline-darwin-x64": "1.5.4",
64
+ "@alyibrahim/claude-statusline-darwin-arm64": "1.5.4",
65
+ "@alyibrahim/claude-statusline-win32-x64": "1.5.4"
65
66
  },
66
67
  "devDependencies": {
67
68
  "jest": "^29.0.0"
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ function readJson(filePath) {
8
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
9
+ }
10
+
11
+ function readCargoVersion(cargoTomlPath) {
12
+ const content = fs.readFileSync(cargoTomlPath, 'utf8');
13
+ const match = content.match(/^version\s*=\s*"([^"]+)"/m);
14
+ return match ? match[1] : null;
15
+ }
16
+
17
+ function main() {
18
+ const root = path.resolve(__dirname, '..');
19
+ const packageJsonPath = path.join(root, 'package.json');
20
+ const packageLockPath = path.join(root, 'package-lock.json');
21
+ const cargoTomlPath = path.join(root, 'Cargo.toml');
22
+ const pluginJsonPath = path.join(root, '.claude-plugin', 'plugin.json');
23
+ const marketplaceJsonPath = path.join(root, '.claude-plugin', 'marketplace.json');
24
+
25
+ const rootPkg = readJson(packageJsonPath);
26
+ const lock = readJson(packageLockPath);
27
+ const cargoVersion = readCargoVersion(cargoTomlPath);
28
+ const pluginJson = readJson(pluginJsonPath);
29
+ const marketplaceJson = readJson(marketplaceJsonPath);
30
+ const optionalDeps = rootPkg.optionalDependencies || {};
31
+
32
+ const errors = [];
33
+ const expected = rootPkg.version;
34
+
35
+ if (!expected) {
36
+ errors.push('package.json is missing a version');
37
+ }
38
+
39
+ if (!cargoVersion) {
40
+ errors.push('Cargo.toml is missing a version');
41
+ } else if (cargoVersion !== expected) {
42
+ errors.push(`Cargo.toml version ${cargoVersion} does not match package.json version ${expected}`);
43
+ }
44
+
45
+ if (!pluginJson.version) {
46
+ errors.push('.claude-plugin/plugin.json is missing a version');
47
+ } else if (pluginJson.version !== expected) {
48
+ errors.push(`.claude-plugin/plugin.json version ${pluginJson.version} does not match package.json version ${expected}`);
49
+ }
50
+
51
+ const marketplaceVersion = marketplaceJson.plugins && marketplaceJson.plugins[0] && marketplaceJson.plugins[0].version;
52
+ if (!marketplaceVersion) {
53
+ errors.push('.claude-plugin/marketplace.json is missing plugins[0].version');
54
+ } else if (marketplaceVersion !== expected) {
55
+ errors.push(`.claude-plugin/marketplace.json plugins[0].version ${marketplaceVersion} does not match package.json version ${expected}`);
56
+ }
57
+
58
+ for (const [name, version] of Object.entries(optionalDeps)) {
59
+ const lockRootVersion = lock.packages && lock.packages[''] && lock.packages[''].optionalDependencies
60
+ ? lock.packages[''].optionalDependencies[name]
61
+ : undefined;
62
+ if (lockRootVersion !== version) {
63
+ errors.push(`package-lock.json root optionalDependencies[${name}] is ${lockRootVersion}, expected ${version}`);
64
+ }
65
+ // node_modules entries are only resolved once the package exists on npm.
66
+ // Pre-publish they contain only { optional: true } — skip the version check in that case.
67
+ const lockPkg = lock.packages && lock.packages[`node_modules/${name}`];
68
+ if (lockPkg && lockPkg.version !== undefined && lockPkg.version !== version) {
69
+ errors.push(`package-lock.json node_modules entry for ${name} is ${lockPkg.version}, expected ${version}`);
70
+ }
71
+ }
72
+
73
+ if (errors.length > 0) {
74
+ console.error('[check-versions] Failed version alignment checks:');
75
+ for (const err of errors) {
76
+ console.error(`- ${err}`);
77
+ }
78
+ process.exit(1);
79
+ }
80
+
81
+ console.log(`[check-versions] OK (version ${expected})`);
82
+ }
83
+
84
+ main();
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
+ };
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const open = require('open');
6
+ const { normalizeProjectSlug } = require('./slug-utils');
6
7
 
7
8
  const JSONL_PATH = path.join(
8
9
  process.env.HOME || process.env.USERPROFILE || os.homedir(),
@@ -65,7 +66,7 @@ function handleHookEnd() {
65
66
 
66
67
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
67
68
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
68
- const slug = projectDir.replace(/[/\\]/g, '-');
69
+ const slug = normalizeProjectSlug(projectDir);
69
70
  const projectsDir = path.join(home, '.claude', 'projects', slug);
70
71
 
71
72
  // Read session stats from the most recently modified JSONL in the project dir
package/scripts/setup.js CHANGED
@@ -5,6 +5,7 @@ const config = require('./config');
5
5
  const { getSettingsPath, atomicWrite } = config;
6
6
 
7
7
  const UNSAFE_CHARS = /["`$!()\\]/;
8
+ const HOOK_MARKER = 'claude-statusline-owned-v1';
8
9
 
9
10
  function buildNodeExecCommand() {
10
11
  return `"${process.execPath}"`;
@@ -108,6 +109,7 @@ function updateHooks(settings, enable, { nodeExecCommand = buildNodeExecCommand(
108
109
  const resolved = resolveHooksFromFile(f, {
109
110
  CLAUDE_PLUGIN_ROOT: escapedRoot,
110
111
  CLAUDE_NODE_EXEC: escapedNodeExec,
112
+ HOOK_MARKER,
111
113
  });
112
114
  for (const entries of Object.values(resolved)) {
113
115
  for (const entry of entries) {
@@ -122,6 +124,7 @@ function updateHooks(settings, enable, { nodeExecCommand = buildNodeExecCommand(
122
124
  const resolvedHooks = resolveHooksFromFile(f, {
123
125
  CLAUDE_PLUGIN_ROOT: escapedRoot,
124
126
  CLAUDE_NODE_EXEC: escapedNodeExec,
127
+ HOOK_MARKER,
125
128
  });
126
129
 
127
130
  for (const [event, entries] of Object.entries(resolvedHooks)) {
@@ -133,11 +136,21 @@ function updateHooks(settings, enable, { nodeExecCommand = buildNodeExecCommand(
133
136
  const hasOwnedMarker = /claude-statusline|CLAUDE_PLUGIN_ROOT/i.test(cmd);
134
137
  return hasScript && hasOwnedMarker;
135
138
  };
139
+ const hasOwnedMarker = cmd => cmd && cmd.includes(`--marker=${HOOK_MARKER}`);
140
+ const isLegacyStatuslineHook = cmd => {
141
+ if (!cmd) return false;
142
+ const isHookSuffix = cmd.endsWith(' hook start') || cmd.endsWith(' hook end');
143
+ if (!isHookSuffix) return false;
144
+ // Keep backward compatibility with older commands while avoiding broad suffix-only matches.
145
+ return /(?:^|\s)(?:statusline|claude-statusline)(?:\s|$)/i.test(cmd);
146
+ };
136
147
  const isOurs = inner => inner.command && (
137
- // Suffix match — catches hooks written by older package versions
138
- inner.command.endsWith(' hook start') || inner.command.endsWith(' hook end') ||
148
+ // Marker match — canonical ownership check for new installs.
149
+ hasOwnedMarker(inner.command) ||
139
150
  // Exact match — catches current hooks including plugin-setup entries
140
151
  ourCommands.has(inner.command) ||
152
+ // Backward-compatible statusline suffix match for older package versions.
153
+ isLegacyStatuslineHook(inner.command) ||
141
154
  // Legacy autosetup fallback — catches prior install roots
142
155
  isLegacyAutosetup(inner.command)
143
156
  );
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ function normalizeProjectSlug(projectPath) {
4
+ return String(projectPath || '').replace(/[/\\]/g, '-');
5
+ }
6
+
7
+ module.exports = { normalizeProjectSlug };
package/statusline.js CHANGED
@@ -6,6 +6,136 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
8
  const { execSync, execFileSync } = require('child_process');
9
+ const { normalizeProjectSlug } = require('./scripts/slug-utils');
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
+ }
9
139
 
10
140
  const cmd = process.argv[2];
11
141
  if (cmd === 'history') {
@@ -17,12 +147,20 @@ if (cmd === 'history') {
17
147
  } else if (cmd === 'hook') {
18
148
  const hookcmd = process.argv[3];
19
149
  if (hookcmd === 'start') {
150
+ emitRealtimeEvent('session_start', {});
20
151
  require('./scripts/history').handleHookStart();
21
152
  return;
22
153
  } else if (hookcmd === 'end') {
154
+ emitRealtimeEvent('session_end', {});
23
155
  require('./scripts/history').handleHookEnd();
24
156
  return;
25
157
  }
158
+ } else if (cmd === 'realtime') {
159
+ const subcmd = process.argv[3];
160
+ if (subcmd === 'shutdown') {
161
+ emitRealtimeEvent('shutdown', {});
162
+ return;
163
+ }
26
164
  }
27
165
 
28
166
  // Reads cumulative token totals from the session JSONL file, using a byte-offset
@@ -30,7 +168,7 @@ if (cmd === 'history') {
30
168
  // Returns { totalIn, totalOut } or null on any error.
31
169
  function readSessionTokens(claudeDir, session, absDir) {
32
170
  if (!session) return null;
33
- const slug = absDir.replace(/\//g, '-');
171
+ const slug = normalizeProjectSlug(absDir);
34
172
  const jsonlPath = path.join(claudeDir, 'projects', slug, `${session}.jsonl`);
35
173
  const cachePath = path.join(claudeDir, `statusline-tokcache-${session}.json`);
36
174
  try {
@@ -88,6 +226,7 @@ process.stdin.on('end', () => {
88
226
  try {
89
227
  const sanitize = s => String(s).replace(/\x1b\[[0-9;]*[mGKHFABCDJ]/g, '');
90
228
  const data = JSON.parse(input);
229
+ emitRealtimeEvent('state_update', data);
91
230
  const model = sanitize(data.model?.display_name || 'Claude');
92
231
  const dir = data.workspace?.current_dir || process.cwd();
93
232
  const session = data.session_id || '';
@@ -229,13 +368,14 @@ process.stdin.on('end', () => {
229
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`)
230
369
  : n >= 1000 ? `${(n / 1000).toFixed(1)}k`
231
370
  : String(n);
232
- 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`;
233
372
  }
234
373
 
235
374
  const usageContent = [u7d, u5h].filter(Boolean).join(' ');
236
- const line2 = (usageContent || costDisplay || tokenDisplay)
237
- ? `\x1b[0m\x1b[32mUsage\x1b[0m \x1b[2m│\x1b[0m ${[usageContent, costDisplay, tokenDisplay].filter(Boolean).join(' ')}`
238
- : '';
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);
239
379
  // Effort level: read from env var first, then settings.json, then fall back to
240
380
  // model-based default (sonnet-4/opus-4 default to "medium" in Claude Code).
241
381
  const effortLetters = { low: 'L', medium: 'M', high: 'H' };
@@ -261,12 +401,17 @@ process.stdin.on('end', () => {
261
401
 
262
402
  const agentDisplay = activeAgents > 0 ? ` \x1b[0m\x1b[36m↪ ${activeAgents}\x1b[0m` : '';
263
403
  const modelDisplay = `\x1b[0m\x1b[94m${model}\x1b[0m` + effortSuffix + agentDisplay;
264
- const line1 = task
265
- ? `${modelDisplay} \x1b[2m│\x1b[0m \x1b[1m${task}\x1b[0m \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`
266
- : `${modelDisplay} \x1b[2m│\x1b[0m ${dirDisplay}${ctx}`;
267
- const visibleLen = line1.replace(/\x1b\[[0-9;]*m/g, '').length;
268
- const sep = `\x1b[2m${'─'.repeat(visibleLen)}\x1b[0m`;
269
- 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'));
270
415
  } catch (e) {
271
416
  // Silent fail - don't break statusline on parse errors
272
417
  }