tokburn 0.1.0 → 0.2.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/cli.js CHANGED
@@ -120,8 +120,13 @@ program
120
120
  .command('init')
121
121
  .description('Interactive setup wizard for Claude Code')
122
122
  .action(async () => {
123
- const { runInit } = require('./init');
124
- await runInit();
123
+ try {
124
+ await import('./init-ui.mjs');
125
+ } catch (err) {
126
+ // Fallback to readline wizard if Ink fails (e.g. non-TTY, missing deps)
127
+ const { runInit } = require('./init');
128
+ await runInit();
129
+ }
125
130
  });
126
131
 
127
132
  program
package/init-ui.mjs ADDED
@@ -0,0 +1,466 @@
1
+ /**
2
+ * tokburn -- init-ui.mjs
3
+ * Ink-based setup wizard for `tokburn init`.
4
+ * ESM module using Ink 6.x, @inkjs/ui v2, React 19.
5
+ */
6
+
7
+ import React, { useState, useEffect, useCallback } from 'react';
8
+ import { render, Box, Text, Newline, useApp } from 'ink';
9
+ import { Select, Spinner, ProgressBar } from '@inkjs/ui';
10
+ import chalk from 'chalk';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { detectEnvironment, configurePlan, configureProxy, configureShell, configureStatusLine, PLANS } = require('./init.js');
15
+ const { getConfig } = require('./config.js');
16
+ const { PRESETS, MODULE_LIST } = require('./statusline.js');
17
+
18
+ const pkg = require('./package.json');
19
+
20
+ // ── ASCII Logo ──────────────────────────────────────────────────────────────
21
+
22
+ const LOGO = ` _ _
23
+ | |_ ___ | | __ ___ _ _ _ __ _ __
24
+ | __/ _ \\| |/ /| _ | | | | '__| '_ \\
25
+ | || (_) | < | _ | |_| | | | | | |
26
+ \\__\\___/|_|\\_\\|___|\\__,_|_| |_| |_|`;
27
+
28
+ // ── Helpers ─────────────────────────────────────────────────────────────────
29
+
30
+ const PLAN_LABELS = {
31
+ pro: 'Pro (~500K/5hr)',
32
+ max: 'Max (~2M/5hr)',
33
+ api: 'API only',
34
+ };
35
+
36
+ function dots(label, value, width = 40) {
37
+ const used = label.length + value.length + 2;
38
+ const count = Math.max(2, width - used);
39
+ return chalk.dim('.'.repeat(count));
40
+ }
41
+
42
+ function completedLine(label, value) {
43
+ return ` ${chalk.green('\u2713')} ${label} ${dots(label, value)} ${chalk.bold(value)}`;
44
+ }
45
+
46
+ function previewForPreset(presetKey) {
47
+ if (presetKey === 'skip') return '(no status line)';
48
+ const modules = PRESETS[presetKey];
49
+ if (!modules) return '';
50
+ const lineOne = [];
51
+ const extra = [];
52
+ for (const key of modules) {
53
+ const info = MODULE_LIST.find(m => m.key === key);
54
+ if (!info) continue;
55
+ if (key === 'current_limit' || key === 'weekly_limit') {
56
+ extra.push(info.example);
57
+ } else {
58
+ lineOne.push(info.example);
59
+ }
60
+ }
61
+ let out = '';
62
+ if (lineOne.length > 0) out += lineOne.join(' | ');
63
+ for (const e of extra) out += '\n' + e;
64
+ return out;
65
+ }
66
+
67
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
68
+
69
+ // ── Phase constants ─────────────────────────────────────────────────────────
70
+
71
+ const PHASE_WELCOME = 'welcome';
72
+ const PHASE_PLAN = 'plan';
73
+ const PHASE_PROXY = 'proxy';
74
+ const PHASE_SHELL = 'shell';
75
+ const PHASE_STATUSLINE = 'statusline';
76
+ const PHASE_PROCESSING = 'processing';
77
+ const PHASE_DONE = 'done';
78
+
79
+ // ── Main App ────────────────────────────────────────────────────────────────
80
+
81
+ function App() {
82
+ const { exit } = useApp();
83
+ const [phase, setPhase] = useState(PHASE_WELCOME);
84
+ const [env, setEnv] = useState(null);
85
+
86
+ // User choices
87
+ const [plan, setPlan] = useState(null);
88
+ const [wantProxy, setWantProxy] = useState(null);
89
+ const [wantShell, setWantShell] = useState(null);
90
+ const [statusPreset, setStatusPreset] = useState(null);
91
+
92
+ // Processing state
93
+ const [taskIndex, setTaskIndex] = useState(-1);
94
+ const [taskResults, setTaskResults] = useState({});
95
+ const [progress, setProgress] = useState(0);
96
+
97
+ // Detect environment on mount
98
+ useEffect(() => {
99
+ try {
100
+ const detected = detectEnvironment();
101
+ setEnv(detected);
102
+ } catch (e) {
103
+ setEnv({ home: '', shell: 'unknown', rcFile: '', rcPath: '', claudeDir: '', claudeSettings: '', hasClaudeCode: false });
104
+ }
105
+ }, []);
106
+
107
+ // Auto-advance from welcome after a short pause
108
+ useEffect(() => {
109
+ if (phase === PHASE_WELCOME && env) {
110
+ const t = setTimeout(() => setPhase(PHASE_PLAN), 100);
111
+ return () => clearTimeout(t);
112
+ }
113
+ }, [phase, env]);
114
+
115
+ // Run processing tasks
116
+ useEffect(() => {
117
+ if (phase !== PHASE_PROCESSING) return;
118
+ let cancelled = false;
119
+
120
+ async function runTasks() {
121
+ const tasks = buildTaskList();
122
+ const results = {};
123
+
124
+ for (let i = 0; i < tasks.length; i++) {
125
+ if (cancelled) return;
126
+ setTaskIndex(i);
127
+ setProgress(Math.round(((i) / tasks.length) * 100));
128
+ await sleep(200);
129
+
130
+ try {
131
+ const result = await tasks[i].run();
132
+ results[tasks[i].key] = { success: true, ...(result || {}) };
133
+ } catch (e) {
134
+ results[tasks[i].key] = { success: false, error: e.message };
135
+ }
136
+ }
137
+
138
+ if (cancelled) return;
139
+ setTaskResults(results);
140
+ setProgress(100);
141
+ setTaskIndex(tasks.length);
142
+ await sleep(300);
143
+ setPhase(PHASE_DONE);
144
+ }
145
+
146
+ runTasks();
147
+ return () => { cancelled = true; };
148
+ }, [phase]);
149
+
150
+ // Exit after done phase renders
151
+ useEffect(() => {
152
+ if (phase === PHASE_DONE) {
153
+ const t = setTimeout(() => exit(), 500);
154
+ return () => clearTimeout(t);
155
+ }
156
+ }, [phase, exit]);
157
+
158
+ // ── Task builders ───────────────────────────────────────────────────────
159
+
160
+ function buildTaskList() {
161
+ const tasks = [];
162
+
163
+ tasks.push({
164
+ key: 'plan',
165
+ label: 'Plan configured',
166
+ run: () => {
167
+ configurePlan(plan);
168
+ return {};
169
+ },
170
+ });
171
+
172
+ if (wantProxy) {
173
+ tasks.push({
174
+ key: 'proxy',
175
+ label: 'Proxy started',
176
+ run: () => {
177
+ const result = configureProxy();
178
+ return { pid: result.pid, message: result.message };
179
+ },
180
+ });
181
+ }
182
+
183
+ if (wantShell && env && env.hasClaudeCode) {
184
+ tasks.push({
185
+ key: 'shell',
186
+ label: 'Shell configured',
187
+ run: () => {
188
+ const config = getConfig();
189
+ const result = configureShell(env.rcPath, config.port);
190
+ return { added: result.added, reason: result.reason };
191
+ },
192
+ });
193
+ }
194
+
195
+ if (statusPreset && statusPreset !== 'skip' && env && env.hasClaudeCode) {
196
+ tasks.push({
197
+ key: 'statusline',
198
+ label: 'Status line configured',
199
+ run: () => {
200
+ const modules = PRESETS[statusPreset];
201
+ configureStatusLine(modules);
202
+ return { count: modules.length };
203
+ },
204
+ });
205
+ }
206
+
207
+ return tasks;
208
+ }
209
+
210
+ // ── Handlers ────────────────────────────────────────────────────────────
211
+
212
+ const handlePlanSelect = useCallback((value) => {
213
+ setPlan(value);
214
+ setPhase(PHASE_PROXY);
215
+ }, []);
216
+
217
+ const handleProxySelect = useCallback((value) => {
218
+ setWantProxy(value === 'yes');
219
+ if (env && env.hasClaudeCode) {
220
+ setPhase(PHASE_SHELL);
221
+ } else {
222
+ // Skip shell and statusline if no Claude Code
223
+ setWantShell(false);
224
+ setStatusPreset('skip');
225
+ setPhase(PHASE_PROCESSING);
226
+ }
227
+ }, [env]);
228
+
229
+ const handleShellSelect = useCallback((value) => {
230
+ setWantShell(value === 'yes');
231
+ if (env && env.hasClaudeCode) {
232
+ setPhase(PHASE_STATUSLINE);
233
+ } else {
234
+ setStatusPreset('skip');
235
+ setPhase(PHASE_PROCESSING);
236
+ }
237
+ }, [env]);
238
+
239
+ const handleStatusLineSelect = useCallback((value) => {
240
+ setStatusPreset(value);
241
+ setPhase(PHASE_PROCESSING);
242
+ }, []);
243
+
244
+ // ── Render ──────────────────────────────────────────────────────────────
245
+
246
+ if (!env) {
247
+ return React.createElement(Box, { paddingX: 2 },
248
+ React.createElement(Spinner, { label: 'Detecting environment...' })
249
+ );
250
+ }
251
+
252
+ return React.createElement(Box, { flexDirection: 'column', paddingX: 1 },
253
+ // Logo + header (always shown)
254
+ React.createElement(Box, { flexDirection: 'column' },
255
+ React.createElement(Text, { color: 'yellow' }, LOGO),
256
+ React.createElement(Text, { dimColor: true }, ` token tracking for Claude Code v${pkg.version}`),
257
+ React.createElement(Newline, null),
258
+ React.createElement(Text, { dimColor: true }, ' ' + '\u2500'.repeat(44)),
259
+ React.createElement(Newline, null),
260
+ React.createElement(Text, null,
261
+ ' Detected: ',
262
+ React.createElement(Text, { bold: true }, env.shell),
263
+ env.hasClaudeCode
264
+ ? React.createElement(Text, null, ' + ', React.createElement(Text, { color: 'cyan' }, 'Claude Code'))
265
+ : React.createElement(Text, { dimColor: true }, ' (no Claude Code)')
266
+ ),
267
+ React.createElement(Text, null,
268
+ ' Works with: ',
269
+ React.createElement(Text, { color: 'cyan' }, 'Claude Code'),
270
+ React.createElement(Text, { dimColor: true }, ' | Codex, Cursor -- coming soon')
271
+ ),
272
+ React.createElement(Newline, null)
273
+ ),
274
+
275
+ // Completed steps
276
+ plan ? React.createElement(Text, null, completedLine('Plan', PLAN_LABELS[plan])) : null,
277
+ wantProxy !== null ? React.createElement(Text, null, completedLine('Proxy', wantProxy ? 'start now' : 'skipped')) : null,
278
+ wantShell !== null ? React.createElement(Text, null, completedLine('Shell', wantShell ? `add to ~/${env.rcFile}` : 'manual')) : null,
279
+ statusPreset !== null && phase !== PHASE_STATUSLINE ? React.createElement(Text, null, completedLine('Status line', statusPreset === 'skip' ? 'skipped' : `${statusPreset[0].toUpperCase() + statusPreset.slice(1)} (${(PRESETS[statusPreset] || []).length})`)) : null,
280
+
281
+ // Active phase
282
+ phase === PHASE_PLAN ? React.createElement(PlanStep, { onSelect: handlePlanSelect }) : null,
283
+ phase === PHASE_PROXY ? React.createElement(ProxyStep, { onSelect: handleProxySelect }) : null,
284
+ phase === PHASE_SHELL ? React.createElement(ShellStep, { onSelect: handleShellSelect, rcFile: env.rcFile }) : null,
285
+ phase === PHASE_STATUSLINE ? React.createElement(StatusLineStep, { onSelect: handleStatusLineSelect }) : null,
286
+ phase === PHASE_PROCESSING ? React.createElement(ProcessingPhase, {
287
+ tasks: buildTaskList(),
288
+ taskIndex,
289
+ taskResults,
290
+ progress,
291
+ }) : null,
292
+ phase === PHASE_DONE ? React.createElement(DoneSummary, {
293
+ plan,
294
+ wantProxy,
295
+ wantShell,
296
+ statusPreset,
297
+ env,
298
+ taskResults,
299
+ }) : null
300
+ );
301
+ }
302
+
303
+ // ── Step Components ─────────────────────────────────────────────────────────
304
+
305
+ function PlanStep({ onSelect }) {
306
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
307
+ React.createElement(Text, { bold: true }, ' [1/4] Which Claude plan are you on?'),
308
+ React.createElement(Newline, null),
309
+ React.createElement(Box, { paddingLeft: 4 },
310
+ React.createElement(Select, {
311
+ options: [
312
+ { label: 'Pro ~500K tokens / 5hr window', value: 'pro' },
313
+ { label: 'Max ~2M tokens / 5hr window', value: 'max' },
314
+ { label: 'API only (no plan limits)', value: 'api' },
315
+ ],
316
+ onChange: onSelect,
317
+ })
318
+ )
319
+ );
320
+ }
321
+
322
+ function ProxyStep({ onSelect }) {
323
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
324
+ React.createElement(Text, { bold: true }, ' [2/4] Start the proxy daemon?'),
325
+ React.createElement(Text, { dimColor: true }, ' Enables per-request tracking for detailed breakdowns.'),
326
+ React.createElement(Newline, null),
327
+ React.createElement(Box, { paddingLeft: 4 },
328
+ React.createElement(Select, {
329
+ options: [
330
+ { label: 'Yes, start now', value: 'yes' },
331
+ { label: 'No, skip', value: 'no' },
332
+ ],
333
+ onChange: onSelect,
334
+ })
335
+ )
336
+ );
337
+ }
338
+
339
+ function ShellStep({ onSelect, rcFile }) {
340
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
341
+ React.createElement(Text, { bold: true }, ` [3/4] Add ANTHROPIC_BASE_URL to ~/${rcFile}?`),
342
+ React.createElement(Text, { dimColor: true }, ' Required for the proxy to intercept API calls.'),
343
+ React.createElement(Newline, null),
344
+ React.createElement(Box, { paddingLeft: 4 },
345
+ React.createElement(Select, {
346
+ options: [
347
+ { label: 'Yes, add it', value: 'yes' },
348
+ { label: 'No, I\'ll do it manually', value: 'no' },
349
+ ],
350
+ onChange: onSelect,
351
+ })
352
+ )
353
+ );
354
+ }
355
+
356
+ function StatusLineStep({ onSelect }) {
357
+ const [selected, setSelected] = useState(null);
358
+
359
+ const handleChange = useCallback((value) => {
360
+ setSelected(value);
361
+ onSelect(value);
362
+ }, [onSelect]);
363
+
364
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 0 },
365
+ React.createElement(Text, { bold: true }, ' [4/4] Configure Claude Code status line?'),
366
+ React.createElement(Newline, null),
367
+ React.createElement(Box, { paddingLeft: 4 },
368
+ React.createElement(Select, {
369
+ options: [
370
+ { label: 'Recommended model | ctx% | repo | limits | cost', value: 'recommended' },
371
+ { label: 'Minimal model | current rate limit', value: 'minimal' },
372
+ { label: 'Full everything including burn rate', value: 'full' },
373
+ { label: 'Skip', value: 'skip' },
374
+ ],
375
+ onChange: handleChange,
376
+ })
377
+ ),
378
+ // Preview box shown after selection or for the default
379
+ !selected ? React.createElement(Box, {
380
+ borderStyle: 'round',
381
+ borderColor: 'gray',
382
+ paddingX: 1,
383
+ marginTop: 1,
384
+ marginLeft: 4,
385
+ },
386
+ React.createElement(Box, { flexDirection: 'column' },
387
+ React.createElement(Text, { dimColor: true }, 'Preview (Recommended):'),
388
+ ...previewForPreset('recommended').split('\n').map((line, i) =>
389
+ React.createElement(Text, { key: `p-${i}` }, line)
390
+ )
391
+ )
392
+ ) : null
393
+ );
394
+ }
395
+
396
+ // ── Processing Phase ────────────────────────────────────────────────────────
397
+
398
+ function ProcessingPhase({ tasks, taskIndex, taskResults, progress }) {
399
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
400
+ React.createElement(Text, { bold: true }, ' Setting up tokburn...'),
401
+ React.createElement(Newline, null),
402
+ React.createElement(Box, { paddingLeft: 2 },
403
+ React.createElement(ProgressBar, { value: progress })
404
+ ),
405
+ React.createElement(Newline, null),
406
+ ...tasks.map((task, i) => {
407
+ if (i < taskIndex) {
408
+ // Done
409
+ return React.createElement(Text, { key: task.key },
410
+ ' ', React.createElement(Text, { color: 'green' }, '\u2713'),
411
+ ' ', task.label,
412
+ taskResults[task.key] && taskResults[task.key].pid ? ` (PID ${taskResults[task.key].pid})` : ''
413
+ );
414
+ } else if (i === taskIndex) {
415
+ // Active
416
+ return React.createElement(Box, { key: task.key },
417
+ React.createElement(Text, null, ' '),
418
+ React.createElement(Spinner, { label: task.label })
419
+ );
420
+ } else {
421
+ // Pending
422
+ return React.createElement(Text, { key: task.key, dimColor: true },
423
+ ' \u25CB ', task.label
424
+ );
425
+ }
426
+ })
427
+ );
428
+ }
429
+
430
+ // ── Done Summary ────────────────────────────────────────────────────────────
431
+
432
+ function DoneSummary({ plan, wantProxy, wantShell, statusPreset, env, taskResults }) {
433
+ const proxyResult = taskResults.proxy || {};
434
+ const shellResult = taskResults.shell || {};
435
+
436
+ const proxyDesc = !wantProxy ? 'skipped'
437
+ : proxyResult.pid ? `started :${proxyResult.pid}`
438
+ : proxyResult.message || 'started';
439
+
440
+ const shellDesc = !wantShell ? 'skipped'
441
+ : shellResult.added ? `added to ~/${env.rcFile}`
442
+ : shellResult.reason === 'already exists' ? 'already in rc'
443
+ : 'configured';
444
+
445
+ const statusDesc = !statusPreset || statusPreset === 'skip' ? 'skipped'
446
+ : `${statusPreset[0].toUpperCase() + statusPreset.slice(1)} (${(PRESETS[statusPreset] || []).length})`;
447
+
448
+ return React.createElement(Box, { flexDirection: 'column', marginTop: 1 },
449
+ React.createElement(Text, { dimColor: true }, ` ${'─'.repeat(2)} Setup complete ${'─'.repeat(25)}`),
450
+ React.createElement(Newline, null),
451
+ React.createElement(Text, null, completedLine('Plan', PLAN_LABELS[plan])),
452
+ React.createElement(Text, null, completedLine('Proxy', proxyDesc)),
453
+ React.createElement(Text, null, completedLine('Shell', shellDesc)),
454
+ React.createElement(Text, null, completedLine('Status line', statusDesc)),
455
+ React.createElement(Newline, null),
456
+ React.createElement(Text, { bold: true }, ' Try these:'),
457
+ React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn status'), ' check everything'),
458
+ React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn today'), ' see today\'s usage'),
459
+ React.createElement(Text, null, ' ', React.createElement(Text, { color: 'cyan' }, 'tokburn live'), ' real-time dashboard'),
460
+ React.createElement(Newline, null)
461
+ );
462
+ }
463
+
464
+ // ── Entry point ─────────────────────────────────────────────────────────────
465
+
466
+ render(React.createElement(App));
package/init.js CHANGED
@@ -257,4 +257,70 @@ async function runInit() {
257
257
  rl.close();
258
258
  }
