@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 +50 -1
- package/bin/cli.js +76 -1
- package/hooks/hooks.json +2 -2
- package/hooks/plugin-setup.json +1 -1
- package/package.json +8 -7
- package/scripts/check-version-alignment.js +84 -0
- package/scripts/config.js +42 -3
- package/scripts/history.js +2 -1
- package/scripts/setup.js +15 -2
- package/scripts/slug-utils.js +7 -0
- package/statusline.js +156 -11
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
|
-
|
|
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
|
-
'
|
|
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
|
}
|
package/hooks/plugin-setup.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alyibrahim/claude-statusline",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
61
|
-
"@alyibrahim/claude-statusline-linux-arm64": "1.5.
|
|
62
|
-
"@alyibrahim/claude-statusline-darwin-x64": "1.5.
|
|
63
|
-
"@alyibrahim/claude-statusline-darwin-arm64": "1.5.
|
|
64
|
-
"@alyibrahim/claude-statusline-win32-x64": "1.5.
|
|
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
|
|
6
|
+
function getClaudeConfigDir() {
|
|
7
7
|
const configDir = process.env.CLAUDE_CONFIG_DIR;
|
|
8
|
-
|
|
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 = {
|
|
75
|
+
module.exports = {
|
|
76
|
+
getSettingsPath,
|
|
77
|
+
atomicWrite,
|
|
78
|
+
resolveBinary,
|
|
79
|
+
getClaudeConfigDir,
|
|
80
|
+
getRealtimeTtySlug,
|
|
81
|
+
getRealtimePaths,
|
|
82
|
+
};
|
package/scripts/history.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
138
|
-
|
|
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
|
);
|
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
|
|
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[
|
|
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
|
|
237
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
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
|
}
|