bingocode 1.1.166 → 1.1.167

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/bin/bingo-win.cjs CHANGED
@@ -1,147 +1,215 @@
1
- #!/usr/bin/env node
2
-
3
- const { spawn, spawnSync } = require('node:child_process');
4
- const path = require('path');
5
- const os = require('os');
6
- const fs = require('fs');
7
-
8
- process.env.NoDefaultCurrentDirectoryInExePath = '1';
9
-
10
- // ── 首次部署:将默认 bingo 配置复制到 ~/.claude/bingo/ ──
11
- /**
12
- * 更加健壮的根目录定位:
13
- * 1. 如果 preload.ts 在 ../ (当前 bin/ 目录下运行)
14
- * 2. 否则查找同级及上级目录中的 package.json
15
- */
16
- function getProjectRoot() {
17
- let curr = __dirname;
18
- try {
19
- while (curr !== path.dirname(curr)) {
20
- if (fs.existsSync(path.join(curr, 'preload.ts')) || fs.existsSync(path.join(curr, 'package.json'))) {
21
- return curr;
22
- }
23
- const parent = path.dirname(curr);
24
- if (fs.existsSync(path.join(parent, 'preload.ts'))) return parent;
25
- curr = parent;
26
- }
27
- } catch (err) {
28
- // 防止权限拒绝等导致挂死
29
- }
30
- return path.join(__dirname, '..');
31
- }
32
-
33
- const ROOT_DIR = getProjectRoot();
34
-
35
- (function deployBingoDefaults() {
36
- const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
37
- const bingoDir = path.join(configDir, 'bingo');
38
- const targetSettings = path.join(bingoDir, 'settings.json');
39
-
40
- // 只在 settings.json 不存在时才部署
41
- if (!fs.existsSync(targetSettings)) {
42
- const defaultsDir = path.join(ROOT_DIR, 'config', 'bingo-defaults');
43
- const srcSettings = path.join(defaultsDir, 'settings.json');
44
-
45
- if (fs.existsSync(srcSettings)) {
46
- try {
47
- if (!fs.existsSync(bingoDir)) {
48
- fs.mkdirSync(bingoDir, { recursive: true });
49
- }
50
- fs.copyFileSync(srcSettings, targetSettings);
51
- console.log('[bingo] 首次启动:已部署默认配置到', targetSettings);
52
- } catch (err) {
53
- console.warn('[bingo] 部署默认配置失败:', err.message);
54
- }
55
- }
56
- }
57
- })();
58
-
59
- // 自动定位 bun.exe(纯文件系统查找,无子进程,无 DEP0190 警告)
60
- function resolveBunExe() {
61
- // 1. 用户指定路径
62
- if (process.env.BUN_PATH && fs.existsSync(process.env.BUN_PATH)) {
63
- return process.env.BUN_PATH;
64
- }
65
- const home = os.homedir();
66
- const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
67
- const candidates = [
68
- // npm install -g bun 的真实 exe(最常见)
69
- path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
70
- // bun 官方安装脚本位置
71
- path.join(home, '.bun', 'bin', 'bun.exe'),
72
- ];
73
- // 遍历 PATH 中每个目录查找 bun.exe
74
- for (const dir of (process.env.PATH || '').split(path.delimiter)) {
75
- candidates.push(path.join(dir, 'bun.exe'));
76
- }
77
- for (const c of candidates) {
78
- try { if (fs.existsSync(c)) return c; } catch (_) {}
79
- }
80
- return null;
81
- }
82
-
83
- // 检查 bun 是否可用
84
- function bunExists() {
85
- return resolveBunExe() !== null;
86
- }
87
-
88
- // 安装 bun(通过 npm install -g bun)
89
- function installBun() {
90
- console.log('[bingocode] bun 未检测到,正在通过 npm install -g bun 安装...');
91
-
92
- try {
93
- const npmResult = spawnSync(
94
- 'npm.cmd',
95
- ['install', '-g', 'bun', '--loglevel', 'error'],
96
- { stdio: 'inherit' }
97
- );
98
- if (npmResult.status !== 0) {
99
- throw new Error(`npm install -g bun 失败,exit code ${npmResult.status}`);
100
- }
101
-
102
- console.log('[bingocode] bun 安装完成,正在启动...');
103
- return true;
104
- } catch (err) {
105
- console.error(`[bingocode] bun 自动安装失败: ${err.message}`);
106
- console.log('[bingocode] 请手动安装 bun: npm install -g bun');
107
- return false;
108
- }
109
- }
110
-
111
- if (!bunExists()) {
112
- if (!installBun()) {
113
- process.exit(1);
114
- }
115
- }
116
-
117
- // 安装完成后重新解析 bun 路径
118
- const bunExe = resolveBunExe();
119
- if (!bunExe) {
120
- console.error('[bingocode] 安装后仍找不到 bun.exe,请重新打开终端后再试,或手动安装 bun: npm install -g bun');
121
- process.exit(1);
122
- }
123
-
124
- // Bingo Manager 入口
125
- const entry = path.join(ROOT_DIR, 'src', 'entrypoints', 'manager.tsx');
126
-
127
- // preload shim
128
- const preload = path.join(ROOT_DIR, 'preload.ts');
129
- if (!fs.existsSync(preload)) {
130
- console.error('[bingocode] 找不到 preload.ts,MACRO 将无法注入:' + preload);
131
- process.exit(1);
132
- }
133
-
134
- // 检查 .env
135
- let envFlag = '';
136
- const envPath = path.join(ROOT_DIR, '.env');
137
- if (fs.existsSync(envPath)) {
138
- envFlag = `--env-file=${envPath}`;
139
- }
140
-
141
- const extraArgs = process.argv.slice(2);
142
- const args = [`--preload=${preload}`, envFlag, entry, ...extraArgs].filter(Boolean);
143
-
144
- // 用绝对路径 spawn,不依赖 shell 解析 PATH
145
- const child = spawn(bunExe, args, { stdio: 'inherit' });
146
-
147
- child.on('exit', (code) => process.exit(code));
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, spawnSync } = require('node:child_process');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const fs = require('fs');
7
+
8
+ process.env.NoDefaultCurrentDirectoryInExePath = '1';
9
+
10
+ // ── 首次部署:将默认 bingo 配置复制到 ~/.claude/bingo/ ──
11
+ /**
12
+ * 更加健壮的根目录定位:
13
+ * 1. 如果 preload.ts 在 ../ (当前 bin/ 目录下运行)
14
+ * 2. 否则查找同级及上级目录中的 package.json
15
+ */
16
+ function getProjectRoot() {
17
+ let curr = __dirname;
18
+ try {
19
+ while (curr !== path.dirname(curr)) {
20
+ if (fs.existsSync(path.join(curr, 'preload.ts')) || fs.existsSync(path.join(curr, 'package.json'))) {
21
+ return curr;
22
+ }
23
+ const parent = path.dirname(curr);
24
+ if (fs.existsSync(path.join(parent, 'preload.ts'))) return parent;
25
+ curr = parent;
26
+ }
27
+ } catch (err) {
28
+ // 防止权限拒绝等导致挂死
29
+ }
30
+ return path.join(__dirname, '..');
31
+ }
32
+
33
+ const ROOT_DIR = getProjectRoot();
34
+
35
+ (function deployBingoDefaults() {
36
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
37
+ const bingoDir = path.join(configDir, 'bingo');
38
+ const targetSettings = path.join(bingoDir, 'settings.json');
39
+
40
+ // 只在 settings.json 不存在时才部署
41
+ if (!fs.existsSync(targetSettings)) {
42
+ const defaultsDir = path.join(ROOT_DIR, 'config', 'bingo-defaults');
43
+ const srcSettings = path.join(defaultsDir, 'settings.json');
44
+
45
+ if (fs.existsSync(srcSettings)) {
46
+ try {
47
+ if (!fs.existsSync(bingoDir)) {
48
+ fs.mkdirSync(bingoDir, { recursive: true });
49
+ }
50
+ fs.copyFileSync(srcSettings, targetSettings);
51
+ console.log('[bingo] 首次启动:已部署默认配置到', targetSettings);
52
+ } catch (err) {
53
+ console.warn('[bingo] 部署默认配置失败:', err.message);
54
+ }
55
+ }
56
+ }
57
+ })();
58
+
59
+ // 自动定位 bun.exe(纯文件系统查找,无子进程,无 DEP0190 警告)
60
+ function resolveBunExe() {
61
+ // 1. 用户指定路径
62
+ if (process.env.BUN_PATH && fs.existsSync(process.env.BUN_PATH)) {
63
+ return process.env.BUN_PATH;
64
+ }
65
+ const home = os.homedir();
66
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
67
+ const candidates = [
68
+ // npm install -g bun 的真实 exe(最常见)
69
+ path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
70
+ // bun 官方安装脚本位置
71
+ path.join(home, '.bun', 'bin', 'bun.exe'),
72
+ ];
73
+ // 遍历 PATH 中每个目录查找 bun.exe
74
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
75
+ candidates.push(path.join(dir, 'bun.exe'));
76
+ }
77
+ for (const c of candidates) {
78
+ try { if (fs.existsSync(c)) return c; } catch (_) {}
79
+ }
80
+ return null;
81
+ }
82
+
83
+ // 检查 bun 是否可用
84
+ function bunExists() {
85
+ return resolveBunExe() !== null;
86
+ }
87
+
88
+ // 安装 bun(通过 npm install -g bun)
89
+ function installBun() {
90
+ console.log('[bingocode] bun 未检测到,正在通过 npm install -g bun 安装...');
91
+
92
+ try {
93
+ const npmResult = spawnSync(
94
+ 'npm.cmd',
95
+ ['install', '-g', 'bun', '--loglevel', 'error'],
96
+ { stdio: 'inherit' }
97
+ );
98
+ if (npmResult.status !== 0) {
99
+ throw new Error(`npm install -g bun 失败,exit code ${npmResult.status}`);
100
+ }
101
+
102
+ console.log('[bingocode] bun 安装完成,正在启动...');
103
+ return true;
104
+ } catch (err) {
105
+ console.error(`[bingocode] bun 自动安装失败: ${err.message}`);
106
+ console.log('[bingocode] 请手动安装 bun: npm install -g bun');
107
+ return false;
108
+ }
109
+ }
110
+
111
+ if (!bunExists()) {
112
+ if (!installBun()) {
113
+ process.exit(1);
114
+ }
115
+ }
116
+
117
+ // 安装完成后重新解析 bun 路径
118
+ const bunExe = resolveBunExe();
119
+ if (!bunExe) {
120
+ console.error('[bingocode] 安装后仍找不到 bun.exe,请重新打开终端后再试,或手动安装 bun: npm install -g bun');
121
+ process.exit(1);
122
+ }
123
+
124
+ // Bingo Manager 入口
125
+ const entry = path.join(ROOT_DIR, 'src', 'entrypoints', 'manager.tsx');
126
+
127
+ // preload shim
128
+ const preload = path.join(ROOT_DIR, 'preload.ts');
129
+ if (!fs.existsSync(preload)) {
130
+ console.error('[bingocode] 找不到 preload.ts,MACRO 将无法注入:' + preload);
131
+ process.exit(1);
132
+ }
133
+
134
+ // 检查 .env
135
+ let envFlag = '';
136
+ const envPath = path.join(ROOT_DIR, '.env');
137
+ if (fs.existsSync(envPath)) {
138
+ envFlag = `--env-file=${envPath}`;
139
+ }
140
+
141
+ const extraArgs = process.argv.slice(2);
142
+
143
+ // ── Start tray daemon if not already running ────────────────────────────────
144
+ const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
145
+ const DAEMON_LOCK_FILE = path.join(RUNTIME_DIR, 'daemon.lock');
146
+
147
+ function serverHealthy() {
148
+ return new Promise((resolve) => {
149
+ const http = require('http');
150
+ const req = http.get('http://127.0.0.1:3456/health', { timeout: 1000 }, (res) => {
151
+ let data = '';
152
+ res.on('data', (c) => (data += c));
153
+ res.on('end', () => {
154
+ try {
155
+ resolve(res.statusCode === 200 && JSON.parse(data).status === 'ok');
156
+ } catch {
157
+ resolve(false);
158
+ }
159
+ });
160
+ });
161
+ req.on('error', () => resolve(false));
162
+ req.setTimeout(1000, () => { req.destroy(); resolve(false); });
163
+ });
164
+ }
165
+
166
+ function isDaemonAlive() {
167
+ try {
168
+ const pid = parseInt(fs.readFileSync(DAEMON_LOCK_FILE, 'utf-8').trim(), 10);
169
+ process.kill(pid, 0); // signal 0 == probe only
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ const CHECK_MS = 3000;
177
+
178
+ async function startTrayDaemonIfNeeded() {
179
+ // Daemon PID lock prevents race: only one daemon runs across multiple bingo launches
180
+ if (isDaemonAlive()) {
181
+ console.log('[bingo] Daemon already running, skipping daemon start');
182
+ return;
183
+ }
184
+
185
+ // stale lock cleanup
186
+ try { fs.unlinkSync(DAEMON_LOCK_FILE); } catch {}
187
+
188
+ console.log('[bingo] Starting tray daemon...');
189
+ const trayEntry = path.join(ROOT_DIR, 'src', 'entrypoints', 'tray-only.ts');
190
+ const daemon = spawn(bunExe, ['--preload=' + preload, trayEntry], {
191
+ detached: true,
192
+ stdio: 'ignore',
193
+ env: { ...process.env },
194
+ });
195
+ daemon.unref();
196
+
197
+ // Wait for health check
198
+ const start = Date.now();
199
+ while (Date.now() - start < CHECK_MS) {
200
+ if (await serverHealthy()) {
201
+ console.log('[bingo] Tray daemon started successfully');
202
+ break;
203
+ }
204
+ await (new Promise((r) => setTimeout(r, 300)));
205
+ }
206
+ }
207
+
208
+ // Start daemon, then launch CLI independently
209
+ startTrayDaemonIfNeeded().catch(() => {});
210
+
211
+ // ── Launch CLI (connects to existing server) ───────────────────────────────
212
+ const args = [`--preload=${preload}`, envFlag, entry, ...extraArgs].filter(Boolean);
213
+
214
+ // 用绝对路径 spawn,不依赖 shell 解析 PATH
215
+ const child = spawn(bunExe, args, { stdio: 'inherit' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bingocode",
3
- "version": "1.1.166",
3
+ "version": "1.1.167",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude": "bin/claude-win.cjs",
@@ -80,6 +80,7 @@
80
80
  "lru-cache": "^11.2.7",
81
81
  "marked": "^17.0.5",
82
82
  "medium-zoom": "^1.1.0",
83
+ "node-notifier": "^10.0.1",
83
84
  "p-map": "^7.0.4",
84
85
  "picomatch": "^4.0.4",
85
86
  "proper-lockfile": "^4.1.2",
@@ -94,6 +95,7 @@
94
95
  "stack-utils": "^2.0.6",
95
96
  "strip-ansi": "^7.2.0",
96
97
  "supports-hyperlinks": "^4.4.0",
98
+ "systray2": "^2.1.4",
97
99
  "tree-kill": "^1.2.2",
98
100
  "ts-node": "^10.9.2",
99
101
  "type-fest": "^5.5.0",
@@ -0,0 +1,151 @@
1
+ /**
2
+ * tray-only.ts — 背景托盘入口(无 UI,系统托盘守护进程)
3
+ *
4
+ * 使用 systray2 渲染原生托盘图标,启动服务器,处理退出请求。
5
+ * 关闭所有 CLI 时,服务器继续运行直到用户右键 Exit 退出。
6
+ */
7
+ import SysTray from 'systray2';
8
+ import path from 'path';
9
+ import fs from 'fs';
10
+ import os from 'os';
11
+ import http from 'http';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+ const ROOT_DIR = path.resolve(__dirname, '../../');
17
+
18
+ const PORT = Number(process.env.BINGO_SERVER_PORT || 3456);
19
+ const HOST = process.env.BINGO_SERVER_HOST || '127.0.0.1';
20
+
21
+ let serverHandle: any = null;
22
+
23
+ // ── Daemon PID lock ──────────────────────────────────────────────────────
24
+ const DAEMON_LOCK_FILE = path.join(os.homedir(), '.claude-cli', 'runtime', 'daemon.lock');
25
+ try { fs.mkdirSync(path.dirname(DAEMON_LOCK_FILE), { recursive: true }); } catch {}
26
+ fs.writeFileSync(DAEMON_LOCK_FILE, String(process.pid), { flag: 'w' });
27
+ process.on('exit', () => {
28
+ try { fs.unlinkSync(DAEMON_LOCK_FILE); } catch {}
29
+ });
30
+
31
+ // Generate green dot icon inline (16x16 PNG, 32-bit RGBA)
32
+ import zlib from 'zlib';
33
+ function buildGreenDotBase64(): string {
34
+ const W = 16, H = 16;
35
+ const raw: number[] = [];
36
+ for (let y = 0; y < H; y++) {
37
+ raw.push(0); // filter none
38
+ for (let x = 0; x < W; x++) {
39
+ const dx = x - 7.5, dy = y - 7.5;
40
+ const d = Math.sqrt(dx*dx + dy*dy);
41
+ if (d < 5.5) {
42
+ raw.push(0x1a, 0xe8, 0x30, 0xff);
43
+ } else {
44
+ raw.push(0, 0, 0, 0);
45
+ }
46
+ }
47
+ }
48
+ const rgbadata = Buffer.from(raw);
49
+ const compressed = zlib.deflateSync(rgbadata);
50
+ const sig = Buffer.from([137,80,78,71,13,10,26,10]);
51
+ const ihdrData = Buffer.alloc(13);
52
+ ihdrData.writeUInt32BE(W, 0);
53
+ ihdrData.writeUInt32BE(H, 4);
54
+ ihdrData.writeUInt8(8, 8);
55
+ ihdrData.writeUInt8(6, 9);
56
+ const ihdr = makePngChunk('IHDR', ihdrData);
57
+ const idat = makePngChunk('IDAT', compressed);
58
+ const iend = makePngChunk('IEND', Buffer.alloc(0));
59
+ return Buffer.concat([sig, ihdr, idat, iend]).toString('base64');
60
+ }
61
+ function makePngChunk(type: string, data: Buffer): Buffer {
62
+ const t = Buffer.from(type, 'ascii');
63
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
64
+ const crcInput = Buffer.concat([t, data]);
65
+ const crc = crc32(crcInput);
66
+ const trailer = Buffer.alloc(4); trailer.writeUInt32BE(crc, 0);
67
+ return Buffer.concat([len, t, data, trailer]);
68
+ }
69
+ function crc32(buf: Buffer): number {
70
+ let c = 0xffffffff;
71
+ for (let i = 0; i < buf.length; i++) {
72
+ c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
73
+ }
74
+ return (c ^ 0xffffffff) >>> 0;
75
+ }
76
+ const crcTable = (() => {
77
+ const t = new Int32Array(256);
78
+ for (let n = 0; n < 256; n++) {
79
+ let c = n;
80
+ for (let k = 0; k < 8; k++) {
81
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
82
+ }
83
+ t[n] = c;
84
+ }
85
+ return t;
86
+ })();
87
+
88
+ const iconB64 = buildGreenDotBase64();
89
+
90
+ try {
91
+ const systray = new SysTray({
92
+ menu: {
93
+ icon: iconB64,
94
+ title: 'Bingo',
95
+ tooltip: 'Bingo is running',
96
+ items: [
97
+ {
98
+ title: 'Server Running',
99
+ tooltip: 'Bingo is running on port ' + PORT,
100
+ checked: false,
101
+ enabled: false,
102
+ },
103
+ {
104
+ title: 'Exit Bingo',
105
+ tooltip: 'Stop all bingo services',
106
+ checked: false,
107
+ enabled: true,
108
+ },
109
+ ],
110
+ },
111
+ debug: false,
112
+ copyDir: true,
113
+ });
114
+
115
+ systray.onClick((action: any) => {
116
+ if (action.seq_id === 1) {
117
+ console.log('[tray] Exiting via tray menu');
118
+ const req = http.request(
119
+ `http://${HOST}:${PORT}/exit`,
120
+ { method: 'POST', timeout: 5000 },
121
+ () => {},
122
+ );
123
+ req.on('error', () => {
124
+ try {
125
+ systray.kill(false);
126
+ } catch {}
127
+ process.exit(0);
128
+ });
129
+ req.end();
130
+ }
131
+ });
132
+
133
+ } catch (e) {
134
+ console.error('[tray] Failed to create systray:', e);
135
+ }
136
+
137
+ // ── Start the server ─────────────────────────────────────────────────────────
138
+ import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer.js';
139
+ const serverEntry = path.join(ROOT_DIR, 'src', 'server', 'index.ts');
140
+
141
+ ensureSingletonLocalServer({ serverEntry, host: HOST, port: PORT })
142
+ .then((handle: any) => {
143
+ serverHandle = handle;
144
+ console.log('[tray] Server started on http://' + HOST + ':' + PORT);
145
+ })
146
+ .catch((err: any) => {
147
+ console.error('[tray] Failed to start server:', err.message || err);
148
+ });
149
+
150
+ // Keep event loop alive indefinitely
151
+ setInterval(() => {}, 60_000);
@@ -14,7 +14,6 @@ import { ensureSingletonLocalServer } from '../server/ensureSingletonLocalServer
14
14
  import { TopBar, BottomBar, Panel, Hint, Kbd, SecondaryMenu, StateDisplay, ScrollBar, truncate, safePadEnd } from '../manager/CliMenuUi.tsx';
15
15
  import { WelcomeV2 } from '../components/LogoV2/WelcomeV2.tsx';
16
16
  import { TopToolbar } from '../manager/TopToolbar.tsx';
17
- import { TokenStatsPanel } from '../manager/TokenStatsPanel.tsx';
18
17
 
19
18
  // Theme switching (Hook)
20
19
  import { useTheme } from '../components/design-system/ThemeProvider.js';
@@ -485,7 +484,7 @@ export const CliMenuManager: React.FC = () => {
485
484
  const [settingData, setSettingData] = useState<any>(null);
486
485
  const [loadingSetting, setLoadingSetting] = useState(false);
487
486
  const [setErr, setSetErr] = useState<string | null>(null);
488
- const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker' | 'tokenStats'>('list');
487
+ const [settingsStage, setSettingsStage] = useState<'list' | 'langPicker'>('list');
489
488
  const [settingsCursor, setSettingsCursor] = useState(0);
490
489
  const [autoModeEnabled, setAutoModeEnabled] = useState(false);
491
490
  const [bypassPermsEnabled, setBypassPermsEnabled] = useState(false);
@@ -764,7 +763,6 @@ export const CliMenuManager: React.FC = () => {
764
763
  // Settings: langPicker → back to list; list → back to main menu
765
764
  if (page === 'settings') {
766
765
  if (settingsStage === 'langPicker') { setSettingsStage('list'); return; }
767
- if (settingsStage === 'tokenStats') { setSettingsStage('list'); return; }
768
766
  }
769
767
  setPage(null);
770
768
  setHistoryMenuStage('list');
@@ -876,8 +874,8 @@ export const CliMenuManager: React.FC = () => {
876
874
  // Settings interactions
877
875
  if (!showHelp && page === 'settings') {
878
876
  if (settingsStage === 'list') {
879
- // Fixed rows: Language, Auto Mode, Bypass, Token Stats = 4 rows
880
- const totalRows = 4 + (settingData && typeof settingData === 'object' ? Object.keys(settingData).length : 0);
877
+ // +1 for the fixed Language row prepended before settingData entries
878
+ const totalRows = 3 + (settingData && typeof settingData === 'object' ? Object.keys(settingData).length : 0);
881
879
  const visible = Math.max(1, MID_H - 2);
882
880
  if (key.downArrow || input === 'j') {
883
881
  setSettingsCursor(c => Math.min(totalRows - 1, c + 1));
@@ -925,9 +923,6 @@ export const CliMenuManager: React.FC = () => {
925
923
  }
926
924
  return next;
927
925
  });
928
- } else if (settingsCursor === 3) {
929
- // Row 3: open Token Stats
930
- setSettingsStage('tokenStats');
931
926
  }
932
927
  }
933
928
  }
@@ -1411,24 +1406,12 @@ export const CliMenuManager: React.FC = () => {
1411
1406
  );
1412
1407
  }
1413
1408
 
1414
- // --- tokenStats sub-menu ---
1415
- if (settingsStage === 'tokenStats') {
1416
- return (
1417
- <TokenStatsPanel
1418
- width={VIEW_W}
1419
- height={MID_H}
1420
- onBack={() => setSettingsStage('list')}
1421
- />
1422
- );
1423
- }
1424
-
1425
1409
  // --- settings list ---
1426
1410
  type SettingRow = { key: string; label: string; value: string; interactive: boolean };
1427
1411
  const fixedRows: SettingRow[] = [
1428
1412
  { key: '__lang', label: tS.langLabel, value: currentLangLabel, interactive: true },
1429
1413
  { key: '__autoMode', label: tS.autoModeLabel, value: autoModeEnabled ? tS.autoModeOn : tS.autoModeOff, interactive: true },
1430
1414
  { key: '__bypassPerms', label: tS.bypassPermsLabel, value: bypassPermsEnabled ? tS.bypassPermsOn : tS.bypassPermsOff, interactive: true },
1431
- { key: '__tokenStats', label: 'Token Stats', value: '>', interactive: true },
1432
1415
  ];
1433
1416
  const dataEntries = settingData && typeof settingData === 'object' ? Object.entries(settingData) : [];
1434
1417
  const dataRows: SettingRow[] = dataEntries.map(([k, v]) => ({
@@ -1,182 +1,173 @@
1
- // server/ensureSingletonLocalServer.ts
2
- import { spawn } from 'child_process';
3
- import fs from 'fs';
4
- import fsp from 'fs/promises';
5
- import path from 'path';
6
- import os from 'os';
7
- import axios from 'axios';
8
-
9
- type Handle = {
10
- baseUrl: string;
11
- stopIfLast: () => Promise<void>;
12
- pid?: number;
13
- };
14
-
15
- const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
16
- const LOCK_JSON = path.join(RUNTIME_DIR, 'server.lock.json');
17
- const BOOT_LOCK = path.join(RUNTIME_DIR, 'server.boot.lock');
18
- const LEASES_DIR = path.join(RUNTIME_DIR, 'leases');
19
-
20
- const DEFAULT_HOST = '127.0.0.1';
21
- const DEFAULT_PORT = Number(process.env.SERVER_PORT || 3456);
22
- const HEALTH_TIMEOUT_MS = Number(process.env.HEALTH_TIMEOUT_MS || 20000);
23
- const HEALTH_RETRY_MS = 300;
24
-
25
- function mkdirp(p: string) { fs.mkdirSync(p, { recursive: true }); }
26
- function atomicCreate(p: string): boolean {
27
- try { const fd = fs.openSync(p, 'wx'); fs.closeSync(fd); return true; } catch { return false; }
28
- }
29
- function rmSafe(p: string) { try { fs.rmSync(p, { force: true, recursive: true }); } catch {} }
30
- function readJson<T>(p: string): T | null { try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as T; } catch { return null; } }
31
- function writeJson(p: string, data: any) { fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); }
32
- function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
33
- async function waitHealthy(baseUrl: string, timeoutMs: number) {
34
- const start = Date.now(); let lastErr: any = null;
35
- while (Date.now() - start < timeoutMs) {
36
- try {
37
- const r = await axios.get(baseUrl.replace(/\/+$/, '') + '/health', { timeout: 1500 });
38
- if (r.status === 200 && r.data?.status === 'ok') return;
39
- } catch (e) { lastErr = e; }
40
- await new Promise(r => setTimeout(r, HEALTH_RETRY_MS));
41
- }
42
- throw new Error(`Health check timeout: ${lastErr?.message || 'unknown'}`);
43
- }
44
- function resolveBunPath(): string {
45
- // 优先环境变量
46
- const fromEnv = process.env.BUN_PATH;
47
- if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
48
-
49
- // Windows:纯文件系统查找 bun.exe,无子进程,无 DEP0190 警告
50
- if (process.platform === 'win32') {
51
- const home = os.homedir();
52
- const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
53
- const candidates = [
54
- path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
55
- path.join(home, '.bun', 'bin', 'bun.exe'),
56
- ];
57
- for (const dir of (process.env.PATH || '').split(path.delimiter)) {
58
- candidates.push(path.join(dir, 'bun.exe'));
59
- }
60
- for (const c of candidates) {
61
- try { if (fs.existsSync(c)) return c; } catch (_) {}
62
- }
63
- } else {
64
- // Linux/macOS:遍历 PATH 查找 bun
65
- for (const dir of (process.env.PATH || '').split(path.delimiter)) {
66
- const c = path.join(dir, 'bun');
67
- try { if (fs.existsSync(c)) return c; } catch (_) {}
68
- }
69
- }
70
-
71
- throw new Error('Bun not detected. Please install via: npm install -g bun');
72
- }
73
-
74
- async function acquireLease(): Promise<string> {
75
- mkdirp(LEASES_DIR);
76
- const lease = path.join(LEASES_DIR, `${process.pid}.json`);
77
- await fsp.writeFile(lease, JSON.stringify({ pid: process.pid, ts: Date.now() }));
78
- const cleanup = () => rmSafe(lease);
79
- process.once('exit', cleanup);
80
- process.once('SIGINT', () => { cleanup(); process.exit(130); });
81
- process.once('SIGTERM', () => { cleanup(); process.exit(143); });
82
- return lease;
83
- }
84
- function countValidLeases(): number {
85
- try {
86
- const files = fs.readdirSync(LEASES_DIR);
87
- let alive = 0;
88
- for (const f of files) {
89
- const p = path.join(LEASES_DIR, f);
90
- const data = readJson<{pid:number, ts:number}>(p);
91
- if (data?.pid && isPidAlive(data.pid)) alive++;
92
- else rmSafe(p);
93
- }
94
- return alive;
95
- } catch { return 0; }
96
- }
97
-
98
- export async function ensureSingletonLocalServer(opts: {
99
- serverEntry: string;
100
- host?: string;
101
- port?: number;
102
- baseUrlEnv?: string;
103
- passEnv?: Record<string, string>;
104
- }): Promise<Handle> {
105
- const preset = (opts.baseUrlEnv || process.env.BASE_API_URL || '').trim();
106
- if (preset) {
107
- try { await waitHealthy(preset, 3000); } catch {}
108
- await acquireLease();
109
- return { baseUrl: preset.replace(/\/+$/, ''), stopIfLast: async () => {} };
110
- }
111
-
112
- const host = opts.host || DEFAULT_HOST;
113
- const port = Number(opts.port ?? DEFAULT_PORT);
114
- const baseUrl = `http://${host}:${port}`;
115
- mkdirp(RUNTIME_DIR);
116
-
117
- const lock = readJson<{ pid:number, port:number }>(LOCK_JSON);
118
- if (lock && lock.port === port) {
119
- const healthy = await (async () => { try { await waitHealthy(baseUrl, 1200); return true; } catch { return false; } })();
120
- if (healthy && isPidAlive(lock.pid)) {
121
- await acquireLease();
122
- return { baseUrl, stopIfLast: makeStopIfLast(lock.pid, baseUrl), pid: lock.pid };
123
- }
124
- rmSafe(LOCK_JSON);
125
- }
126
-
127
- const iAmSpawner = atomicCreate(BOOT_LOCK);
128
- if (!iAmSpawner) {
129
- try { await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS); } catch {
130
- rmSafe(BOOT_LOCK); rmSafe(LOCK_JSON);
131
- return await ensureSingletonLocalServer(opts);
132
- }
133
- await acquireLease();
134
- const live = readJson<{pid:number}>(LOCK_JSON);
135
- return { baseUrl, stopIfLast: makeStopIfLast(live?.pid || 0, baseUrl), pid: live?.pid };
136
- }
137
-
138
- let child: any = null;
139
- try {
140
- const bun = resolveBunPath();
141
- child = spawn(
142
- bun,
143
- [opts.serverEntry, '--host', host, '--port', String(port)],
144
- {
145
- env: { ...process.env, SERVER_AUTH_REQUIRED: '0', ...(opts.passEnv || {}) },
146
- stdio: 'ignore',
147
- detached: false,
148
- }
149
- );
150
- await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS);
151
- writeJson(LOCK_JSON, { pid: child.pid, port, startedAt: new Date().toISOString() });
152
- } catch (e) {
153
- if (child?.pid) {
154
- try {
155
- if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
156
- else process.kill(child.pid, 'SIGTERM');
157
- } catch {}
158
- }
159
- throw e;
160
- } finally {
161
- rmSafe(BOOT_LOCK);
162
- }
163
-
164
- await acquireLease();
165
- return { baseUrl, stopIfLast: makeStopIfLast(child.pid, baseUrl), pid: child.pid };
166
- }
167
-
168
- function makeStopIfLast(serverPid: number, baseUrl: string) {
169
- return async () => {
170
- const rest = countValidLeases();
171
- if (rest > 0) return;
172
- let healthy = false;
173
- try { await waitHealthy(baseUrl, 800); healthy = true; } catch {}
174
- if (healthy && serverPid && isPidAlive(serverPid)) {
175
- try {
176
- if (process.platform === 'win32') spawn('taskkill', ['/PID', String(serverPid), '/T', '/F']);
177
- else process.kill(serverPid, 'SIGTERM');
178
- } catch {}
179
- }
180
- rmSafe(LOCK_JSON);
181
- };
182
- }
1
+ // server/ensureSingletonLocalServer.ts
2
+ import { spawn } from 'child_process';
3
+ import fs from 'fs';
4
+ import fsp from 'fs/promises';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import axios from 'axios';
8
+
9
+ type Handle = {
10
+ baseUrl: string;
11
+ stopIfLast: () => Promise<void>;
12
+ pid?: number;
13
+ };
14
+
15
+ const RUNTIME_DIR = path.join(os.homedir(), '.claude-cli', 'runtime');
16
+ const LOCK_JSON = path.join(RUNTIME_DIR, 'server.lock.json');
17
+ const BOOT_LOCK = path.join(RUNTIME_DIR, 'server.boot.lock');
18
+ const LEASES_DIR = path.join(RUNTIME_DIR, 'leases');
19
+
20
+ const DEFAULT_HOST = '127.0.0.1';
21
+ const DEFAULT_PORT = Number(process.env.SERVER_PORT || 3456);
22
+ const HEALTH_TIMEOUT_MS = Number(process.env.HEALTH_TIMEOUT_MS || 20000);
23
+ const HEALTH_RETRY_MS = 300;
24
+
25
+ function mkdirp(p: string) { fs.mkdirSync(p, { recursive: true }); }
26
+ function atomicCreate(p: string): boolean {
27
+ try { const fd = fs.openSync(p, 'wx'); fs.closeSync(fd); return true; } catch { return false; }
28
+ }
29
+ function rmSafe(p: string) { try { fs.rmSync(p, { force: true, recursive: true }); } catch {} }
30
+ function readJson<T>(p: string): T | null { try { return JSON.parse(fs.readFileSync(p, 'utf-8')) as T; } catch { return null; } }
31
+ function writeJson(p: string, data: any) { fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); }
32
+ function isPidAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
33
+ async function waitHealthy(baseUrl: string, timeoutMs: number) {
34
+ const start = Date.now(); let lastErr: any = null;
35
+ while (Date.now() - start < timeoutMs) {
36
+ try {
37
+ const r = await axios.get(baseUrl.replace(/\/+$/, '') + '/health', { timeout: 1500 });
38
+ if (r.status === 200 && r.data?.status === 'ok') return;
39
+ } catch (e) { lastErr = e; }
40
+ await new Promise(r => setTimeout(r, HEALTH_RETRY_MS));
41
+ }
42
+ throw new Error(`Health check timeout: ${lastErr?.message || 'unknown'}`);
43
+ }
44
+ function resolveBunPath(): string {
45
+ // 优先环境变量
46
+ const fromEnv = process.env.BUN_PATH;
47
+ if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
48
+
49
+ // Windows:纯文件系统查找 bun.exe,无子进程,无 DEP0190 警告
50
+ if (process.platform === 'win32') {
51
+ const home = os.homedir();
52
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
53
+ const candidates = [
54
+ path.join(appData, 'npm', 'node_modules', 'bun', 'bin', 'bun.exe'),
55
+ path.join(home, '.bun', 'bin', 'bun.exe'),
56
+ ];
57
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
58
+ candidates.push(path.join(dir, 'bun.exe'));
59
+ }
60
+ for (const c of candidates) {
61
+ try { if (fs.existsSync(c)) return c; } catch (_) {}
62
+ }
63
+ } else {
64
+ // Linux/macOS:遍历 PATH 查找 bun
65
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
66
+ const c = path.join(dir, 'bun');
67
+ try { if (fs.existsSync(c)) return c; } catch (_) {}
68
+ }
69
+ }
70
+
71
+ throw new Error('Bun not detected. Please install via: npm install -g bun');
72
+ }
73
+
74
+ async function acquireLease(): Promise<string> {
75
+ mkdirp(LEASES_DIR);
76
+ const lease = path.join(LEASES_DIR, `${process.pid}.json`);
77
+ await fsp.writeFile(lease, JSON.stringify({ pid: process.pid, ts: Date.now() }));
78
+ const cleanup = () => rmSafe(lease);
79
+ process.once('exit', cleanup);
80
+ process.once('SIGINT', () => { cleanup(); process.exit(130); });
81
+ process.once('SIGTERM', () => { cleanup(); process.exit(143); });
82
+ return lease;
83
+ }
84
+ function countValidLeases(): number {
85
+ try {
86
+ const files = fs.readdirSync(LEASES_DIR);
87
+ let alive = 0;
88
+ for (const f of files) {
89
+ const p = path.join(LEASES_DIR, f);
90
+ const data = readJson<{pid:number, ts:number}>(p);
91
+ if (data?.pid && isPidAlive(data.pid)) alive++;
92
+ else rmSafe(p);
93
+ }
94
+ return alive;
95
+ } catch { return 0; }
96
+ }
97
+
98
+ export async function ensureSingletonLocalServer(opts: {
99
+ serverEntry: string;
100
+ host?: string;
101
+ port?: number;
102
+ baseUrlEnv?: string;
103
+ passEnv?: Record<string, string>;
104
+ }): Promise<Handle> {
105
+ const preset = (opts.baseUrlEnv || process.env.BASE_API_URL || '').trim();
106
+ if (preset) {
107
+ try { await waitHealthy(preset, 3000); } catch {}
108
+ await acquireLease();
109
+ return { baseUrl: preset.replace(/\/+$/, ''), stopIfLast: async () => {} };
110
+ }
111
+
112
+ const host = opts.host || DEFAULT_HOST;
113
+ const port = Number(opts.port ?? DEFAULT_PORT);
114
+ const baseUrl = `http://${host}:${port}`;
115
+ mkdirp(RUNTIME_DIR);
116
+
117
+ const lock = readJson<{ pid:number, port:number }>(LOCK_JSON);
118
+ if (lock && lock.port === port) {
119
+ const healthy = await (async () => { try { await waitHealthy(baseUrl, 1200); return true; } catch { return false; } })();
120
+ if (healthy && isPidAlive(lock.pid)) {
121
+ await acquireLease();
122
+ return { baseUrl, stopIfLast: makeStopIfLast(lock.pid, baseUrl), pid: lock.pid };
123
+ }
124
+ rmSafe(LOCK_JSON);
125
+ }
126
+
127
+ const iAmSpawner = atomicCreate(BOOT_LOCK);
128
+ if (!iAmSpawner) {
129
+ try { await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS); } catch {
130
+ rmSafe(BOOT_LOCK); rmSafe(LOCK_JSON);
131
+ return await ensureSingletonLocalServer(opts);
132
+ }
133
+ await acquireLease();
134
+ const live = readJson<{pid:number}>(LOCK_JSON);
135
+ return { baseUrl, stopIfLast: makeStopIfLast(live?.pid || 0, baseUrl), pid: live?.pid };
136
+ }
137
+
138
+ let child: any = null;
139
+ try {
140
+ const bun = resolveBunPath();
141
+ child = spawn(
142
+ bun,
143
+ [opts.serverEntry, '--host', host, '--port', String(port)],
144
+ {
145
+ env: { ...process.env, SERVER_AUTH_REQUIRED: '0', ...(opts.passEnv || {}) },
146
+ stdio: 'ignore',
147
+ detached: true,
148
+ }
149
+ );
150
+ child.unref();
151
+ await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS);
152
+ writeJson(LOCK_JSON, { pid: child.pid, port, startedAt: new Date().toISOString() });
153
+ } catch (e) {
154
+ if (child?.pid) {
155
+ try {
156
+ if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F']);
157
+ else process.kill(child.pid, 'SIGTERM');
158
+ } catch {}
159
+ }
160
+ throw e;
161
+ } finally {
162
+ rmSafe(BOOT_LOCK);
163
+ }
164
+
165
+ await acquireLease();
166
+ return { baseUrl, stopIfLast: makeStopIfLast(child.pid, baseUrl), pid: child.pid };
167
+ }
168
+
169
+ function makeStopIfLast(_serverPid: number, _baseUrl: string) {
170
+ return async () => {
171
+ // no-op: only POST /exit from tray can stop server
172
+ };
173
+ }
@@ -1,260 +0,0 @@
1
- import React, { useState, useEffect, useMemo } from 'react';
2
- import { Box, Text, useInput } from 'ink';
3
- import { StateDisplay, ScrollBar, safePadEnd } from '../manager/CliMenuUi.tsx';
4
- import { aggregateClaudeCodeStats, type ClaudeCodeStats, type DailyModelTokens } from '../utils/stats.ts';
5
- import { formatNumber } from '../utils/format.js';
6
-
7
- // Get ISO week number (Monday-based)
8
- function getWeekNumber(dateStr: string): string {
9
- const date = new Date(dateStr);
10
- date.setHours(0, 0, 0, 0);
11
- date.setDate(date.getDate() + 4 - (date.getDay() || 7));
12
- const yearStart = new Date(date.getFullYear(), 0, 1);
13
- const weekNo = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
14
- return `${date.getFullYear()}-W${String(weekNo).padStart(2, '0')}`;
15
- }
16
-
17
- // Get month key (YYYY-MM)
18
- function getMonthKey(dateStr: string): string {
19
- return dateStr.slice(0, 7);
20
- }
21
-
22
- // Aggregate daily tokens by week
23
- function aggregateByWeek(dailyTokens: DailyModelTokens[]): DailyModelTokens[] {
24
- const weekMap = new Map<string, { [model: string]: number }>();
25
-
26
- for (const day of dailyTokens) {
27
- const weekKey = getWeekNumber(day.date);
28
- const existing = weekMap.get(weekKey) || {};
29
-
30
- for (const [model, tokens] of Object.entries(day.tokensByModel)) {
31
- existing[model] = (existing[model] || 0) + tokens;
32
- }
33
-
34
- weekMap.set(weekKey, existing);
35
- }
36
-
37
- return Array.from(weekMap.entries())
38
- .map(([date, tokensByModel]) => ({ date, tokensByModel }))
39
- .sort((a, b) => a.date.localeCompare(b.date));
40
- }
41
-
42
- // Aggregate daily tokens by month
43
- function aggregateByMonth(dailyTokens: DailyModelTokens[]): DailyModelTokens[] {
44
- const monthMap = new Map<string, { [model: string]: number }>();
45
-
46
- for (const day of dailyTokens) {
47
- const monthKey = getMonthKey(day.date);
48
- const existing = monthMap.get(monthKey) || {};
49
-
50
- for (const [model, tokens] of Object.entries(day.tokensByModel)) {
51
- existing[model] = (existing[model] || 0) + tokens;
52
- }
53
-
54
- monthMap.set(monthKey, existing);
55
- }
56
-
57
- return Array.from(monthMap.entries())
58
- .map(([date, tokensByModel]) => ({ date, tokensByModel }))
59
- .sort((a, b) => a.date.localeCompare(b.date));
60
- }
61
-
62
- // Get last N days
63
- function getLastNDays(dailyTokens: DailyModelTokens[], n: number = 14): DailyModelTokens[] {
64
- const sorted = [...dailyTokens].sort((a, b) => b.date.localeCompare(a.date));
65
- return sorted.slice(0, n);
66
- }
67
-
68
- type TimeRange = 'day' | 'week' | 'month' | 'total';
69
-
70
- type Props = {
71
- width: number;
72
- height: number;
73
- onBack: () => void;
74
- };
75
-
76
- export const TokenStatsPanel: React.FC<Props> = ({ width, height, onBack }) => {
77
- const [loading, setLoading] = useState(true);
78
- const [error, setError] = useState<string | null>(null);
79
- const [stats, setStats] = useState<ClaudeCodeStats | null>(null);
80
- const [timeRange, setTimeRange] = useState<TimeRange>('total');
81
- const [activeButton, setActiveButton] = useState(3); // 0=day, 1=week, 2=month, 3=total
82
-
83
- useEffect(() => {
84
- let cancelled = false;
85
- setLoading(true);
86
- setError(null);
87
- aggregateClaudeCodeStats()
88
- .then(data => {
89
- if (!cancelled) {
90
- setStats(data);
91
- setLoading(false);
92
- }
93
- })
94
- .catch(err => {
95
- if (!cancelled) {
96
- setError(err.message || String(err));
97
- setLoading(false);
98
- }
99
- });
100
- return () => { cancelled = true; };
101
- }, []);
102
-
103
- // Handle keyboard input for time range switching
104
- useInput((input, key) => {
105
- if (input === '1') {
106
- setTimeRange('day');
107
- setActiveButton(0);
108
- } else if (input === '2') {
109
- setTimeRange('week');
110
- setActiveButton(1);
111
- } else if (input === '3') {
112
- setTimeRange('month');
113
- setActiveButton(2);
114
- } else if (input === '4') {
115
- setTimeRange('total');
116
- setActiveButton(3);
117
- } else if (key.leftArrow && activeButton > 0) {
118
- const newButton = activeButton - 1;
119
- setActiveButton(newButton);
120
- setTimeRange(['day', 'week', 'month', 'total'][newButton] as TimeRange);
121
- } else if (key.rightArrow && activeButton < 3) {
122
- const newButton = activeButton + 1;
123
- setActiveButton(newButton);
124
- setTimeRange(['day', 'week', 'month', 'total'][newButton] as TimeRange);
125
- }
126
- });
127
-
128
- if (loading) {
129
- return <StateDisplay type="loading" message="Loading token stats..." />;
130
- }
131
- if (error) {
132
- return <StateDisplay type="error" message={`Failed to load: ${error}`} />;
133
- }
134
- if (!stats || !stats.modelUsage || Object.keys(stats.modelUsage).length === 0) {
135
- return <StateDisplay type="empty" message="No token data yet. Run some conversations first!" />;
136
- }
137
-
138
- // Calculate column widths
139
- const MODEL_COL = 28;
140
- const NUM_COL = 14;
141
- const DATE_COL = 12;
142
- const visible = height - 4; // leave room for header row + buttons + bottom hint
143
-
144
- // Prepare data based on time range
145
- let isDateView = timeRange !== 'total';
146
-
147
- const formatCount = (n: number) => formatNumber(n);
148
-
149
- // Time range buttons
150
- const timeButtons = [
151
- { label: 'Day', key: '1' },
152
- { label: 'Week', key: '2' },
153
- { label: 'Month', key: '3' },
154
- { label: 'Total', key: '4' },
155
- ];
156
-
157
- return (
158
- <Box width={width} height={height} flexDirection="column">
159
- {/* Time range buttons */}
160
- <Box marginBottom={1}>
161
- {timeButtons.map((btn, idx) => (
162
- <Text key={btn.key} color={activeButton === idx ? 'cyan' : 'white'}>
163
- [{btn.label}] {btn.key}
164
- {idx < timeButtons.length - 1 ? ' ' : ''}
165
- </Text>
166
- ))}
167
- </Box>
168
-
169
- <Box flexDirection="column" flexGrow={1}>
170
- {isDateView ? (
171
- <>
172
- <Text bold>
173
- {safePadEnd('Date', DATE_COL)}
174
- {safePadEnd('Model', MODEL_COL)}
175
- {safePadEnd('Total Tokens', NUM_COL)}
176
- </Text>
177
- <Text dimColor>{'─'.repeat(width - 4)}</Text>
178
- <Box flexDirection="row" flexGrow={1} position="relative">
179
- <Box flexDirection="column" flexGrow={1}>
180
- {(() => {
181
- // Date-based views - aggregate dailyModelTokens
182
- let aggregated: DailyModelTokens[];
183
- if (timeRange === 'day') {
184
- aggregated = getLastNDays(stats.dailyModelTokens, 14);
185
- } else if (timeRange === 'week') {
186
- aggregated = aggregateByWeek(stats.dailyModelTokens);
187
- } else {
188
- // month
189
- aggregated = aggregateByMonth(stats.dailyModelTokens);
190
- }
191
-
192
- return aggregated.flatMap(day =>
193
- Object.entries(day.tokensByModel).map(([model, tokens]) => {
194
- const row = [
195
- safePadEnd(day.date, DATE_COL),
196
- safePadEnd(model.length > MODEL_COL - 3 ? model.slice(0, MODEL_COL - 4) + '…' : model, MODEL_COL),
197
- safePadEnd(formatCount(tokens), NUM_COL),
198
- ].join('');
199
- return <Text key={`${day.date}-${model}`}>{row}</Text>;
200
- })
201
- );
202
- })()}
203
- </Box>
204
- <ScrollBar
205
- total={(() => {
206
- let aggregated: DailyModelTokens[];
207
- if (timeRange === 'day') {
208
- aggregated = getLastNDays(stats.dailyModelTokens, 14);
209
- } else if (timeRange === 'week') {
210
- aggregated = aggregateByWeek(stats.dailyModelTokens);
211
- } else {
212
- aggregated = aggregateByMonth(stats.dailyModelTokens);
213
- }
214
- return aggregated.flatMap(day => Object.keys(day.tokensByModel)).length;
215
- })()}
216
- offset={0}
217
- height={visible - 2}
218
- />
219
- </Box>
220
- </>
221
- ) : (
222
- <>
223
- <Text bold>
224
- {safePadEnd('Model', MODEL_COL)}
225
- {safePadEnd('Input Tokens', NUM_COL)}
226
- {safePadEnd('Output Tokens', NUM_COL)}
227
- {safePadEnd('Cache Read', NUM_COL)}
228
- {safePadEnd('Cache Create', NUM_COL)}
229
- </Text>
230
- <Text dimColor>{'─'.repeat(width - 4)}</Text>
231
- <Box flexDirection="row" flexGrow={1} position="relative">
232
- <Box flexDirection="column" flexGrow={1}>
233
- {Object.entries(stats.modelUsage).sort(
234
- (a, b) => (b[1].inputTokens + b[1].outputTokens) - (a[1].inputTokens + a[1].outputTokens)
235
- ).map(([model, usage]) => {
236
- const row = [
237
- safePadEnd(model.length > MODEL_COL - 3 ? model.slice(0, MODEL_COL - 4) + '…' : model, MODEL_COL),
238
- safePadEnd(formatCount(usage.inputTokens), NUM_COL),
239
- safePadEnd(formatCount(usage.outputTokens), NUM_COL),
240
- safePadEnd(formatCount(usage.cacheReadInputTokens), NUM_COL),
241
- safePadEnd(formatCount(usage.cacheCreationInputTokens), NUM_COL),
242
- ].join('');
243
- return <Text key={model}>{row}</Text>;
244
- })}
245
- </Box>
246
- <ScrollBar
247
- total={Object.keys(stats.modelUsage).length}
248
- offset={0}
249
- height={visible - 2}
250
- />
251
- </Box>
252
- </>
253
- )}
254
- </Box>
255
- <Text dimColor>ESC back · 1-4 or ←→ to switch view</Text>
256
- </Box>
257
- );
258
- };
259
-
260
- export default TokenStatsPanel;