259
259
 
260
- module.exports = { runInit };
260
+ // ── Exported config functions (used by Ink UI) ──────────────────────────────
261
+
262
+ function detectEnvironment() {
263
+ const home = process.env.HOME || process.env.USERPROFILE;
264
+ const shell = path.basename(process.env.SHELL || 'bash');
265
+ const rcFile = shell === 'zsh' ? '.zshrc' : shell === 'fish' ? '.config/fish/config.fish' : '.bashrc';
266
+ const rcPath = path.join(home, rcFile);
267
+ const claudeDir = path.join(home, '.claude');
268
+ const claudeSettings = path.join(claudeDir, 'settings.json');
269
+ const hasClaudeCode = fs.existsSync(claudeDir);
270
+
271
+ return { home, shell, rcFile, rcPath, claudeDir, claudeSettings, hasClaudeCode };
272
+ }
273
+
274
+ function configurePlan(plan) {
275
+ setConfig({ plan, limits: PLANS });
276
+ }
277
+
278
+ function configureProxy() {
279
+ if (isRunning()) {
280
+ return { success: true, message: 'already running', pid: null };
281
+ }
282
+ return startDaemon();
283
+ }
284
+
285
+ function configureShell(rcPath, port) {
286
+ const envLine = `export ANTHROPIC_BASE_URL=http://127.0.0.1:${port}`;
287
+ const existing = fs.existsSync(rcPath) ? fs.readFileSync(rcPath, 'utf8') : '';
288
+ if (existing.includes('ANTHROPIC_BASE_URL')) {
289
+ return { added: false, reason: 'already exists' };
290
+ }
291
+ fs.appendFileSync(rcPath, '\n# tokburn proxy\n' + envLine + '\n');
292
+ return { added: true };
293
+ }
294
+
295
+ function configureStatusLine(selectedModules) {
296
+ const home = process.env.HOME || process.env.USERPROFILE;
297
+ const claudeDir = path.join(home, '.claude');
298
+ const claudeSettings = path.join(claudeDir, 'settings.json');
299
+
300
+ setConfig({ statusline_modules: selectedModules });
301
+
302
+ const srcScript = path.join(__dirname, 'statusline.js');
303
+ const destScript = path.join(claudeDir, 'tokburn-statusline.js');
304
+
305
+ if (fs.existsSync(srcScript)) {
306
+ fs.copyFileSync(srcScript, destScript);
307
+ fs.chmodSync(destScript, '755');
308
+ }
309
+
310
+ let settings = {};
311
+ if (fs.existsSync(claudeSettings)) {
312
+ try { settings = JSON.parse(fs.readFileSync(claudeSettings, 'utf8')); } catch (_) {}
313
+ }
314
+
315
+ settings.statusLine = { type: 'command', command: destScript };
316
+
317
+ if (!fs.existsSync(claudeDir)) {
318
+ fs.mkdirSync(claudeDir, { recursive: true });
319
+ }
320
+ fs.writeFileSync(claudeSettings, JSON.stringify(settings, null, 2) + '\n');
321
+ }
322
+
323
+ module.exports = {
324
+ runInit, detectEnvironment, configurePlan, configureProxy,
325
+ configureShell, configureStatusLine, PLANS
326
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokburn",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "See exactly how fast you're burning tokens and money across Claude Code sessions",
5
5
  "main": "cli.js",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "costs.js",
16
16
  "config.js",
17
17
  "init.js",
18
+ "init-ui.mjs",
18
19
  "card.js",
19
20
  "statusline.js",
20
21
  "statusline.sh",
@@ -55,6 +56,10 @@
55
56
  "node": ">=18.0.0"
56
57
  },
