@alyibrahim/claude-statusline 1.3.1 → 1.4.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 +4 -4
- package/bin/cli.js +40 -4
- package/package.json +69 -59
- package/scripts/history.js +599 -0
- package/scripts/setup.js +61 -1
- package/scripts/uninstall.js +7 -2
- package/statusline.js +15 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/@alyibrahim/claude-statusline)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
**A rich, fast statusline for [Claude Code](https://claude.ai/code)** — shows model, git branch, context usage, rate limits, session cost, and
|
|
7
|
+
**A rich, fast statusline for [Claude Code](https://claude.ai/code)** — shows model, git branch, context usage, rate limits, session cost, and split input/output token counts after every response.
|
|
8
8
|
|
|
9
9
|
Runs as a **compiled Rust binary** (~5ms startup vs ~100ms for Node.js). Zero shell dependencies. One install command.
|
|
10
10
|
|
|
@@ -28,14 +28,14 @@ Done. The statusline configures itself automatically. Restart Claude Code to see
|
|
|
28
28
|
|
|
29
29
|
**Line 1** — Model name · Effort level · Active subagents · Current task · Directory `(branch +commits)` · Context bar
|
|
30
30
|
|
|
31
|
-
**Line 2** — Weekly token usage · 5h usage · Reset countdown *(Pro/Max)* — or — Session cost *(API key)* · Session
|
|
31
|
+
**Line 2** — Weekly token usage · 5h usage · Reset countdown *(Pro/Max)* — or — Session cost *(API key)* · Session tokens `X↓ Y↑`
|
|
32
32
|
|
|
33
33
|
| Feature | Details |
|
|
34
34
|
|---|---|
|
|
35
35
|
| Context bar | Normalized to usable % — accounts for the auto-compact buffer |
|
|
36
36
|
| Rate limits | Shows 5h and weekly usage with color-coded thresholds |
|
|
37
37
|
| Session cost | Displayed only for API key users, hidden for subscribers |
|
|
38
|
-
| Session tokens | Real-time
|
|
38
|
+
| Session tokens | Real-time via JSONL offset caching — split input/output display (`X↓ Y↑`), formatted as `k` or `M` for large counts |
|
|
39
39
|
| Active agents | Counts running subagents from your `~/.claude/todos/` directory |
|
|
40
40
|
| Effort level | Reads `CLAUDE_CODE_EFFORT_LEVEL` env var or `settings.json` |
|
|
41
41
|
| Git branch | Detected automatically, silently absent if not a git repo |
|
|
@@ -70,7 +70,7 @@ npm picks the right one automatically. If your platform isn't listed, the JS fal
|
|
|
70
70
|
| Subscription-aware | Shows usage/resets for Pro/Max, cost for API | Treat everyone as API user |
|
|
71
71
|
| Context bar | Usable % after auto-compact buffer | Raw remaining % |
|
|
72
72
|
| Subagent counter | Counts active agents from todos dir | — |
|
|
73
|
-
| Session tokens | Real-time via JSONL offset cache | Stale stdin snapshot or none |
|
|
73
|
+
| Session tokens | Real-time via JSONL offset cache, split I/O (`X↓ Y↑`) | Stale stdin snapshot or none |
|
|
74
74
|
| Session commits | Tracks git commits made this session | — |
|
|
75
75
|
|
|
76
76
|
---
|
package/bin/cli.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
const { setup } = require('../scripts/setup');
|
|
3
|
+
const { setup, toggleHistory } = require('../scripts/setup');
|
|
4
4
|
const { uninstall } = require('../scripts/uninstall');
|
|
5
|
-
const
|
|
5
|
+
const config = require('../scripts/config');
|
|
6
|
+
const { getSettingsPath } = config;
|
|
7
|
+
const { spawnSync } = require('child_process');
|
|
8
|
+
const path = require('path');
|
|
6
9
|
|
|
7
10
|
const USAGE = `
|
|
8
11
|
claude-statusline <command>
|
|
9
12
|
|
|
10
13
|
Commands:
|
|
11
|
-
setup
|
|
12
|
-
uninstall
|
|
14
|
+
setup Configure ~/.claude/settings.json to use this statusline
|
|
15
|
+
uninstall Remove this statusline from ~/.claude/settings.json
|
|
16
|
+
enable-history Enable tracking session analytics to SQLite (default on setup)
|
|
17
|
+
disable-history Remove history tracking hooks from Claude settings
|
|
18
|
+
history Open the session analytics dashboard
|
|
13
19
|
`.trim();
|
|
14
20
|
|
|
15
21
|
const cmd = process.argv[2];
|
|
@@ -30,6 +36,36 @@ if (cmd === 'setup') {
|
|
|
30
36
|
}
|
|
31
37
|
console.log(`✓ Removed statusline from ${getSettingsPath()}`);
|
|
32
38
|
|
|
39
|
+
} else if (cmd === 'enable-history') {
|
|
40
|
+
const result = toggleHistory(true);
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
console.error('Error:', result.error);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
console.log(`✓ History tracking enabled in ${result.settingsPath}`);
|
|
46
|
+
|
|
47
|
+
} else if (cmd === 'disable-history') {
|
|
48
|
+
const result = toggleHistory(false);
|
|
49
|
+
if (!result.ok) {
|
|
50
|
+
console.error('Error:', result.error);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
console.log(`✓ History tracking disabled from ${result.settingsPath}`);
|
|
54
|
+
|
|
55
|
+
} else if (cmd === 'hook' || cmd === 'history') {
|
|
56
|
+
const binaryPath = config.resolveBinary();
|
|
57
|
+
const scriptPath = path.resolve(__dirname, '../statusline.js');
|
|
58
|
+
|
|
59
|
+
if (binaryPath) {
|
|
60
|
+
// Run Rust binary
|
|
61
|
+
const child = spawnSync(binaryPath, process.argv.slice(2), { stdio: 'inherit' });
|
|
62
|
+
process.exit(child.status || 0);
|
|
63
|
+
} else {
|
|
64
|
+
// Run JS fallback
|
|
65
|
+
const child = spawnSync(process.execPath, [scriptPath, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
66
|
+
process.exit(child.status || 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
} else if (cmd === undefined) {
|
|
34
70
|
console.log(USAGE);
|
|
35
71
|
process.exit(0);
|
package/package.json
CHANGED
|
@@ -1,59 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@alyibrahim/claude-statusline",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"keywords": [
|
|
6
|
-
"claude",
|
|
7
|
-
"claude-code",
|
|
8
|
-
"anthropic",
|
|
9
|
-
"statusline",
|
|
10
|
-
"status-bar",
|
|
11
|
-
"terminal",
|
|
12
|
-
"cli",
|
|
13
|
-
"ai",
|
|
14
|
-
"llm",
|
|
15
|
-
"token-tracking",
|
|
16
|
-
"context-window",
|
|
17
|
-
"rate-limit",
|
|
18
|
-
"git",
|
|
19
|
-
"developer-tools",
|
|
20
|
-
"productivity",
|
|
21
|
-
"rust"
|
|
22
|
-
],
|
|
23
|
-
"homepage": "https://github.com/AlyIbrahim1/claude-statusline#readme",
|
|
24
|
-
"repository": {
|
|
25
|
-
"type": "git",
|
|
26
|
-
"url": "https://github.com/AlyIbrahim1/claude-statusline.git"
|
|
27
|
-
},
|
|
28
|
-
"bugs": {
|
|
29
|
-
"url": "https://github.com/AlyIbrahim1/claude-statusline/issues"
|
|
30
|
-
},
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=16"
|
|
33
|
-
},
|
|
34
|
-
"bin": {
|
|
35
|
-
"claude-statusline": "bin/cli.js"
|
|
36
|
-
},
|
|
37
|
-
"scripts": {
|
|
38
|
-
"postinstall": "node scripts/postinstall.js",
|
|
39
|
-
"preuninstall": "node scripts/preuninstall.js",
|
|
40
|
-
"test": "jest"
|
|
41
|
-
},
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@alyibrahim/claude-statusline",
|
|
3
|
+
"version": "1.4.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"claude",
|
|
7
|
+
"claude-code",
|
|
8
|
+
"anthropic",
|
|
9
|
+
"statusline",
|
|
10
|
+
"status-bar",
|
|
11
|
+
"terminal",
|
|
12
|
+
"cli",
|
|
13
|
+
"ai",
|
|
14
|
+
"llm",
|
|
15
|
+
"token-tracking",
|
|
16
|
+
"context-window",
|
|
17
|
+
"rate-limit",
|
|
18
|
+
"git",
|
|
19
|
+
"developer-tools",
|
|
20
|
+
"productivity",
|
|
21
|
+
"rust"
|
|
22
|
+
],
|
|
23
|
+
"homepage": "https://github.com/AlyIbrahim1/claude-statusline#readme",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/AlyIbrahim1/claude-statusline.git"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/AlyIbrahim1/claude-statusline/issues"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=16"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"claude-statusline": "bin/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"postinstall": "node scripts/postinstall.js",
|
|
39
|
+
"preuninstall": "node scripts/preuninstall.js",
|
|
40
|
+
"test": "jest"
|
|
41
|
+
},
|
|
42
|
+
"jest": {
|
|
43
|
+
"testPathIgnorePatterns": [
|
|
44
|
+
"/node_modules/",
|
|
45
|
+
"/.worktrees/"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"files": [
|
|
49
|
+
"statusline.js",
|
|
50
|
+
"bin/",
|
|
51
|
+
"scripts/",
|
|
52
|
+
"README.md"
|
|
53
|
+
],
|
|
54
|
+
"license": "MIT",
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"open": "^10.1.0"
|
|
57
|
+
},
|
|
58
|
+
"optionalDependencies": {
|
|
59
|
+
"better-sqlite3": "^11.3.0",
|
|
60
|
+
"@alyibrahim/claude-statusline-linux-x64": "1.4.0",
|
|
61
|
+
"@alyibrahim/claude-statusline-linux-arm64": "1.4.0",
|
|
62
|
+
"@alyibrahim/claude-statusline-darwin-x64": "1.4.0",
|
|
63
|
+
"@alyibrahim/claude-statusline-darwin-arm64": "1.4.0",
|
|
64
|
+
"@alyibrahim/claude-statusline-win32-x64": "1.4.0"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"jest": "^29.0.0"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const open = require('open');
|
|
5
|
+
|
|
6
|
+
// Returns undefined if better-sqlite3 is not available
|
|
7
|
+
function getDb() {
|
|
8
|
+
try {
|
|
9
|
+
const Database = require('better-sqlite3');
|
|
10
|
+
const home = process.env.HOME || process.env.USERPROFILE || '.';
|
|
11
|
+
const dbDir = path.join(home, '.claude');
|
|
12
|
+
fs.mkdirSync(dbDir, { recursive: true });
|
|
13
|
+
|
|
14
|
+
const db = new Database(path.join(dbDir, 'statusline-history.db'));
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
session_id TEXT UNIQUE NOT NULL,
|
|
19
|
+
project_dir TEXT NOT NULL,
|
|
20
|
+
project_name TEXT NOT NULL,
|
|
21
|
+
model TEXT NOT NULL,
|
|
22
|
+
start_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
23
|
+
end_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
tokens_in INTEGER DEFAULT 0,
|
|
25
|
+
tokens_out INTEGER DEFAULT 0,
|
|
26
|
+
cost_usd REAL DEFAULT 0.0,
|
|
27
|
+
duration_seconds INTEGER DEFAULT 0,
|
|
28
|
+
exit_reason TEXT
|
|
29
|
+
)
|
|
30
|
+
`);
|
|
31
|
+
return db;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function handleHookStart() {
|
|
38
|
+
const db = getDb();
|
|
39
|
+
if (!db) return; // Silent fallback
|
|
40
|
+
|
|
41
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
42
|
+
const projectName = path.basename(projectDir);
|
|
43
|
+
const tempSessionId = `pending-${projectName}-${Date.now()}`;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const stmt = db.prepare('INSERT INTO sessions (session_id, project_dir, project_name, model, exit_reason) VALUES (?, ?, ?, ?, ?)');
|
|
47
|
+
stmt.run(tempSessionId, projectDir, projectName, 'pending', 'pending');
|
|
48
|
+
} catch (e) {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleHookEnd() {
|
|
52
|
+
let input = '';
|
|
53
|
+
process.stdin.setEncoding('utf8');
|
|
54
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
55
|
+
process.stdin.on('end', () => {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
if (!db) return process.exit(0);
|
|
58
|
+
|
|
59
|
+
let reason = 'unknown';
|
|
60
|
+
try {
|
|
61
|
+
const data = JSON.parse(input);
|
|
62
|
+
reason = data.reason || 'unknown';
|
|
63
|
+
} catch(e) {}
|
|
64
|
+
|
|
65
|
+
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
66
|
+
const home = process.env.HOME || process.env.USERPROFILE || '.';
|
|
67
|
+
const slug = projectDir.replace(/[\/\\]/g, '-');
|
|
68
|
+
const projectsDir = path.join(home, '.claude', 'projects', slug);
|
|
69
|
+
|
|
70
|
+
let newestFile = null;
|
|
71
|
+
let newestTime = 0;
|
|
72
|
+
|
|
73
|
+
if (fs.existsSync(projectsDir)) {
|
|
74
|
+
try {
|
|
75
|
+
const files = fs.readdirSync(projectsDir);
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
if (file.endsWith('.jsonl')) {
|
|
78
|
+
const p = path.join(projectsDir, file);
|
|
79
|
+
const mtime = fs.statSync(p).mtimeMs;
|
|
80
|
+
if (mtime > newestTime) {
|
|
81
|
+
newestTime = mtime;
|
|
82
|
+
newestFile = p;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch(e) {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (newestFile) {
|
|
90
|
+
const sessionId = path.basename(newestFile, '.jsonl');
|
|
91
|
+
let totalIn = 0;
|
|
92
|
+
let totalOut = 0;
|
|
93
|
+
let cost = 0.0;
|
|
94
|
+
let model = '';
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const content = fs.readFileSync(newestFile, 'utf8');
|
|
98
|
+
const lines = content.split('\n');
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
if (!line.trim()) continue;
|
|
101
|
+
try {
|
|
102
|
+
const entry = JSON.parse(line);
|
|
103
|
+
if (entry.type === 'assistant' && entry.message?.usage) {
|
|
104
|
+
const u = entry.message.usage;
|
|
105
|
+
totalIn += (u.input_tokens || 0) + Math.round((u.cache_read_input_tokens || 0) * 0.1) + (u.cache_creation_input_tokens || 0);
|
|
106
|
+
totalOut += (u.output_tokens || 0);
|
|
107
|
+
if (!model) model = entry.message.model || 'Claude';
|
|
108
|
+
} else if (entry.type === 'cost') {
|
|
109
|
+
cost += (entry.cost_usd || 0.0);
|
|
110
|
+
} else if (entry.type === 'message_start' && !model) {
|
|
111
|
+
model = entry.message?.model || 'Claude';
|
|
112
|
+
}
|
|
113
|
+
} catch(e) {}
|
|
114
|
+
}
|
|
115
|
+
} catch(e) {}
|
|
116
|
+
|
|
117
|
+
if (!model) model = 'Claude';
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const stmt = db.prepare(`
|
|
121
|
+
UPDATE sessions
|
|
122
|
+
SET session_id = ?, model = ?, end_time = CURRENT_TIMESTAMP,
|
|
123
|
+
tokens_in = ?, tokens_out = ?, cost_usd = ?, exit_reason = ?,
|
|
124
|
+
duration_seconds = CAST((julianday('now') - julianday(start_time)) * 86400 as integer)
|
|
125
|
+
WHERE id = (SELECT id FROM sessions WHERE project_dir = ? AND exit_reason = 'pending' ORDER BY start_time DESC LIMIT 1)
|
|
126
|
+
`);
|
|
127
|
+
stmt.run(sessionId, model, totalIn, totalOut, cost, reason, projectDir);
|
|
128
|
+
} catch (e) {}
|
|
129
|
+
}
|
|
130
|
+
process.exit(0);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function handleHistory() {
|
|
135
|
+
const db = getDb();
|
|
136
|
+
if (!db) {
|
|
137
|
+
console.error('SQLite is not available. Please install @alyibrahim/claude-statusline with native modules, or ensure better-sqlite3 is successfully installed.');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fmt(n) {
|
|
142
|
+
n = Number(n) || 0;
|
|
143
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
144
|
+
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
|
|
145
|
+
return String(n);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function dur(s) {
|
|
149
|
+
s = Number(s) || 0;
|
|
150
|
+
if (s >= 3600) {
|
|
151
|
+
const h = Math.floor(s / 3600);
|
|
152
|
+
const m = Math.floor((s % 3600) / 60);
|
|
153
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
154
|
+
}
|
|
155
|
+
if (s >= 60) return `${Math.floor(s / 60)}m`;
|
|
156
|
+
return `${s}s`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const rows = db.prepare('SELECT project_name, model, start_time, duration_seconds, tokens_in, tokens_out, cost_usd, exit_reason FROM sessions ORDER BY start_time DESC LIMIT 100').all();
|
|
161
|
+
const projects = db.prepare('SELECT DISTINCT project_name FROM sessions ORDER BY project_name').all();
|
|
162
|
+
|
|
163
|
+
const projectOptions = projects
|
|
164
|
+
.map(p => `<option value="${p.project_name}">${p.project_name}</option>`)
|
|
165
|
+
.join('\n ');
|
|
166
|
+
|
|
167
|
+
let totalCost = 0.0, totalIn = 0, totalOut = 0, sessionCount = 0;
|
|
168
|
+
let rowsHtml = '';
|
|
169
|
+
|
|
170
|
+
for (const s of rows) {
|
|
171
|
+
if (s.exit_reason !== 'pending') {
|
|
172
|
+
totalCost += (s.cost_usd || 0);
|
|
173
|
+
totalIn += (s.tokens_in || 0);
|
|
174
|
+
totalOut += (s.tokens_out || 0);
|
|
175
|
+
sessionCount++;
|
|
176
|
+
}
|
|
177
|
+
const isPending = s.exit_reason === 'pending';
|
|
178
|
+
const badgeClass = { normal: 'reason-badge normal', interrupt: 'reason-badge interrupt', pending: 'reason-badge pending' }[s.exit_reason] ?? 'reason-badge unknown';
|
|
179
|
+
const durCell = isPending ? '\u2014' : dur(s.duration_seconds);
|
|
180
|
+
const tokInCell = isPending ? '\u2014' : fmt(s.tokens_in);
|
|
181
|
+
const tokOutCell = isPending ? '\u2014' : fmt(s.tokens_out);
|
|
182
|
+
const costCell = isPending ? '\u2014' : `$${Number(s.cost_usd).toFixed(4)}`;
|
|
183
|
+
|
|
184
|
+
rowsHtml += `
|
|
185
|
+
<tr data-project="${s.project_name}" data-tok-in="${isPending ? 0 : s.tokens_in}" data-tok-out="${isPending ? 0 : s.tokens_out}" data-cost="${isPending ? 0 : s.cost_usd}" data-pending="${isPending ? 1 : 0}">
|
|
186
|
+
<td><span class="tag">${s.project_name}</span></td>
|
|
187
|
+
<td class="col-model">${s.model}</td>
|
|
188
|
+
<td class="col-ts">${s.start_time}</td>
|
|
189
|
+
<td class="col-dur">${durCell}</td>
|
|
190
|
+
<td class="col-tok">${tokInCell}</td>
|
|
191
|
+
<td class="col-tok">${tokOutCell}</td>
|
|
192
|
+
<td class="col-cost">${costCell}</td>
|
|
193
|
+
<td><span class="${badgeClass}">${s.exit_reason}</span></td>
|
|
194
|
+
</tr>`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const rowCount = rows.length;
|
|
198
|
+
const emptyRow = rowsHtml ? '' : '<tr><td colspan="8" style="text-align:center;padding:48px 20px;color:var(--text-3);font-size:13px;">No sessions recorded yet</td></tr>';
|
|
199
|
+
|
|
200
|
+
const html = `<!DOCTYPE html>
|
|
201
|
+
<html lang="en" data-theme="dark">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="UTF-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
205
|
+
<title>Claude Statusline \u2014 Session History</title>
|
|
206
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
207
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
208
|
+
<link href="https://fonts.googleapis.com/css2?family=Calistoga&family=Plus+Jakarta+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
209
|
+
<style>
|
|
210
|
+
[data-theme="dark"] {
|
|
211
|
+
--bg: #1a1916;
|
|
212
|
+
--bg-header: rgba(26,25,22,0.88);
|
|
213
|
+
--surface: #242220;
|
|
214
|
+
--surface-hover: #2a2825;
|
|
215
|
+
--surface-thead: #1e1c1a;
|
|
216
|
+
--border: #3a3733;
|
|
217
|
+
--border-subtle: #302e2b;
|
|
218
|
+
--accent: #D4673C;
|
|
219
|
+
--accent-mid: rgba(212,103,60,0.14);
|
|
220
|
+
--text: #E8E2DA;
|
|
221
|
+
--text-2: #A09890;
|
|
222
|
+
--text-3: #6B6460;
|
|
223
|
+
--green: #4CAF84;
|
|
224
|
+
--green-bg: rgba(76,175,132,0.15);
|
|
225
|
+
--amber: #D4893A;
|
|
226
|
+
--amber-bg: rgba(212,137,58,0.15);
|
|
227
|
+
--pending: #A99ED4;
|
|
228
|
+
--pending-bg: rgba(169,158,212,0.15);
|
|
229
|
+
--shadow-card: 0 1px 4px rgba(0,0,0,0.3), 0 0 0 1px var(--border);
|
|
230
|
+
--shadow-md: 0 4px 16px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.2);
|
|
231
|
+
--toggle-bg: #3a3733;
|
|
232
|
+
--toggle-knob: #E8E2DA;
|
|
233
|
+
}
|
|
234
|
+
[data-theme="light"] {
|
|
235
|
+
--bg: #FAF9F6;
|
|
236
|
+
--bg-header: rgba(250,249,246,0.88);
|
|
237
|
+
--surface: #FFFFFF;
|
|
238
|
+
--surface-hover: #FDFCFA;
|
|
239
|
+
--surface-thead: #F4F2EE;
|
|
240
|
+
--border: #EAE4DD;
|
|
241
|
+
--border-subtle: #F0EBE5;
|
|
242
|
+
--accent: #C85A2E;
|
|
243
|
+
--accent-mid: rgba(200,90,46,0.10);
|
|
244
|
+
--text: #1C1410;
|
|
245
|
+
--text-2: #6B5D57;
|
|
246
|
+
--text-3: #9E8E87;
|
|
247
|
+
--green: #1F7A50;
|
|
248
|
+
--green-bg: rgba(31,122,80,0.10);
|
|
249
|
+
--amber: #925010;
|
|
250
|
+
--amber-bg: rgba(146,80,16,0.10);
|
|
251
|
+
--pending: #6B5FA8;
|
|
252
|
+
--pending-bg: rgba(107,95,168,0.10);
|
|
253
|
+
--shadow-card: 0 1px 3px rgba(28,20,16,0.07), 0 0 0 1px var(--border);
|
|
254
|
+
--shadow-md: 0 4px 16px rgba(28,20,16,0.10), 0 2px 4px rgba(28,20,16,0.06);
|
|
255
|
+
--toggle-bg: #EAE4DD;
|
|
256
|
+
--toggle-knob: #1C1410;
|
|
257
|
+
}
|
|
258
|
+
:root {
|
|
259
|
+
--radius: 12px;
|
|
260
|
+
--radius-sm: 8px;
|
|
261
|
+
--radius-tag: 6px;
|
|
262
|
+
--font-display:'Calistoga', Georgia, serif;
|
|
263
|
+
--font-body: 'Plus Jakarta Sans', system-ui, sans-serif;
|
|
264
|
+
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
|
|
265
|
+
--ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
|
266
|
+
--header-h: 56px;
|
|
267
|
+
}
|
|
268
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
269
|
+
body {
|
|
270
|
+
font-family: var(--font-body);
|
|
271
|
+
background: var(--bg);
|
|
272
|
+
color: var(--text);
|
|
273
|
+
min-height: 100vh;
|
|
274
|
+
font-size: 14px;
|
|
275
|
+
line-height: 1.6;
|
|
276
|
+
-webkit-font-smoothing: antialiased;
|
|
277
|
+
transition: background 0.25s var(--ease), color 0.25s var(--ease);
|
|
278
|
+
}
|
|
279
|
+
.header {
|
|
280
|
+
position: sticky;
|
|
281
|
+
top: 0;
|
|
282
|
+
z-index: 100;
|
|
283
|
+
height: var(--header-h);
|
|
284
|
+
background: var(--bg-header);
|
|
285
|
+
border-bottom: 1px solid var(--border);
|
|
286
|
+
backdrop-filter: blur(12px);
|
|
287
|
+
-webkit-backdrop-filter: blur(12px);
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
}
|
|
291
|
+
.header-inner {
|
|
292
|
+
width: 100%;
|
|
293
|
+
padding: 0 32px;
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
justify-content: space-between;
|
|
297
|
+
gap: 16px;
|
|
298
|
+
}
|
|
299
|
+
.brand { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
300
|
+
.brand-icon {
|
|
301
|
+
width: 30px; height: 30px; border-radius: 7px; background: var(--accent);
|
|
302
|
+
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
|
303
|
+
transition: background 0.25s var(--ease);
|
|
304
|
+
}
|
|
305
|
+
.brand-icon svg { width: 16px; height: 16px; fill: #fff; }
|
|
306
|
+
.brand-name {
|
|
307
|
+
font-family: var(--font-display); font-size: 17px; color: var(--text);
|
|
308
|
+
letter-spacing: -0.01em; line-height: 1; transition: color 0.25s var(--ease);
|
|
309
|
+
}
|
|
310
|
+
.brand-name span { color: var(--accent); transition: color 0.25s var(--ease); }
|
|
311
|
+
.header-controls { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
|
312
|
+
.filter-wrap { position: relative; }
|
|
313
|
+
.filter-select {
|
|
314
|
+
appearance: none; -webkit-appearance: none;
|
|
315
|
+
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
|
316
|
+
color: var(--text-2); font-family: var(--font-body); font-size: 12px; font-weight: 500;
|
|
317
|
+
padding: 6px 28px 6px 10px; cursor: pointer; outline: none;
|
|
318
|
+
transition: border-color 0.15s, color 0.15s, background 0.25s var(--ease); min-width: 140px;
|
|
319
|
+
}
|
|
320
|
+
.filter-select:hover { border-color: var(--accent); color: var(--text); }
|
|
321
|
+
.filter-select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-mid); }
|
|
322
|
+
.filter-chevron {
|
|
323
|
+
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
|
|
324
|
+
pointer-events: none; color: var(--text-3);
|
|
325
|
+
}
|
|
326
|
+
.gh-link {
|
|
327
|
+
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
|
328
|
+
border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface);
|
|
329
|
+
color: var(--text-2); font-size: 12px; font-weight: 500; text-decoration: none;
|
|
330
|
+
transition: border-color 0.15s, color 0.15s, background 0.25s var(--ease); white-space: nowrap;
|
|
331
|
+
}
|
|
332
|
+
.gh-link:hover { border-color: var(--accent); color: var(--text); }
|
|
333
|
+
.gh-link svg { width: 14px; height: 14px; fill: currentColor; flex-shrink: 0; }
|
|
334
|
+
.theme-toggle {
|
|
335
|
+
width: 40px; height: 22px; border-radius: 11px; background: var(--toggle-bg);
|
|
336
|
+
border: none; cursor: pointer; position: relative; transition: background 0.25s var(--ease); flex-shrink: 0;
|
|
337
|
+
}
|
|
338
|
+
.theme-toggle::after {
|
|
339
|
+
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px;
|
|
340
|
+
border-radius: 50%; background: var(--toggle-knob);
|
|
341
|
+
transition: transform 0.25s var(--ease), background 0.25s var(--ease);
|
|
342
|
+
}
|
|
343
|
+
[data-theme="light"] .theme-toggle::after { transform: translateX(18px); }
|
|
344
|
+
.wrap { max-width: 1160px; margin: 0 auto; padding: 36px 28px 64px; }
|
|
345
|
+
.page-title { text-align: center; margin-bottom: 36px; }
|
|
346
|
+
.page-title h1 {
|
|
347
|
+
font-family: var(--font-display); font-size: 28px; color: var(--text);
|
|
348
|
+
letter-spacing: -0.02em; line-height: 1.1; transition: color 0.25s var(--ease);
|
|
349
|
+
}
|
|
350
|
+
.page-title p {
|
|
351
|
+
font-size: 13px; color: var(--text-3); margin-top: 6px; font-weight: 400;
|
|
352
|
+
transition: color 0.25s var(--ease);
|
|
353
|
+
}
|
|
354
|
+
.section-label {
|
|
355
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
|
|
356
|
+
color: var(--text-3); margin-bottom: 12px; transition: color 0.25s var(--ease);
|
|
357
|
+
}
|
|
358
|
+
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 36px; }
|
|
359
|
+
.card {
|
|
360
|
+
background: var(--surface); box-shadow: var(--shadow-card); border-radius: var(--radius);
|
|
361
|
+
padding: 22px 22px 18px;
|
|
362
|
+
transition: box-shadow 0.2s var(--ease), transform 0.2s var(--ease), background 0.25s var(--ease);
|
|
363
|
+
animation: fadeUp 0.4s var(--ease) both;
|
|
364
|
+
}
|
|
365
|
+
.card:nth-child(1) { animation-delay: 0.05s; }
|
|
366
|
+
.card:nth-child(2) { animation-delay: 0.10s; }
|
|
367
|
+
.card:nth-child(3) { animation-delay: 0.15s; }
|
|
368
|
+
.card:nth-child(4) { animation-delay: 0.20s; }
|
|
369
|
+
@keyframes fadeUp {
|
|
370
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
371
|
+
to { opacity: 1; transform: translateY(0); }
|
|
372
|
+
}
|
|
373
|
+
.card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
|
|
374
|
+
.card-label {
|
|
375
|
+
font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
|
|
376
|
+
color: var(--text-3); margin-bottom: 10px; transition: color 0.25s var(--ease);
|
|
377
|
+
}
|
|
378
|
+
.card-value { font-family: var(--font-mono); font-size: 26px; font-weight: 500; line-height: 1; letter-spacing: -0.02em; }
|
|
379
|
+
.card-value.coral { color: var(--accent); }
|
|
380
|
+
.card-value.amber { color: var(--amber); }
|
|
381
|
+
.card-value.green { color: var(--green); }
|
|
382
|
+
.card-sub { font-size: 12px; color: var(--text-3); margin-top: 8px; font-weight: 400; transition: color 0.25s var(--ease); }
|
|
383
|
+
.table-section { animation: fadeUp 0.4s var(--ease) 0.25s both; }
|
|
384
|
+
.table-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
|
385
|
+
.table-title { font-size: 15px; font-weight: 600; color: var(--text); letter-spacing: -0.01em; transition: color 0.25s var(--ease); }
|
|
386
|
+
.table-count {
|
|
387
|
+
font-size: 12px; color: var(--text-3); background: var(--border-subtle);
|
|
388
|
+
padding: 3px 10px; border-radius: 20px; font-weight: 500;
|
|
389
|
+
transition: background 0.25s var(--ease), color 0.25s var(--ease);
|
|
390
|
+
}
|
|
391
|
+
.table-wrap {
|
|
392
|
+
background: var(--surface); box-shadow: var(--shadow-card); border-radius: var(--radius);
|
|
393
|
+
overflow: hidden; transition: background 0.25s var(--ease);
|
|
394
|
+
}
|
|
395
|
+
.table-scroll { overflow-x: auto; }
|
|
396
|
+
table { width: 100%; border-collapse: collapse; min-width: 860px; }
|
|
397
|
+
thead tr { background: var(--surface-thead); border-bottom: 2px solid var(--border); }
|
|
398
|
+
thead th {
|
|
399
|
+
padding: 12px 16px; text-align: left; font-size: 10px; font-weight: 600;
|
|
400
|
+
letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-3); white-space: nowrap;
|
|
401
|
+
transition: background 0.25s var(--ease), color 0.25s var(--ease);
|
|
402
|
+
}
|
|
403
|
+
thead th:first-child { color: var(--accent); opacity: 0.8; }
|
|
404
|
+
tbody tr { border-bottom: 1px solid var(--border-subtle); transition: background 0.12s var(--ease); }
|
|
405
|
+
tbody tr:last-child { border-bottom: none; }
|
|
406
|
+
tbody tr:nth-child(even) { background: rgba(255,255,255,0.02); }
|
|
407
|
+
[data-theme="light"] tbody tr:nth-child(even) { background: rgba(0,0,0,0.015); }
|
|
408
|
+
tbody tr:hover { background: var(--accent-mid); }
|
|
409
|
+
tbody td { padding: 11px 16px; font-size: 13px; white-space: nowrap; }
|
|
410
|
+
.tag {
|
|
411
|
+
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px;
|
|
412
|
+
border-radius: var(--radius-tag); background: var(--accent-mid); color: var(--accent);
|
|
413
|
+
font-size: 12px; font-weight: 600; letter-spacing: -0.01em; max-width: 160px;
|
|
414
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
415
|
+
transition: background 0.25s var(--ease), color 0.25s var(--ease);
|
|
416
|
+
}
|
|
417
|
+
.tag::before {
|
|
418
|
+
content: ''; width: 5px; height: 5px; border-radius: 50%;
|
|
419
|
+
background: currentColor; opacity: 0.6; flex-shrink: 0;
|
|
420
|
+
}
|
|
421
|
+
.col-model { font-family: var(--font-mono); font-size: 11px; color: var(--text-2); }
|
|
422
|
+
.col-ts { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
|
|
423
|
+
.col-dur { font-family: var(--font-mono); font-size: 12px; color: var(--text); font-weight: 500; }
|
|
424
|
+
.col-tok { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--amber); }
|
|
425
|
+
.col-cost { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--green); }
|
|
426
|
+
.reason-badge { display: inline-block; padding: 2px 8px; border-radius: 20px; font-size: 11px; font-weight: 600; letter-spacing: 0.02em; }
|
|
427
|
+
.reason-badge.normal { background: var(--green-bg); color: var(--green); }
|
|
428
|
+
.reason-badge.interrupt { background: var(--amber-bg); color: var(--amber); }
|
|
429
|
+
.reason-badge.pending { background: var(--pending-bg); color: var(--pending); font-style: italic; }
|
|
430
|
+
.reason-badge.unknown { background: var(--border-subtle); color: var(--text-3); }
|
|
431
|
+
@media (max-width: 840px) {
|
|
432
|
+
.cards { grid-template-columns: repeat(2, 1fr); }
|
|
433
|
+
.wrap { padding: 24px 16px 48px; }
|
|
434
|
+
.header-inner { padding: 0 16px; }
|
|
435
|
+
.gh-link span { display: none; }
|
|
436
|
+
}
|
|
437
|
+
@media (max-width: 600px) {
|
|
438
|
+
.filter-select { min-width: 110px; }
|
|
439
|
+
.brand-name { font-size: 15px; }
|
|
440
|
+
}
|
|
441
|
+
@media (prefers-reduced-motion: reduce) {
|
|
442
|
+
.card, .table-section { animation: none; }
|
|
443
|
+
.card:hover { transform: none; }
|
|
444
|
+
* { transition-duration: 0ms !important; }
|
|
445
|
+
}
|
|
446
|
+
tbody tr.hidden { display: none; }
|
|
447
|
+
</style>
|
|
448
|
+
</head>
|
|
449
|
+
<body>
|
|
450
|
+
|
|
451
|
+
<header class="header">
|
|
452
|
+
<div class="header-inner">
|
|
453
|
+
<div class="brand">
|
|
454
|
+
<div class="brand-icon">
|
|
455
|
+
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
|
456
|
+
<path d="M9 1.5C4.86 1.5 1.5 4.86 1.5 9s3.36 7.5 7.5 7.5 7.5-3.36 7.5-7.5S13.14 1.5 9 1.5zm0 2.5a5 5 0 110 10A5 5 0 019 4zm0 2a3 3 0 100 6 3 3 0 000-6z"/>
|
|
457
|
+
</svg>
|
|
458
|
+
</div>
|
|
459
|
+
<div class="brand-name">claude<span>.</span>statusline</div>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="header-controls">
|
|
462
|
+
<div class="filter-wrap">
|
|
463
|
+
<select class="filter-select" id="projectFilter" aria-label="Filter by project">
|
|
464
|
+
<option value="">All projects</option>
|
|
465
|
+
${projectOptions}
|
|
466
|
+
</select>
|
|
467
|
+
<svg class="filter-chevron" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
468
|
+
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
469
|
+
</svg>
|
|
470
|
+
</div>
|
|
471
|
+
<a class="gh-link" href="https://github.com/alyibrahim/claude-statusline" target="_blank" rel="noopener" aria-label="GitHub repository">
|
|
472
|
+
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
|
473
|
+
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
|
474
|
+
</svg>
|
|
475
|
+
<span>GitHub</span>
|
|
476
|
+
</a>
|
|
477
|
+
<button class="theme-toggle" id="themeToggle" aria-label="Toggle dark/light mode" title="Toggle theme"></button>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</header>
|
|
481
|
+
|
|
482
|
+
<main class="wrap">
|
|
483
|
+
<div class="page-title">
|
|
484
|
+
<h1>Session History</h1>
|
|
485
|
+
<p>Claude Code usage across all projects</p>
|
|
486
|
+
</div>
|
|
487
|
+
<div class="section-label">Overview</div>
|
|
488
|
+
<div class="cards">
|
|
489
|
+
<div class="card">
|
|
490
|
+
<div class="card-label">Sessions</div>
|
|
491
|
+
<div class="card-value coral" id="statSessions">${sessionCount}</div>
|
|
492
|
+
<div class="card-sub">recorded</div>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="card">
|
|
495
|
+
<div class="card-label">Tokens In</div>
|
|
496
|
+
<div class="card-value amber" id="statTokIn">${fmt(totalIn)}</div>
|
|
497
|
+
<div class="card-sub">input tokens</div>
|
|
498
|
+
</div>
|
|
499
|
+
<div class="card">
|
|
500
|
+
<div class="card-label">Tokens Out</div>
|
|
501
|
+
<div class="card-value amber" id="statTokOut">${fmt(totalOut)}</div>
|
|
502
|
+
<div class="card-sub">output tokens</div>
|
|
503
|
+
</div>
|
|
504
|
+
<div class="card">
|
|
505
|
+
<div class="card-label">Total Spend</div>
|
|
506
|
+
<div class="card-value green" id="statCost">$${totalCost.toFixed(2)}</div>
|
|
507
|
+
<div class="card-sub">USD</div>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
<div class="table-section">
|
|
511
|
+
<div class="table-header">
|
|
512
|
+
<div class="table-title">Session Log</div>
|
|
513
|
+
<div class="table-count" id="rowCount">${rowCount} entr${rowCount === 1 ? 'y' : 'ies'}</div>
|
|
514
|
+
</div>
|
|
515
|
+
<div class="table-wrap">
|
|
516
|
+
<div class="table-scroll">
|
|
517
|
+
<table>
|
|
518
|
+
<thead>
|
|
519
|
+
<tr>
|
|
520
|
+
<th>Project</th>
|
|
521
|
+
<th>Model</th>
|
|
522
|
+
<th>Start Time</th>
|
|
523
|
+
<th>Duration</th>
|
|
524
|
+
<th>Tokens In</th>
|
|
525
|
+
<th>Tokens Out</th>
|
|
526
|
+
<th>Cost</th>
|
|
527
|
+
<th>Reason</th>
|
|
528
|
+
</tr>
|
|
529
|
+
</thead>
|
|
530
|
+
<tbody id="tableBody">${rowsHtml}${emptyRow}</tbody>
|
|
531
|
+
</table>
|
|
532
|
+
</div>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
</main>
|
|
536
|
+
|
|
537
|
+
<script>
|
|
538
|
+
const toggle = document.getElementById('themeToggle');
|
|
539
|
+
const html = document.documentElement;
|
|
540
|
+
html.setAttribute('data-theme', localStorage.getItem('theme') || 'dark');
|
|
541
|
+
toggle.addEventListener('click', () => {
|
|
542
|
+
const next = html.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
543
|
+
html.setAttribute('data-theme', next);
|
|
544
|
+
localStorage.setItem('theme', next);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
function fmtTokens(n) {
|
|
548
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
549
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
550
|
+
return String(n);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const filter = document.getElementById('projectFilter');
|
|
554
|
+
const rows = document.querySelectorAll('#tableBody tr');
|
|
555
|
+
const countEl = document.getElementById('rowCount');
|
|
556
|
+
const statSessions = document.getElementById('statSessions');
|
|
557
|
+
const statTokIn = document.getElementById('statTokIn');
|
|
558
|
+
const statTokOut = document.getElementById('statTokOut');
|
|
559
|
+
const statCost = document.getElementById('statCost');
|
|
560
|
+
|
|
561
|
+
function applyFilter() {
|
|
562
|
+
const val = filter.value;
|
|
563
|
+
let sessions = 0, tokIn = 0, tokOut = 0, cost = 0, visible = 0;
|
|
564
|
+
rows.forEach(row => {
|
|
565
|
+
const match = !val || row.dataset.project === val;
|
|
566
|
+
row.classList.toggle('hidden', !match);
|
|
567
|
+
if (match) {
|
|
568
|
+
visible++;
|
|
569
|
+
if (row.dataset.pending !== '1') {
|
|
570
|
+
sessions++;
|
|
571
|
+
tokIn += Number(row.dataset.tokIn) || 0;
|
|
572
|
+
tokOut += Number(row.dataset.tokOut) || 0;
|
|
573
|
+
cost += Number(row.dataset.cost) || 0;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
countEl.textContent = visible + ' entr' + (visible === 1 ? 'y' : 'ies');
|
|
578
|
+
statSessions.textContent = sessions;
|
|
579
|
+
statTokIn.textContent = fmtTokens(tokIn);
|
|
580
|
+
statTokOut.textContent = fmtTokens(tokOut);
|
|
581
|
+
statCost.textContent = '$' + cost.toFixed(2);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
filter.addEventListener('change', applyFilter);
|
|
585
|
+
</script>
|
|
586
|
+
</body>
|
|
587
|
+
</html>`;
|
|
588
|
+
|
|
589
|
+
const tempPath = path.join(os.tmpdir(), 'claude-statusline-dashboard.html');
|
|
590
|
+
fs.writeFileSync(tempPath, html);
|
|
591
|
+
|
|
592
|
+
console.log(`Opened dashboard at ${tempPath}`);
|
|
593
|
+
await open.default(tempPath);
|
|
594
|
+
} catch (e) {
|
|
595
|
+
console.error(`Failed to load database: ${e.message}`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
module.exports = { handleHookStart, handleHookEnd, handleHistory };
|
package/scripts/setup.js
CHANGED
|
@@ -38,6 +38,8 @@ function setup({ force = false } = {}) {
|
|
|
38
38
|
: `"${process.execPath}" "${scriptPath}"`;
|
|
39
39
|
settings.statusLine = { type: 'command', command };
|
|
40
40
|
|
|
41
|
+
updateHooks(settings, command, true);
|
|
42
|
+
|
|
41
43
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
42
44
|
|
|
43
45
|
try {
|
|
@@ -49,4 +51,62 @@ function setup({ force = false } = {}) {
|
|
|
49
51
|
return { ok: true, settingsPath };
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
function updateHooks(settings, command, enable) {
|
|
55
|
+
if (!settings.hooks) settings.hooks = {};
|
|
56
|
+
|
|
57
|
+
const startCmd = `${command} hook start`;
|
|
58
|
+
const endCmd = `${command} hook end`;
|
|
59
|
+
|
|
60
|
+
function toggleHook(hookName, cmdString) {
|
|
61
|
+
if (!settings.hooks[hookName]) settings.hooks[hookName] = [];
|
|
62
|
+
// Remove any existing statusline hook entries (both old and new format)
|
|
63
|
+
settings.hooks[hookName] = settings.hooks[hookName].filter(h => {
|
|
64
|
+
if (h.hooks) {
|
|
65
|
+
return !h.hooks.some(inner => inner.command && (inner.command.includes('hook start') || inner.command.includes('hook end')));
|
|
66
|
+
}
|
|
67
|
+
return !(h.command && (h.command.includes('hook start') || h.command.includes('hook end')));
|
|
68
|
+
});
|
|
69
|
+
if (enable) {
|
|
70
|
+
settings.hooks[hookName].push({ matcher: '', hooks: [{ type: 'command', command: cmdString }] });
|
|
71
|
+
}
|
|
72
|
+
if (settings.hooks[hookName].length === 0) {
|
|
73
|
+
delete settings.hooks[hookName];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toggleHook('SessionStart', startCmd);
|
|
78
|
+
toggleHook('SessionEnd', endCmd);
|
|
79
|
+
|
|
80
|
+
if (Object.keys(settings.hooks).length === 0) {
|
|
81
|
+
delete settings.hooks;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toggleHistory(enable) {
|
|
86
|
+
const scriptPath = path.resolve(__dirname, '../statusline.js');
|
|
87
|
+
const binaryPath = config.resolveBinary();
|
|
88
|
+
const safeBinary = binaryPath && !UNSAFE_CHARS.test(binaryPath) ? binaryPath : null;
|
|
89
|
+
const command = safeBinary ? `"${safeBinary}"` : `"${process.execPath}" "${scriptPath}"`;
|
|
90
|
+
|
|
91
|
+
const settingsPath = getSettingsPath();
|
|
92
|
+
let settings = {};
|
|
93
|
+
if (fs.existsSync(settingsPath)) {
|
|
94
|
+
try {
|
|
95
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, error: 'settings.json contains invalid JSON — fix manually then re-run.' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
updateHooks(settings, command, enable);
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
atomicWrite(settingsPath, settings);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return { ok: false, error: err.message };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { ok: true, settingsPath };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { setup, toggleHistory, updateHooks };
|
package/scripts/uninstall.js
CHANGED
|
@@ -13,8 +13,13 @@ function uninstall() {
|
|
|
13
13
|
return { ok: false, error: 'settings.json contains invalid JSON — cannot safely modify.' };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
if (
|
|
17
|
-
|
|
16
|
+
if (settings.statusLine) {
|
|
17
|
+
delete settings.statusLine;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Also strip our hooks if they exist
|
|
21
|
+
const { updateHooks } = require('./setup');
|
|
22
|
+
updateHooks(settings, '', false);
|
|
18
23
|
|
|
19
24
|
try {
|
|
20
25
|
atomicWrite(settingsPath, settings);
|
package/statusline.js
CHANGED
|
@@ -7,6 +7,21 @@ const path = require('path');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const { execSync, execFileSync } = require('child_process');
|
|
9
9
|
|
|
10
|
+
const cmd = process.argv[2];
|
|
11
|
+
if (cmd === 'history') {
|
|
12
|
+
require('./scripts/history').handleHistory();
|
|
13
|
+
return;
|
|
14
|
+
} else if (cmd === 'hook') {
|
|
15
|
+
const hookcmd = process.argv[3];
|
|
16
|
+
if (hookcmd === 'start') {
|
|
17
|
+
require('./scripts/history').handleHookStart();
|
|
18
|
+
return;
|
|
19
|
+
} else if (hookcmd === 'end') {
|
|
20
|
+
require('./scripts/history').handleHookEnd();
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
// Reads cumulative token totals from the session JSONL file, using a byte-offset
|
|
11
26
|
// cache so only new bytes are parsed on each invocation (O(new bytes) not O(file)).
|
|
12
27
|
// Returns { totalIn, totalOut } or null on any error.
|