57
58
  "dependencies": {
58
- "commander": "^12.0.0"
59
+ "@inkjs/ui": "^2.0.0",
60
+ "chalk": "^5.6.2",
61
+ "commander": "^12.0.0",
62
+ "ink": "^6.8.0",
63
+ "react": "^19.2.4"
59
64
  }
60
65
  }
package/statusline.js CHANGED
@@ -3,37 +3,16 @@
3
3
  * tokburn — Status line renderer for Claude Code
4
4
  * Reads session JSON from stdin, renders configured modules.
5
5
  * Configured via ~/.tokburn/config.json → statusline_modules
6
+ *
7
+ * IMPORTANT: Stdin reading and rendering only happens when run directly.
8
+ * When require()'d as a module, only MODULE_LIST, PRESETS are exported.
6
9
  */
7
10
 
8
11
  const fs = require('fs');
9
12
  const path = require('path');
10
13
  const { execFileSync } = require('child_process');
11
14
 
12
- // Read stdin synchronously
13
- let input = '';
14
- try {
15
- input = fs.readFileSync('/dev/stdin', 'utf8');
16
- } catch (_) {}
17
-
18
- let data = {};
19
- try {
20
- data = JSON.parse(input);
21
- } catch (_) {}
22
-
23
- // Load config
24
- const configPath = path.join(process.env.HOME || process.env.USERPROFILE, '.tokburn', 'config.json');
25
- let config = {};
26
- try {
27
- if (fs.existsSync(configPath)) {
28
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
29
- }
30
- } catch (_) {}
31
-
32
- const enabledModules = config.statusline_modules || [
33
- 'model_context', 'repo_branch', 'current_limit', 'weekly_limit', 'cost'
34
- ];
35
-
36
- // ── Module renderers ────────────────────────────────────────────────────────────
15
+ // ── Helpers ─────────────────────────────────────────────────────────────────────
37
16
 
38
17
  function dotBar(pct, count) {
39
18
  count = count || 10;
@@ -64,7 +43,6 @@ function formatResetTime(resetTimestamp) {
64
43
  const remainMins = mins % 60;
65
44
  if (hrs < 24) return hrs + 'hr ' + (remainMins > 0 ? remainMins + 'min' : '');
66
45
 
67
- // Show day + time for >24hr
68
46
  const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
69
47
  const day = days[reset.getDay()];
70
48
  const h = reset.getHours();
@@ -74,86 +52,90 @@ function formatResetTime(resetTimestamp) {
74
52
  return day + ' ' + h12 + ':' + String(m).padStart(2, '0') + ampm;
75
53
  }
76
54
 
77
- const MODULES = {
78
- model_context: function () {
79
- const model = (data.model && data.model.display_name) || '?';
80
- const ctxPct = Math.round((data.context_window && data.context_window.used_percentage) || 0);
81
- return model + ' | ctx ' + ctxPct + '%';
82
- },
83
-
84
- repo_branch: function () {
85
- const cwd = (data.workspace && data.workspace.current_dir) || data.cwd || '';
86
- const repoName = path.basename(cwd);
87
- let branch = '';
88
- try {
89
- branch = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
90
- encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
91
- }).trim();
92
- const status = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], {
93
- encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
94
- }).trim();
95
- if (status) branch += '*';
96
- } catch (_) {}
97
-
98
- if (branch) return repoName + ' (' + branch + ')';
99
- return repoName;
100
- },
101
-
102
- current_limit: function () {
103
- const rl = data.rate_limits && data.rate_limits.five_hour;
104
- if (!rl) return 'current ' + dotBar(0) + ' 0%';
105
-
106
- const pct = Math.round(rl.used_percentage || 0);
107
- const reset = formatResetTime(rl.resets_at);
108
- return 'current ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
109
- },
110
-
111
- weekly_limit: function () {
112
- const rl = data.rate_limits && data.rate_limits.seven_day;
113
- if (!rl) return 'weekly ' + dotBar(0) + ' 0%';
114
-
115
- const pct = Math.round(rl.used_percentage || 0);
116
- const reset = formatResetTime(rl.resets_at);
117
- return 'weekly ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
118
- },
119
-
120
- token_count: function () {
121
- const input = (data.context_window && data.context_window.total_input_tokens) || 0;
122
- const output = (data.context_window && data.context_window.total_output_tokens) || 0;
123
- return abbreviate(input + output) + ' tok';
124
- },
125
-
126
- cost: function () {
127
- const cost = (data.cost && data.cost.total_cost_usd) || 0;
128
- return '$' + cost.toFixed(2);
129
- },
130
-
131
- burn_rate: function () {
132
- try {
133
- const usagePath = path.join(process.env.HOME || '', '.tokburn', 'usage.jsonl');
134
- if (!fs.existsSync(usagePath)) return '';
135
- const raw = fs.readFileSync(usagePath, 'utf8').trim();
136
- if (!raw) return '';
137
- const lines = raw.split('\n');
138
- const today = new Date().toISOString().split('T')[0];
139
- const todayEntries = [];
140
- for (const l of lines) {
141
- if (!l.startsWith('{"timestamp":"' + today)) continue;
142
- try { todayEntries.push(JSON.parse(l)); } catch (_) {}
55
+ // ── Module builders (take data as parameter) ────────────────────────────────────
56
+
57
+ function buildModules(data) {
58
+ return {
59
+ model_context: function () {
60
+ const model = (data.model && data.model.display_name) || '?';
61
+ const ctxPct = Math.round((data.context_window && data.context_window.used_percentage) || 0);
62
+ return model + ' | ctx ' + ctxPct + '%';
63
+ },
64
+
65
+ repo_branch: function () {
66
+ const cwd = (data.workspace && data.workspace.current_dir) || data.cwd || '';
67
+ const repoName = path.basename(cwd);
68
+ let branch = '';
69
+ try {
70
+ branch = execFileSync('git', ['-C', cwd, 'branch', '--show-current'], {
71
+ encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
72
+ }).trim();
73
+ const status = execFileSync('git', ['-C', cwd, 'status', '--porcelain'], {
74
+ encoding: 'utf8', timeout: 500, stdio: ['pipe', 'pipe', 'pipe'],
75
+ }).trim();
76
+ if (status) branch += '*';
77
+ } catch (_) {}
78
+
79
+ if (branch) return repoName + ' (' + branch + ')';
80
+ return repoName;
81
+ },
82
+
83
+ current_limit: function () {
84
+ const rl = data.rate_limits && data.rate_limits.five_hour;
85
+ if (!rl) return 'current ' + dotBar(0) + ' 0%';
86
+
87
+ const pct = Math.round(rl.used_percentage || 0);
88
+ const reset = formatResetTime(rl.resets_at);
89
+ return 'current ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
90
+ },
91
+
92
+ weekly_limit: function () {
93
+ const rl = data.rate_limits && data.rate_limits.seven_day;
94
+ if (!rl) return 'weekly ' + dotBar(0) + ' 0%';
95
+
96
+ const pct = Math.round(rl.used_percentage || 0);
97
+ const reset = formatResetTime(rl.resets_at);
98
+ return 'weekly ' + dotBar(pct) + ' ' + pct + '%' + (reset ? ' \u21BB ' + reset : '');
99
+ },
100
+
101
+ token_count: function () {
102
+ const inp = (data.context_window && data.context_window.total_input_tokens) || 0;
103
+ const out = (data.context_window && data.context_window.total_output_tokens) || 0;
104
+ return abbreviate(inp + out) + ' tok';
105
+ },
106
+
107
+ cost: function () {
108
+ const cost = (data.cost && data.cost.total_cost_usd) || 0;
109
+ return '$' + cost.toFixed(2);
110
+ },
111
+
112
+ burn_rate: function () {
113
+ try {
114
+ const usagePath = path.join(process.env.HOME || '', '.tokburn', 'usage.jsonl');
115
+ if (!fs.existsSync(usagePath)) return '';
116
+ const raw = fs.readFileSync(usagePath, 'utf8').trim();
117
+ if (!raw) return '';
118
+ const lines = raw.split('\n');
119
+ const today = new Date().toISOString().split('T')[0];
120
+ const todayEntries = [];
121
+ for (const l of lines) {
122
+ if (!l.startsWith('{"timestamp":"' + today)) continue;
123
+ try { todayEntries.push(JSON.parse(l)); } catch (_) {}
124
+ }
125
+ if (todayEntries.length < 2) return '';
126
+ const first = new Date(todayEntries[0].timestamp);
127
+ const last = new Date(todayEntries[todayEntries.length - 1].timestamp);
128
+ const elapsed = (last - first) / 60000;
129
+ if (elapsed <= 0) return '';
130
+ let total = 0;
131
+ for (const e of todayEntries) total += (e.input_tokens || 0) + (e.output_tokens || 0);
132
+ return '~' + abbreviate(Math.round(total / elapsed)) + '/min';
133
+ } catch (_) {
134
+ return '';
143
135
  }
144
- if (todayEntries.length < 2) return '';
145
- const first = new Date(todayEntries[0].timestamp);
146
- const last = new Date(todayEntries[todayEntries.length - 1].timestamp);
147
- const elapsed = (last - first) / 60000;
148
- if (elapsed <= 0) return '';
149
- let total = 0;
150
- for (const e of todayEntries) total += (e.input_tokens || 0) + (e.output_tokens || 0);
151
- return '~' + abbreviate(Math.round(total / elapsed)) + '/min';
152
- } catch (_) {
153
- return '';
154
- }
155
- },
156
- };
136
+ },
137
+ };
138
+ }
157
139
 
158
140
  // ── Available modules metadata (used by init wizard) ────────────────────────────
159
141
 
@@ -175,16 +157,38 @@ const PRESETS = {
175
157
  full: ['model_context', 'repo_branch', 'current_limit', 'weekly_limit', 'token_count', 'cost', 'burn_rate'],
176
158
  };
177
159
 
178
- // ── Render ───────────────────────────────────────────────────────────────────────
160
+ // ── Main: only runs when executed directly (not require'd) ──────────────────────
179
161
 
180
162
  if (require.main === module) {
163
+ let input = '';
164
+ try {
165
+ input = fs.readFileSync('/dev/stdin', 'utf8');
166
+ } catch (_) {}
167
+
168
+ let data = {};
169
+ try {
170
+ data = JSON.parse(input);
171
+ } catch (_) {}
172
+
173
+ // Load config
174
+ const configPath = path.join(process.env.HOME || process.env.USERPROFILE, '.tokburn', 'config.json');
175
+ let config = {};
176
+ try {
177
+ if (fs.existsSync(configPath)) {
178
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
179
+ }
180
+ } catch (_) {}
181
+
182
+ const enabledModules = config.statusline_modules || PRESETS.recommended;
183
+ const modules = buildModules(data);
184
+
181
185
  const outputLines = [];
182
186
  const lineOneModules = [];
183
187
  const extraLines = [];
184
188
 
185
189
  for (const mod of enabledModules) {
186
- if (!MODULES[mod]) continue;
187
- const val = MODULES[mod]();
190
+ if (!modules[mod]) continue;
191
+ const val = modules[mod]();
188
192
  if (!val) continue;
189
193
 
190
194
  if (mod === 'current_limit' || mod === 'weekly_limit') {
@@ -202,4 +206,4 @@ if (require.main === module) {
202
206
  process.stdout.write(outputLines.join('\n'));
203
207
  }
204
208
 
205
- module.exports = { MODULE_LIST, PRESETS, MODULES };
209
+ module.exports = { MODULE_LIST, PRESETS, buildModules };