claude-rpc 0.20.3 → 0.21.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/SECURITY.md +38 -11
- package/package.json +1 -1
- package/src/daemon.js +13 -4
- package/src/install.js +174 -11
- package/src/paths.js +4 -0
- package/src/tui.js +387 -275
- package/src/version.js +1 -1
package/SECURITY.md
CHANGED
|
@@ -16,7 +16,7 @@ fetch-and-execute anywhere in `src/`.
|
|
|
16
16
|
|
|
17
17
|
| Behavior | Where | Scope | Reversible? |
|
|
18
18
|
| --- | --- | --- | --- |
|
|
19
|
-
| Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal
|
|
19
|
+
| Startup persistence | `src/install.js` → `addStartupEntry` (Windows Run key, macOS LaunchAgent, Linux `systemd --user`) + `selfHealOnUpdate` (installs it on update); `src/hook.js` → `ensureDaemonRunning` (SessionStart self-heal) | Per-user login autostart (no admin/root/system service) + a detached daemon (re)launched when you start a Claude Code session | Yes — `claude-rpc uninstall` / `removeStartupEntry`; disable every auto-start path with `autostart:false` |
|
|
20
20
|
| Hook injection | `src/install.js` → `installHooks` | Only into Claude Code's own `settings.json`, only our own commands | Yes — `uninstallHooks` removes exactly what it added |
|
|
21
21
|
| Outbound network | `src/community.js`, `src/gist.js`, `src/usage.js`, `src/notify.js`, `default-config.js` | Anonymous counters + (opt-in) profile/gist/webhook + own read-only OAuth-usage poll + GIF assets | Telemetry: `community off`. Profile: `profile off`. Gist/webhook: opt-in only. Usage: `usage.enabled:false`. |
|
|
22
22
|
| Local subprocess | `reg.exe`, `wscript`, the daemon itself (`node`/packaged exe — via the Run key and the SessionStart self-heal), `git`, `gh`, `npm`, `claude`, `security`, notifiers | Static or escaped args, no shell interpolation of untrusted input | n/a |
|
|
@@ -25,9 +25,10 @@ No credential access beyond the read-only Claude Code OAuth-token read for usage
|
|
|
25
25
|
polling (§3d), no filesystem scanning outside `~/.claude-rpc` and Claude Code
|
|
26
26
|
transcripts, no keylogging, no clipboard access, no AV/EDR evasion.
|
|
27
27
|
|
|
28
|
-
## 1. Startup persistence (
|
|
28
|
+
## 1. Startup persistence (login autostart, all platforms)
|
|
29
29
|
|
|
30
|
-
**Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry
|
|
30
|
+
**Source:** `src/install.js`, `addStartupEntry` / `removeStartupEntry`; the
|
|
31
|
+
on-update install path lives in `selfHealOnUpdate`.
|
|
31
32
|
|
|
32
33
|
On Windows, `setup` writes one value:
|
|
33
34
|
|
|
@@ -47,16 +48,38 @@ HKCU\Software\Microsoft\Windows\CurrentVersion\Run
|
|
|
47
48
|
not at wherever you happened to run the installer from — see
|
|
48
49
|
`ensureCanonicalExe`.
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
**macOS and Linux** now get login autostart too, at per-user parity with Windows:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
macOS ~/Library/LaunchAgents/com.claude-rpc.daemon.plist (launchctl, RunAtLoad)
|
|
55
|
+
Linux ~/.config/systemd/user/claude-rpc.service (systemctl --user enable)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- **Scope:** per-user only — a user LaunchAgent / `systemctl --user` unit, never
|
|
59
|
+
a root/system daemon, LaunchDaemon, or system-wide service. No admin, no `sudo`.
|
|
60
|
+
- **Reverse it:** `claude-rpc uninstall` removes the plist/unit and unloads it
|
|
61
|
+
(`removeStartupEntry`); or delete the file and `launchctl unload` /
|
|
62
|
+
`systemctl --user disable` by hand.
|
|
63
|
+
|
|
64
|
+
**A login-autostart entry can appear on UPDATE, not only at `setup`.** Because
|
|
65
|
+
npm runs no install script (see intro), an `npm update` reaches you through the
|
|
66
|
+
daemon: the first time it starts after an update, `selfHealOnUpdate` re-wires
|
|
67
|
+
hooks, migrates config, **and installs the login-autostart entry above** so the
|
|
68
|
+
daemon comes up at login on the new version. This is the one place a
|
|
69
|
+
background-start entry is created without an explicit `setup`. It is gated by
|
|
70
|
+
`autostart` (default on) — set `autostart:false` to opt out of every auto-start
|
|
71
|
+
path — and is best-effort: if `launchctl`/`systemctl`/`reg` fails, nothing
|
|
72
|
+
breaks (the session self-heal below still covers startup).
|
|
73
|
+
|
|
74
|
+
Underneath all of this, on every platform, the `SessionStart` hook also
|
|
75
|
+
**self-heals** the daemon: if none is running when you start a Claude Code
|
|
76
|
+
session, the hook spawns one (detached, windowless), so presence is restored
|
|
77
|
+
after a reboot, crash, or OS sleep exactly when you next use Claude. It is the
|
|
78
|
+
same long-lived daemon launched the same way (`src/ensure-daemon.js` →
|
|
56
79
|
`spawnDaemonDetached`), a no-op when a daemon is already running, and
|
|
57
80
|
cooldown-guarded against spawn storms; the daemon's atomic single-instance claim
|
|
58
|
-
reaps any duplicate. Disable
|
|
59
|
-
the daemon yourself via `claude-rpc start` / `stop`).
|
|
81
|
+
reaps any duplicate. Disable everything with `autostart:false` in config (then
|
|
82
|
+
manage the daemon yourself via `claude-rpc start` / `stop`).
|
|
60
83
|
|
|
61
84
|
## 2. Hook injection into Claude Code
|
|
62
85
|
|
|
@@ -256,6 +279,10 @@ untrusted or remote input into a shell:
|
|
|
256
279
|
|
|
257
280
|
- `reg.exe add/delete` — the Windows Run key (`src/install.js`).
|
|
258
281
|
- `wscript.exe` — runs the generated windowless startup shim (`src/install.js`).
|
|
282
|
+
- `launchctl load/unload` — the macOS LaunchAgent (`src/install.js`); args are
|
|
283
|
+
the fixed plist path.
|
|
284
|
+
- `systemctl --user enable/disable/daemon-reload` — the Linux user service
|
|
285
|
+
(`src/install.js`); args are the fixed unit name.
|
|
259
286
|
- `chcp.com 65001` — set the console to UTF-8 on Windows TTYs (`src/cli.js`).
|
|
260
287
|
- `git` — read last commit subject / branch for the "just shipped" card
|
|
261
288
|
(`src/git.js`).
|
package/package.json
CHANGED
package/src/daemon.js
CHANGED
|
@@ -10,7 +10,8 @@ import { detectGithubUrl } from './git.js';
|
|
|
10
10
|
import { applyPrivacy } from './privacy.js';
|
|
11
11
|
import { pauseUntil } from './pause.js';
|
|
12
12
|
import { loadConfig } from './config.js';
|
|
13
|
-
import {
|
|
13
|
+
import { selfHealOnUpdate } from './install.js';
|
|
14
|
+
import { VERSION } from './version.js';
|
|
14
15
|
import { desktopNotify, postWebhook, shouldWebhook, shouldNotify, sanitizeLabel } from './notify.js';
|
|
15
16
|
import { humanProject } from './format.js';
|
|
16
17
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH, PAUSE_PATH } from './paths.js';
|
|
@@ -79,12 +80,20 @@ function loadConfigWithLog() {
|
|
|
79
80
|
// move. Idempotent: only writes when something actually changes, so steady-state
|
|
80
81
|
// restarts are a no-op and can't loop the config watcher. Best-effort — a
|
|
81
82
|
// migration failure must never stop the daemon from starting.
|
|
83
|
+
// Self-heal on update — when this daemon is a newer version than the last one
|
|
84
|
+
// that ran, re-wire hooks (their command/path drifts across versions) and
|
|
85
|
+
// migrate config to the current shape, then stamp the version. This is how an
|
|
86
|
+
// `npm update` reaches users who never re-run setup: there is no install script
|
|
87
|
+
// (by design), so the daemon — itself brought up by the SessionStart self-heal —
|
|
88
|
+
// carries the update forward. A since-0.1.0 install converges to the current
|
|
89
|
+
// hooks + config the first time it's used after updating.
|
|
82
90
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
const healed = selfHealOnUpdate({ silent: true });
|
|
92
|
+
if (healed.changed) {
|
|
93
|
+
log(`self-heal on update: ${healed.from || 'fresh'} → ${VERSION} (hooks ${healed.rewired ? 're-wired' : 'already current'}, config migrated${healed.autostart ? ', login autostart ensured' : ''})`);
|
|
85
94
|
}
|
|
86
95
|
} catch (e) {
|
|
87
|
-
log('startup
|
|
96
|
+
log('startup self-heal failed (continuing):', e?.message || String(e));
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
let config = loadConfigWithLog();
|
package/src/install.js
CHANGED
|
@@ -8,12 +8,14 @@ import {
|
|
|
8
8
|
readdirSync, unlinkSync,
|
|
9
9
|
} from 'node:fs';
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
11
12
|
import { spawn, spawnSync } from 'node:child_process';
|
|
12
13
|
import { randomUUID } from 'node:crypto';
|
|
13
14
|
import {
|
|
14
15
|
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
|
|
15
16
|
HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL, IS_NPX,
|
|
16
|
-
CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME,
|
|
17
|
+
CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME, VERSION_STAMP,
|
|
18
|
+
DAEMON_SCRIPT,
|
|
17
19
|
} from './paths.js';
|
|
18
20
|
import { DEFAULT_CONFIG } from './default-config.js';
|
|
19
21
|
import { VERSION } from './version.js';
|
|
@@ -83,7 +85,7 @@ export function isOurHook(h) {
|
|
|
83
85
|
return !!h && (h._claudeRpc === true || isOurHookCommand(h.command));
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
export function installHooks(exePath) {
|
|
88
|
+
export function installHooks(exePath, { silent = false } = {}) {
|
|
87
89
|
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
88
90
|
const before = JSON.stringify(settings.hooks || {});
|
|
89
91
|
settings.hooks = settings.hooks || {};
|
|
@@ -118,11 +120,11 @@ export function installHooks(exePath) {
|
|
|
118
120
|
}
|
|
119
121
|
}
|
|
120
122
|
if (JSON.stringify(settings.hooks) === before) {
|
|
121
|
-
noop(`hooks wired (${EVENTS.length} events)`);
|
|
123
|
+
if (!silent) noop(`hooks wired (${EVENTS.length} events)`);
|
|
122
124
|
return false;
|
|
123
125
|
}
|
|
124
126
|
writeJson(CLAUDE_SETTINGS, settings);
|
|
125
|
-
dirtyStep(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
|
|
127
|
+
if (!silent) dirtyStep(SYM_OK, 'hooks wired', `${EVENTS.length} events → ${CLAUDE_SETTINGS}`);
|
|
126
128
|
return true;
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -153,7 +155,129 @@ function regCommand(args) {
|
|
|
153
155
|
|
|
154
156
|
const STARTUP_VBS = join(CANONICAL_INSTALL_DIR, 'claude-rpc-daemon.vbs');
|
|
155
157
|
|
|
158
|
+
// macOS LaunchAgent + Linux systemd --user paths/labels.
|
|
159
|
+
const LAUNCHD_LABEL = 'com.claude-rpc.daemon';
|
|
160
|
+
const LAUNCHD_PLIST = join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
161
|
+
const SYSTEMD_UNIT_NAME = 'claude-rpc.service';
|
|
162
|
+
const SYSTEMD_UNIT = join(homedir(), '.config', 'systemd', 'user', SYSTEMD_UNIT_NAME);
|
|
163
|
+
|
|
164
|
+
// The daemon launch command, shared by every autostart mechanism so login/boot
|
|
165
|
+
// starts the daemon exactly like `start` and the hook do:
|
|
166
|
+
// packaged → "<exe>" daemon ; npm/dev → "<abs node>" "<abs daemon.js>"
|
|
167
|
+
// (absolute node, like the hooks — login shells under nvm have no node on PATH).
|
|
168
|
+
function daemonLaunch(exePath) {
|
|
169
|
+
if (IS_PACKAGED) return { exe: exePath, args: ['daemon'] };
|
|
170
|
+
return { exe: process.execPath, args: [DAEMON_SCRIPT] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function xmlEscape(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
174
|
+
function systemctlUser(args) {
|
|
175
|
+
try { return spawnSync('systemctl', ['--user', ...args], { stdio: 'ignore' }); }
|
|
176
|
+
catch { return { status: 1 }; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Pure file-content builders — exported so the generated plist/unit are
|
|
180
|
+
// unit-testable without touching launchctl/systemctl or the real filesystem.
|
|
181
|
+
export function launchdPlist({ exe, args }) {
|
|
182
|
+
const progArgs = [exe, ...args].map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
|
183
|
+
return [
|
|
184
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
185
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
186
|
+
'<plist version="1.0">',
|
|
187
|
+
'<dict>',
|
|
188
|
+
` <key>Label</key><string>${LAUNCHD_LABEL}</string>`,
|
|
189
|
+
' <key>ProgramArguments</key>',
|
|
190
|
+
' <array>',
|
|
191
|
+
progArgs,
|
|
192
|
+
' </array>',
|
|
193
|
+
' <key>RunAtLoad</key><true/>',
|
|
194
|
+
// KeepAlive false: start at login, but don't fight a manual `claude-rpc stop`.
|
|
195
|
+
' <key>KeepAlive</key><false/>',
|
|
196
|
+
' <key>ProcessType</key><string>Background</string>',
|
|
197
|
+
'</dict>',
|
|
198
|
+
'</plist>',
|
|
199
|
+
'',
|
|
200
|
+
].join('\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function systemdUnit({ exe, args }) {
|
|
204
|
+
const execStart = [exe, ...args].map((a) => `"${a}"`).join(' '); // quote each token (paths with spaces)
|
|
205
|
+
return [
|
|
206
|
+
'[Unit]',
|
|
207
|
+
'Description=claude-rpc — Discord Rich Presence for Claude Code',
|
|
208
|
+
'After=default.target',
|
|
209
|
+
'',
|
|
210
|
+
'[Service]',
|
|
211
|
+
'Type=simple',
|
|
212
|
+
`ExecStart=${execStart}`,
|
|
213
|
+
// on-failure (not always): a clean exit when another daemon already owns the
|
|
214
|
+
// instance must NOT be restarted, and neither should a `claude-rpc stop`.
|
|
215
|
+
'Restart=on-failure',
|
|
216
|
+
'RestartSec=5',
|
|
217
|
+
'',
|
|
218
|
+
'[Install]',
|
|
219
|
+
'WantedBy=default.target',
|
|
220
|
+
'',
|
|
221
|
+
].join('\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── macOS: launchd LaunchAgent (per-user, starts at login) ────────────────────
|
|
225
|
+
function addStartupEntryMac(exePath) {
|
|
226
|
+
const plist = launchdPlist(daemonLaunch(exePath));
|
|
227
|
+
try {
|
|
228
|
+
mkdirSync(dirname(LAUNCHD_PLIST), { recursive: true });
|
|
229
|
+
writeFileSync(LAUNCHD_PLIST, plist);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
step(SYM_WARN, 'startup entry', `couldn't write LaunchAgent (${e.message}) — the session self-heal still starts the daemon`, console.warn);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// Load so it's active now and at every login. `load -w` is deprecated but
|
|
235
|
+
// portable across the macOS versions we target; a failure is non-fatal — the
|
|
236
|
+
// plist alone starts it next login, and SessionStart self-heal covers the gap.
|
|
237
|
+
try { spawnSync('launchctl', ['unload', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* not loaded yet */ }
|
|
238
|
+
try { spawnSync('launchctl', ['load', '-w', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* deferred to next login */ }
|
|
239
|
+
dirtyStep(SYM_OK, 'startup entry', `LaunchAgent ${LAUNCHD_LABEL} — daemon starts at login`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function removeStartupEntryMac() {
|
|
243
|
+
try { spawnSync('launchctl', ['unload', '-w', LAUNCHD_PLIST], { stdio: 'ignore' }); } catch { /* not loaded */ }
|
|
244
|
+
try { if (existsSync(LAUNCHD_PLIST)) { unlinkSync(LAUNCHD_PLIST); step(SYM_OK, 'startup entry', 'removed (LaunchAgent)'); } }
|
|
245
|
+
catch { /* already gone */ }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Linux: systemd --user service (starts at login) ───────────────────────────
|
|
249
|
+
function addStartupEntryLinux(exePath) {
|
|
250
|
+
const unit = systemdUnit(daemonLaunch(exePath));
|
|
251
|
+
try {
|
|
252
|
+
mkdirSync(dirname(SYSTEMD_UNIT), { recursive: true });
|
|
253
|
+
writeFileSync(SYSTEMD_UNIT, unit);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
step(SYM_WARN, 'startup entry', `couldn't write systemd unit (${e.message}) — the session self-heal still starts the daemon`, console.warn);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Enable + start now. Non-fatal if there's no systemd --user session (some
|
|
259
|
+
// containers / minimal WSL): the SessionStart self-heal still brings it up.
|
|
260
|
+
systemctlUser(['daemon-reload']);
|
|
261
|
+
const r = systemctlUser(['enable', '--now', SYSTEMD_UNIT_NAME]);
|
|
262
|
+
if (r && r.status === 0) dirtyStep(SYM_OK, 'startup entry', `systemd --user ${SYSTEMD_UNIT_NAME} — daemon starts at login`);
|
|
263
|
+
else dirtyStep(SYM_INFO, 'startup entry', `systemd unit written — enable with: systemctl --user enable --now ${SYSTEMD_UNIT_NAME}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function removeStartupEntryLinux() {
|
|
267
|
+
systemctlUser(['disable', '--now', SYSTEMD_UNIT_NAME]);
|
|
268
|
+
try { if (existsSync(SYSTEMD_UNIT)) { unlinkSync(SYSTEMD_UNIT); step(SYM_OK, 'startup entry', 'removed (systemd unit)'); } }
|
|
269
|
+
catch { /* already gone */ }
|
|
270
|
+
systemctlUser(['daemon-reload']);
|
|
271
|
+
}
|
|
272
|
+
|
|
156
273
|
export async function addStartupEntry(exePath) {
|
|
274
|
+
if (process.platform === 'darwin') return addStartupEntryMac(exePath);
|
|
275
|
+
if (process.platform === 'linux') return addStartupEntryLinux(exePath);
|
|
276
|
+
if (process.platform !== 'win32') {
|
|
277
|
+
if (runDirty) step(SYM_INFO, 'startup entry', `skipped — no login-autostart for ${process.platform}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// ── Windows: HKCU Run-key + windowless .vbs shim ──
|
|
157
281
|
// The packaged exe is a console-subsystem node.exe, so a bare Run-key entry
|
|
158
282
|
// (`"<exe>" daemon`) makes Explorer pop a console window at every login that
|
|
159
283
|
// persists for the daemon's whole (weeks-long) life — closing it kills the
|
|
@@ -178,6 +302,9 @@ export async function addStartupEntry(exePath) {
|
|
|
178
302
|
}
|
|
179
303
|
|
|
180
304
|
export async function removeStartupEntry() {
|
|
305
|
+
if (process.platform === 'darwin') return removeStartupEntryMac();
|
|
306
|
+
if (process.platform === 'linux') return removeStartupEntryLinux();
|
|
307
|
+
if (process.platform !== 'win32') return;
|
|
181
308
|
try {
|
|
182
309
|
await regCommand(['delete', STARTUP_KEY, '/v', STARTUP_VALUE, '/f']);
|
|
183
310
|
step(SYM_OK, 'startup entry', 'removed');
|
|
@@ -557,6 +684,43 @@ function warnIfStale() {
|
|
|
557
684
|
} catch { /* offline or npm missing — a version check must never block setup */ }
|
|
558
685
|
}
|
|
559
686
|
|
|
687
|
+
// Self-heal an install across an update. There is NO npm install script (by
|
|
688
|
+
// design — `npm install` must run nothing), so an `npm update` swaps the files
|
|
689
|
+
// but re-wires nothing: a long-lived user keeps stale hook commands and old
|
|
690
|
+
// config and never gets the new behaviour. The daemon — itself brought up by the
|
|
691
|
+
// SessionStart self-heal — calls this on startup: when the stamped version
|
|
692
|
+
// differs from the running one, re-wire the hooks (their command/path drifts
|
|
693
|
+
// across versions) and migrate config to the current shape, then stamp the
|
|
694
|
+
// version. Best-effort and quiet by default; never throws into the caller.
|
|
695
|
+
// Returns { changed, from, rewired }.
|
|
696
|
+
export function selfHealOnUpdate({ exePath = null, silent = true } = {}) {
|
|
697
|
+
let from = null;
|
|
698
|
+
try { from = (readFileSync(VERSION_STAMP, 'utf8') || '').trim() || null; } catch { /* first run / unreadable — treat as needing a heal */ }
|
|
699
|
+
if (from === VERSION) return { changed: false, from, rewired: false };
|
|
700
|
+
const exe = exePath || ((IS_PACKAGED && existsSync(CANONICAL_EXE)) ? CANONICAL_EXE : process.execPath);
|
|
701
|
+
let rewired = false;
|
|
702
|
+
try { rewired = installHooks(exe, { silent }); } catch { /* best-effort — a hook re-wire must never block daemon startup */ }
|
|
703
|
+
try { migrateConfig({ silent }); } catch { /* best-effort */ }
|
|
704
|
+
// Carry the login autostart forward too — an update should leave the daemon
|
|
705
|
+
// coming up at login, not only on the next Claude Code session — unless the
|
|
706
|
+
// user opted out with autostart:false. Best-effort + fire-and-forget; a
|
|
707
|
+
// failure just falls back to the SessionStart self-heal. In the detached
|
|
708
|
+
// daemon, addStartupEntry's console output lands in ignored stdio. This is
|
|
709
|
+
// disclosed in SECURITY.md (a login-autostart service can appear on update).
|
|
710
|
+
let autostart = false;
|
|
711
|
+
try {
|
|
712
|
+
if (readJson(CONFIG_PATH, {}).autostart !== false) {
|
|
713
|
+
autostart = true;
|
|
714
|
+
Promise.resolve(addStartupEntry(exe)).catch(() => {});
|
|
715
|
+
}
|
|
716
|
+
} catch { /* best-effort */ }
|
|
717
|
+
try {
|
|
718
|
+
mkdirSync(dirname(VERSION_STAMP), { recursive: true });
|
|
719
|
+
writeFileSync(VERSION_STAMP, VERSION + '\n');
|
|
720
|
+
} catch { /* unwritable — we'll just self-heal again next start, which is harmless (idempotent) */ }
|
|
721
|
+
return { changed: true, from, rewired, autostart };
|
|
722
|
+
}
|
|
723
|
+
|
|
560
724
|
export async function install({ exePath, withStartup = true } = {}) {
|
|
561
725
|
resetRun();
|
|
562
726
|
console.log('');
|
|
@@ -604,12 +768,11 @@ export async function install({ exePath, withStartup = true } = {}) {
|
|
|
604
768
|
// row lands under this heading; setupOutro() then closes the screen.
|
|
605
769
|
phase('daemon');
|
|
606
770
|
if (withStartup) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
771
|
+
// Cross-platform login autostart: Windows Run-key, macOS LaunchAgent, Linux
|
|
772
|
+
// systemd --user. Best-effort — a failure leaves the SessionStart self-heal
|
|
773
|
+
// as the (already reliable) fallback, so setup never fails on this.
|
|
774
|
+
try { await addStartupEntry(target); }
|
|
775
|
+
catch (e) { step(SYM_WARN, 'startup entry', `failed: ${e.message} — session self-heal still covers it`, console.warn); }
|
|
613
776
|
}
|
|
614
777
|
// Nothing changed: the checklist above stayed silent, so say so in one line.
|
|
615
778
|
if (!runDirty && probe.ok) {
|
|
@@ -640,7 +803,7 @@ export async function uninstall() {
|
|
|
640
803
|
console.log(` ${c.bold}${c.magenta}◆ claude-rpc uninstall${c.reset}`);
|
|
641
804
|
console.log('');
|
|
642
805
|
uninstallHooks();
|
|
643
|
-
|
|
806
|
+
await removeStartupEntry();
|
|
644
807
|
console.log('');
|
|
645
808
|
console.log(` ${SYM_OK} ${c.bold}uninstalled${c.reset} — config at ${c.cyan}${USER_CONFIG_DIR}${c.reset} ${c.dim}left intact; delete it manually if you want.${c.reset}`);
|
|
646
809
|
console.log('');
|
package/src/paths.js
CHANGED
|
@@ -91,6 +91,10 @@ export const DATA_DIR = join(homedir(), '.claude-rpc');
|
|
|
91
91
|
export const AGGREGATE_PATH = join(DATA_DIR, 'aggregate.json');
|
|
92
92
|
export const SCAN_CACHE_PATH = join(DATA_DIR, 'scan-cache.json');
|
|
93
93
|
export const EVENTS_LOG_PATH = join(DATA_DIR, 'events.jsonl');
|
|
94
|
+
// Last claude-rpc version that ran the on-update self-heal (hook re-wire + config
|
|
95
|
+
// migrate). Persistent (under ~/.claude-rpc) so an update is detected across
|
|
96
|
+
// reboots — see install.selfHealOnUpdate.
|
|
97
|
+
export const VERSION_STAMP = join(DATA_DIR, 'version');
|
|
94
98
|
export const CLAUDE_HOME = join(homedir(), '.claude');
|
|
95
99
|
export const CLAUDE_PROJECTS = join(CLAUDE_HOME, 'projects');
|
|
96
100
|
export const CLAUDE_SETTINGS = join(CLAUDE_HOME, 'settings.json');
|
package/src/tui.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// Interactive terminal dashboard
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Interactive terminal dashboard — full-screen framed panels, keyboard tabs,
|
|
2
|
+
// live refresh. Uses the alternate screen buffer (restored on quit) and box
|
|
3
|
+
// drawing, so it targets modern terminals (Windows Terminal, iTerm, kitty,
|
|
4
|
+
// etc.). NO_COLOR is honored: color drops out, the box structure stays.
|
|
5
|
+
//
|
|
6
|
+
// renderFrame() is pure (data in → string out) so the layout is testable and
|
|
7
|
+
// previewable without a live TTY; startTui() owns the IO/loop.
|
|
6
8
|
|
|
7
9
|
import process from 'node:process';
|
|
8
10
|
import { readFileSync, existsSync } from 'node:fs';
|
|
9
11
|
import { readActiveState } from './state.js';
|
|
10
|
-
import { readAggregate, findLiveSessions,
|
|
11
|
-
import {
|
|
12
|
-
import { buildVars, applyIdle, humanProject } from './format.js';
|
|
12
|
+
import { readAggregate, findLiveSessions, dayKey } from './scanner.js';
|
|
13
|
+
import { buildVars, applyIdle, humanProject, fmtNum } from './format.js';
|
|
13
14
|
import { loadConfig } from './config.js';
|
|
14
15
|
import { PID_PATH } from './paths.js';
|
|
15
16
|
import { fmtCost } from './pricing.js';
|
|
@@ -17,25 +18,32 @@ import { heat } from './ui.js';
|
|
|
17
18
|
|
|
18
19
|
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
19
20
|
const ESC = '\x1b[';
|
|
20
|
-
const RESET = ESC + '0m';
|
|
21
21
|
const CLEAR = ESC + '2J' + ESC + 'H';
|
|
22
22
|
const HIDE_CURSOR = ESC + '?25l';
|
|
23
23
|
const SHOW_CURSOR = ESC + '?25h';
|
|
24
|
+
const ALT_ON = ESC + '?1049h'; // alternate screen buffer — restored on quit
|
|
25
|
+
const ALT_OFF = ESC + '?1049l';
|
|
24
26
|
|
|
27
|
+
// Color drops out entirely under NO_COLOR; box-drawing (not color) stays.
|
|
28
|
+
const COLOR = !process.env.NO_COLOR;
|
|
29
|
+
const sgr = (code) => (COLOR ? ESC + code : '');
|
|
25
30
|
const C = {
|
|
26
|
-
reset:
|
|
27
|
-
dim:
|
|
28
|
-
bold:
|
|
29
|
-
red:
|
|
30
|
-
green:
|
|
31
|
-
yellow:
|
|
32
|
-
magenta:
|
|
33
|
-
cyan:
|
|
34
|
-
gray:
|
|
31
|
+
reset: sgr('0m'),
|
|
32
|
+
dim: sgr('2m'),
|
|
33
|
+
bold: sgr('1m'),
|
|
34
|
+
red: sgr('31m'),
|
|
35
|
+
green: sgr('32m'),
|
|
36
|
+
yellow: sgr('33m'),
|
|
37
|
+
magenta: sgr('35m'),
|
|
38
|
+
cyan: sgr('36m'),
|
|
39
|
+
gray: sgr('90m'),
|
|
35
40
|
};
|
|
36
41
|
const ansiRe = /\x1b\[[0-9;]*m/g;
|
|
37
42
|
const visLen = (s) => String(s).replace(ansiRe, '').length;
|
|
38
43
|
|
|
44
|
+
// Box-drawing — rounded corners for the "serious" look.
|
|
45
|
+
const BX = { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│', sl: '├', sr: '┤' };
|
|
46
|
+
|
|
39
47
|
// ── Tabs ────────────────────────────────────────────────────────────────────
|
|
40
48
|
const TABS = [
|
|
41
49
|
{ key: 'now', label: 'Now' },
|
|
@@ -50,15 +58,124 @@ let currentTab = 0;
|
|
|
50
58
|
let refreshTimer = null;
|
|
51
59
|
let exiting = false;
|
|
52
60
|
|
|
53
|
-
// ──
|
|
54
|
-
|
|
61
|
+
// ── Width-aware string helpers ────────────────────────────────────────────────
|
|
62
|
+
// Truncate/pad a (possibly ANSI-coloured) string to exactly `w` visible columns.
|
|
63
|
+
// Padding resets colour first so trailing spaces never inherit a fill; truncation
|
|
64
|
+
// appends an ellipsis and a reset so colour can't bleed past the cut.
|
|
65
|
+
function fit(s, w) {
|
|
66
|
+
s = String(s);
|
|
67
|
+
const len = visLen(s);
|
|
68
|
+
if (len === w) return s;
|
|
69
|
+
if (len < w) return s + C.reset + ' '.repeat(w - len);
|
|
70
|
+
let out = '', vis = 0, i = 0;
|
|
71
|
+
while (i < s.length && vis < w - 1) {
|
|
72
|
+
if (s[i] === '\x1b') {
|
|
73
|
+
const m = s.indexOf('m', i);
|
|
74
|
+
if (m !== -1) { out += s.slice(i, m + 1); i = m + 1; continue; }
|
|
75
|
+
}
|
|
76
|
+
out += s[i]; vis++; i++;
|
|
77
|
+
}
|
|
78
|
+
return out + '…' + C.reset;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Heat-graded fill bar. `ratio` in 0..1 (or value/max). NO_COLOR-safe via heat().
|
|
82
|
+
function bar(ratio, w) {
|
|
83
|
+
const r = Math.max(0, Math.min(1, ratio || 0));
|
|
84
|
+
const filled = Math.max(0, Math.min(w, Math.round(r * w)));
|
|
85
|
+
return `${heat(r)}${'█'.repeat(filled)}${C.reset}${' '.repeat(w - filled)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Centre a (possibly ANSI) string within width `w` (left pad only; row() right-pads).
|
|
89
|
+
function center(s, w) {
|
|
90
|
+
const len = visLen(s);
|
|
91
|
+
if (len >= w) return s;
|
|
92
|
+
return ' '.repeat(Math.floor((w - len) / 2)) + s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Panels & columns ──────────────────────────────────────────────────────────
|
|
96
|
+
// A bordered panel of exact total width `w`, title embedded in the top border.
|
|
97
|
+
// Returns an array of lines (height = body.length + 2).
|
|
98
|
+
function panel(title, body, w) {
|
|
99
|
+
const out = [];
|
|
100
|
+
if (title) {
|
|
101
|
+
const fill = Math.max(0, w - 5 - visLen(title));
|
|
102
|
+
out.push(`${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)}${BX.tr}${C.reset}`);
|
|
103
|
+
} else {
|
|
104
|
+
out.push(`${C.gray}${BX.tl}${BX.h.repeat(w - 2)}${BX.tr}${C.reset}`);
|
|
105
|
+
}
|
|
106
|
+
for (const line of body) {
|
|
107
|
+
out.push(`${C.gray}${BX.v}${C.reset} ${fit(line, w - 4)} ${C.gray}${BX.v}${C.reset}`);
|
|
108
|
+
}
|
|
109
|
+
out.push(`${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Lay panel arrays side by side, padding shorter ones with blanks of their width.
|
|
114
|
+
function columns(panels, gap = 3) {
|
|
115
|
+
const hgt = Math.max(...panels.map((p) => p.length));
|
|
116
|
+
const ws = panels.map((p) => visLen(p[0] || ''));
|
|
117
|
+
const spacer = ' '.repeat(gap);
|
|
118
|
+
const out = [];
|
|
119
|
+
for (let i = 0; i < hgt; i++) {
|
|
120
|
+
out.push(panels.map((p, j) => (p[i] !== undefined ? p[i] : ' '.repeat(ws[j]))).join(spacer));
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Split a content width into two column widths (with a gap between).
|
|
126
|
+
function split2(cw, gap = 3) {
|
|
127
|
+
const tot = cw - gap;
|
|
128
|
+
const l = Math.floor(tot / 2);
|
|
129
|
+
return [l, tot - l];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// "label ............ value" justified to width `w` (panel content width).
|
|
133
|
+
function statRows(pairs, w) {
|
|
134
|
+
return pairs.map(([label, val]) => {
|
|
135
|
+
const pad = Math.max(1, w - visLen(label) - visLen(val));
|
|
136
|
+
return `${C.dim}${label}${C.reset}${' '.repeat(pad)}${val}`;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// "name ████████ value" justified to width `w` (panel content width).
|
|
141
|
+
function barRow(label, valStr, ratio, w) {
|
|
142
|
+
const labelW = Math.max(8, Math.min(16, Math.floor(w * 0.42)));
|
|
143
|
+
const valW = Math.max(5, Math.min(11, visLen(valStr)));
|
|
144
|
+
const barW = Math.max(3, w - labelW - valW - 2);
|
|
145
|
+
const name = String(label).length > labelW ? String(label).slice(0, labelW - 1) + '…' : String(label).padEnd(labelW);
|
|
146
|
+
return `${C.dim}${name}${C.reset} ${bar(ratio, barW)} ${C.cyan}${valStr.padStart(valW)}${C.reset}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function hms(ms) {
|
|
150
|
+
const h = (ms || 0) / 3_600_000;
|
|
151
|
+
if (h < 1) return `${Math.round(h * 60)}m`;
|
|
152
|
+
return h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function statusColor(s) {
|
|
156
|
+
return s === 'working' ? C.green
|
|
157
|
+
: s === 'thinking' ? C.yellow
|
|
158
|
+
: s === 'notification' ? C.magenta
|
|
159
|
+
: s === 'shipped' ? C.green
|
|
160
|
+
: s === 'stale' ? C.dim
|
|
161
|
+
: C.cyan;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function headline(title, value, sub) {
|
|
165
|
+
const v = value ? ` ${C.bold}${C.cyan}${value}${C.reset}` : '';
|
|
166
|
+
const s = sub ? ` ${C.dim}${sub}${C.reset}` : '';
|
|
167
|
+
return [`${C.bold}${title.toUpperCase()}${C.reset}${v}${s}`, ''];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Data ──────────────────────────────────────────────────────────────────────
|
|
171
|
+
export function loadSnapshot() {
|
|
55
172
|
let state = readActiveState();
|
|
56
173
|
state.liveSessions = findLiveSessions({ thresholdMs: 90_000 });
|
|
57
174
|
const config = loadConfig();
|
|
58
175
|
state = applyIdle(state, config);
|
|
59
176
|
const aggregate = readAggregate() || {};
|
|
60
177
|
const vars = buildVars(state, config, aggregate);
|
|
61
|
-
return { state, config, aggregate, vars };
|
|
178
|
+
return { state, config, aggregate, vars, pid: daemonPid() };
|
|
62
179
|
}
|
|
63
180
|
|
|
64
181
|
function daemonPid() {
|
|
@@ -70,293 +187,298 @@ function daemonPid() {
|
|
|
70
187
|
} catch { return null; }
|
|
71
188
|
}
|
|
72
189
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
function
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
function drawHeader(w) {
|
|
92
|
-
const pid = daemonPid();
|
|
93
|
-
const status = pid
|
|
94
|
-
? `${C.green}● running${C.reset} ${C.dim}pid ${pid}${C.reset}`
|
|
95
|
-
: `${C.red}○ not running${C.reset}`;
|
|
96
|
-
|
|
97
|
-
const title = `${C.bold}Claude RPC${C.reset}`;
|
|
98
|
-
// First line: title left, status right
|
|
99
|
-
const left = title;
|
|
100
|
-
const right = status;
|
|
101
|
-
const innerWidth = w - 4;
|
|
102
|
-
const padCount = Math.max(1, innerWidth - visLen(left) - visLen(right));
|
|
103
|
-
const line1 = ' ' + left + ' '.repeat(padCount) + right;
|
|
104
|
-
|
|
105
|
-
// Tabs line
|
|
106
|
-
const tabBits = TABS.map((t, i) => {
|
|
107
|
-
if (i === currentTab) return `${C.bold}${C.cyan}${t.label}${C.reset}`;
|
|
108
|
-
return `${C.dim}${t.label}${C.reset}`;
|
|
109
|
-
});
|
|
110
|
-
const tabs = tabBits.join(` ${C.gray}·${C.reset} `);
|
|
111
|
-
const line2 = ' ' + tabs;
|
|
112
|
-
|
|
113
|
-
return [line1, line2, ' ' + rule(w)];
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function drawFooter(w) {
|
|
117
|
-
const keys = `${C.dim}1-7 jump${C.reset} ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} ${C.gray}·${C.reset} ${C.dim}r refresh${C.reset} ${C.gray}·${C.reset} ${C.dim}q quit${C.reset}`;
|
|
118
|
-
return [' ' + rule(w), ' ' + keys];
|
|
190
|
+
const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
191
|
+
|
|
192
|
+
// Rolling last-7-days window (today + the 6 prior days), summed from byDay.
|
|
193
|
+
// Anchored at local noon so a day subtraction can't slip across a DST boundary.
|
|
194
|
+
function last7Days(byDay = {}) {
|
|
195
|
+
const days = [];
|
|
196
|
+
let totMs = 0, prompts = 0, tools = 0, sessions = 0, tokens = 0, cost = 0;
|
|
197
|
+
for (let i = 6; i >= 0; i--) {
|
|
198
|
+
const d = new Date(); d.setHours(12, 0, 0, 0); d.setDate(d.getDate() - i);
|
|
199
|
+
const key = dayKey(d.getTime());
|
|
200
|
+
const e = byDay[key] || {};
|
|
201
|
+
const ms = e.activeMs || 0;
|
|
202
|
+
days.push({ label: `${WD[d.getDay()]} ${key.slice(5)}`, ms, isToday: i === 0 });
|
|
203
|
+
totMs += ms; prompts += e.userMessages || 0; tools += e.toolCalls || 0; sessions += e.sessions || 0;
|
|
204
|
+
tokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.cacheReadTokens || 0) + (e.cacheWriteTokens || 0);
|
|
205
|
+
cost += e.cost || 0;
|
|
206
|
+
}
|
|
207
|
+
return { days, maxMs: Math.max(0, ...days.map((d) => d.ms)), totMs, prompts, tools, sessions, tokens, cost };
|
|
119
208
|
}
|
|
120
209
|
|
|
121
|
-
// ── Tab renderers
|
|
122
|
-
function tabNow(
|
|
210
|
+
// ── Tab renderers — each returns content lines (row() fits them to width) ──────
|
|
211
|
+
function tabNow(data, cw) {
|
|
123
212
|
const v = data.vars;
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
213
|
+
const sc = statusColor(v.status);
|
|
214
|
+
const head = [
|
|
215
|
+
`${C.bold}${sc}${String(v.statusVerbose).toUpperCase()}${C.reset} ${C.dim}in${C.reset} ${C.bold}${v.project}${C.reset}`,
|
|
216
|
+
`${C.dim}${v.modelPretty} · ${v.duration} elapsed${C.reset}`,
|
|
217
|
+
'',
|
|
218
|
+
];
|
|
219
|
+
const [lw, rw] = split2(cw);
|
|
220
|
+
const session = panel('session', statRows([
|
|
221
|
+
['prompts', `${C.yellow}${v.messages}${C.reset}`],
|
|
222
|
+
['tool calls', `${C.yellow}${v.tools}${C.reset}`],
|
|
223
|
+
['files', `${C.cyan}${v.filesOpened}${C.reset} ${C.dim}open · ${v.filesEdited} edit · ${v.filesRead} read${C.reset}`],
|
|
224
|
+
['tokens', `${C.bold}${v.tokensFmt}${C.reset}`],
|
|
225
|
+
['', `${C.dim}${v.inputTokens} in · ${v.outputTokens} out · ${v.cacheTokens} cache${C.reset}`],
|
|
226
|
+
], lw - 4), lw);
|
|
227
|
+
const liveBody = [];
|
|
228
|
+
if (v.currentTool) liveBody.push(...statRows([['doing', `${C.bold}${v.currentToolPretty}${C.reset}`]], rw - 4));
|
|
229
|
+
if (v.currentFilePretty) liveBody.push(`${C.dim}file${C.reset} ${C.cyan}${v.currentFilePretty}${C.reset}`);
|
|
230
|
+
liveBody.push(...statRows([['model', `${C.bold}${v.modelPretty}${C.reset}`]], rw - 4));
|
|
231
|
+
if (Number(v.concurrent) > 1) {
|
|
232
|
+
liveBody.push('');
|
|
233
|
+
liveBody.push(`${C.magenta}${v.concurrentLabel}${C.reset}`);
|
|
234
|
+
liveBody.push(`${C.dim}${v.concurrentListPretty}${C.reset}`);
|
|
235
|
+
} else {
|
|
236
|
+
liveBody.push(`${C.dim}single session${C.reset}`);
|
|
140
237
|
}
|
|
141
|
-
return
|
|
238
|
+
return [...head, ...columns([session, panel('live', liveBody, rw)])];
|
|
142
239
|
}
|
|
143
240
|
|
|
144
|
-
function tabToday(
|
|
241
|
+
function tabToday(data, cw) {
|
|
145
242
|
const v = data.vars;
|
|
146
243
|
const agg = data.aggregate;
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
244
|
+
const head = headline('today', v.todayHours, 'active');
|
|
245
|
+
const [lw, rw] = split2(cw);
|
|
246
|
+
const stats = panel('today', statRows([
|
|
247
|
+
['prompts', `${C.yellow}${v.todayPrompts}${C.reset}`],
|
|
248
|
+
['tool calls', `${C.yellow}${v.todayToolsFmt}${C.reset}`],
|
|
249
|
+
['sessions', `${C.cyan}${v.todaySessions}${C.reset}`],
|
|
250
|
+
['spend', `${C.green}${v.todayCostFmt}${C.reset}`],
|
|
251
|
+
], lw - 4), lw);
|
|
252
|
+
const toks = panel('tokens', statRows([
|
|
253
|
+
['total', `${C.bold}${v.todayTokensFmt}${C.reset}`],
|
|
254
|
+
['fresh', `${C.cyan}${v.todayTokensRealFmt}${C.reset}`],
|
|
255
|
+
['cache', `${C.dim}${v.todayCacheTokensFmt}${C.reset}`],
|
|
256
|
+
['lines', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
|
|
257
|
+
], rw - 4), rw);
|
|
258
|
+
const out = [...head, ...columns([stats, toks])];
|
|
259
|
+
|
|
260
|
+
// Full-width hour-of-day histogram.
|
|
261
|
+
const heightChars = ' ▁▂▃▄▅▆▇█';
|
|
262
|
+
let max = 0;
|
|
263
|
+
for (let h = 0; h < 24; h++) max = Math.max(max, agg.byHour?.[h]?.activeMs || 0);
|
|
264
|
+
if (max > 0) {
|
|
265
|
+
const bars = [];
|
|
266
|
+
for (let h = 0; h < 24; h++) {
|
|
267
|
+
const ms = agg.byHour?.[h]?.activeMs || 0;
|
|
268
|
+
const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
|
|
269
|
+
const ch = heightChars[idx];
|
|
270
|
+
bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
|
|
171
271
|
}
|
|
272
|
+
// Two cells per hour so the 24h strip reads wide.
|
|
273
|
+
out.push('');
|
|
274
|
+
out.push(...panel('when you code · hour of day', [
|
|
275
|
+
bars.map((b) => b + b).join(''),
|
|
276
|
+
`${C.dim}00 03 06 09 12 15 18 21${C.reset}`,
|
|
277
|
+
], cw));
|
|
172
278
|
}
|
|
173
279
|
return out;
|
|
174
280
|
}
|
|
175
281
|
|
|
176
|
-
function tabWeek(
|
|
177
|
-
const v = data.vars;
|
|
282
|
+
function tabWeek(data, cw) {
|
|
178
283
|
const agg = data.aggregate;
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return
|
|
284
|
+
const wk = last7Days(agg.byDay || {});
|
|
285
|
+
const head = headline('last 7 days', hms(wk.totMs), 'active');
|
|
286
|
+
const [lw, rw] = split2(cw);
|
|
287
|
+
|
|
288
|
+
const cwBody = lw - 4;
|
|
289
|
+
const dailyBody = wk.days.map(({ label, ms, isToday }) => {
|
|
290
|
+
const marker = isToday ? `${C.bold}${C.cyan} ‹today${C.reset}` : '';
|
|
291
|
+
const lbl = isToday ? `${C.bold}${label}${C.reset}` : `${C.dim}${label}${C.reset}`;
|
|
292
|
+
// Reserve room for the value (1 space + 5) and, on today's row, the marker
|
|
293
|
+
// (' ‹today' = 7) so the bar never pushes the value off the panel edge.
|
|
294
|
+
const barW = Math.max(3, cwBody - 11 - 6 - (isToday ? 7 : 0));
|
|
295
|
+
return `${lbl}${' '.repeat(Math.max(1, 11 - visLen(label)))}${bar(wk.maxMs ? ms / wk.maxMs : 0, barW)} ${C.cyan}${hms(ms).padStart(5)}${C.reset}${marker}`;
|
|
296
|
+
});
|
|
297
|
+
const daily = panel('daily', dailyBody, lw);
|
|
298
|
+
|
|
299
|
+
const totals = panel('last 7 days', statRows([
|
|
300
|
+
['active', `${C.green}${hms(wk.totMs)}${C.reset}`],
|
|
301
|
+
['prompts', `${C.yellow}${fmtNum(wk.prompts)}${C.reset}`],
|
|
302
|
+
['tool calls', `${C.yellow}${fmtNum(wk.tools)}${C.reset}`],
|
|
303
|
+
['sessions', `${C.cyan}${fmtNum(wk.sessions)}${C.reset}`],
|
|
304
|
+
['tokens', `${C.bold}${fmtNum(wk.tokens)}${C.reset}`],
|
|
305
|
+
['spend', `${C.green}${fmtCost(wk.cost)}${C.reset}`],
|
|
306
|
+
], rw - 4), rw);
|
|
307
|
+
|
|
308
|
+
return [...head, ...columns([daily, totals])];
|
|
204
309
|
}
|
|
205
310
|
|
|
206
|
-
function tabStreak(
|
|
311
|
+
function tabStreak(data, cw) {
|
|
207
312
|
const v = data.vars;
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}
|
|
218
|
-
if (v.peakHour) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (v.topEditedFile) {
|
|
223
|
-
out.push('');
|
|
224
|
-
out.push(`${C.dim}hotspot${C.reset} ${C.bold}${v.topEditedFile}${C.reset} ${C.dim}${v.topEditedCountLabel}${C.reset}`);
|
|
225
|
-
}
|
|
226
|
-
return out;
|
|
313
|
+
const [lw, rw] = split2(cw);
|
|
314
|
+
const head = headline('streak', `${v.streak}d`, `longest ${v.longestStreak}d`);
|
|
315
|
+
const left = panel('streak', statRows([
|
|
316
|
+
['current', `${C.bold}${C.magenta}${v.streak}${C.reset} ${C.dim}days${C.reset}`],
|
|
317
|
+
['longest', `${C.cyan}${v.longestStreak}${C.reset} ${C.dim}days${C.reset}`],
|
|
318
|
+
['days on Claude', `${C.cyan}${v.daysSinceFirst}${C.reset}`],
|
|
319
|
+
], lw - 4), lw);
|
|
320
|
+
const rb = [];
|
|
321
|
+
if (v.bestDayDate) rb.push(...statRows([['best day', `${C.bold}${v.bestDayHours}${C.reset} ${C.dim}${v.bestDayDate}${C.reset}`]], rw - 4));
|
|
322
|
+
if (v.bestDayDate) rb.push(`${C.dim}${v.bestDayPrompts} prompts · ${v.bestDayTokensFmt} tokens${C.reset}`);
|
|
323
|
+
if (v.peakHour) rb.push(...statRows([['peak hour', `${C.bold}${v.peakHour}${C.reset} ${C.dim}${v.peakHourActiveLabel}${C.reset}`]], rw - 4));
|
|
324
|
+
if (v.topEditedFile) rb.push(...statRows([['hotspot', `${C.bold}${v.topEditedFile}${C.reset}`]], rw - 4));
|
|
325
|
+
if (!rb.length) rb.push(`${C.dim}keep going to unlock records${C.reset}`);
|
|
326
|
+
return [...head, ...columns([left, panel('records', rb, rw)])];
|
|
227
327
|
}
|
|
228
328
|
|
|
229
|
-
function tabLifetime(
|
|
329
|
+
function tabLifetime(data, cw) {
|
|
230
330
|
const v = data.vars;
|
|
231
331
|
const agg = data.aggregate;
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
out.push(`${C.dim}top projects${C.reset}`);
|
|
249
|
-
const maxMs = top[0][1].activeMs;
|
|
250
|
-
for (const [name, p] of top) {
|
|
251
|
-
const h = p.activeMs / 3_600_000;
|
|
252
|
-
const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
|
|
253
|
-
const pretty = humanProject(name).slice(0, 18).padEnd(20);
|
|
254
|
-
out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
return out;
|
|
332
|
+
const [lw, rw] = split2(cw);
|
|
333
|
+
const head = headline('all-time', v.allHours, `active · ${v.allWallHours} wall`);
|
|
334
|
+
const stats = panel('all-time', statRows([
|
|
335
|
+
['sessions', `${C.yellow}${v.allSessions}${C.reset} ${C.dim}+${v.allSubagentRuns} sub${C.reset}`],
|
|
336
|
+
['prompts', `${C.yellow}${v.allMessagesFmt}${C.reset}`],
|
|
337
|
+
['tool calls', `${C.yellow}${v.allToolsFmt}${C.reset}`],
|
|
338
|
+
['files', `${C.cyan}${v.allFilesFmt}${C.reset}`],
|
|
339
|
+
['tokens', `${C.bold}${C.magenta}${v.allTokensFmt}${C.reset}`],
|
|
340
|
+
['spend', `${C.green}${v.allCostFmt}${C.reset}`],
|
|
341
|
+
], lw - 4), lw);
|
|
342
|
+
const top = Object.entries(agg.projects || {}).sort((a, b) => b[1].activeMs - a[1].activeMs).slice(0, 6);
|
|
343
|
+
const maxMs = top[0] ? top[0][1].activeMs : 1;
|
|
344
|
+
const projBody = top.length
|
|
345
|
+
? top.map(([name, p]) => barRow(humanProject(name), hms(p.activeMs), p.activeMs / maxMs, rw - 4))
|
|
346
|
+
: [`${C.dim}no projects yet${C.reset}`];
|
|
347
|
+
return [...head, ...columns([stats, panel('top projects · by time', projBody, rw)])];
|
|
258
348
|
}
|
|
259
349
|
|
|
260
|
-
function tabCost(
|
|
350
|
+
function tabCost(data, cw) {
|
|
261
351
|
const v = data.vars;
|
|
262
352
|
const agg = data.aggregate;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
353
|
+
const [lw, rw] = split2(cw);
|
|
354
|
+
const head = headline('spend', v.allCostFmt, 'all-time · approximate');
|
|
355
|
+
|
|
356
|
+
// Per-project spend — the headline ask.
|
|
357
|
+
const projs = Object.entries(agg.projects || {})
|
|
358
|
+
.map(([k, p]) => [humanProject(k), p.cost || 0]).filter(([, c]) => c > 0)
|
|
359
|
+
.sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
360
|
+
const maxP = projs[0] ? projs[0][1] : 1;
|
|
361
|
+
const projBody = projs.length
|
|
362
|
+
? projs.map(([name, c]) => barRow(name, fmtCost(c), c / maxP, lw - 4))
|
|
363
|
+
: [`${C.dim}no spend recorded${C.reset}`];
|
|
364
|
+
const byProject = panel('by project', projBody, lw);
|
|
365
|
+
|
|
366
|
+
const models = Object.entries(agg.costByModel || {}).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
367
|
+
const maxM = models[0] ? models[0][1] : 1;
|
|
368
|
+
const modelBody = models.length
|
|
369
|
+
? models.map(([m, c]) => barRow(m, fmtCost(c), c / maxM, rw - 4))
|
|
370
|
+
: [`${C.dim}no model data${C.reset}`];
|
|
371
|
+
const byModel = panel('by model', modelBody, rw);
|
|
372
|
+
|
|
373
|
+
const out = [...head, ...columns([byProject, byModel])];
|
|
374
|
+
|
|
375
|
+
// Month-to-date + forecast strip.
|
|
283
376
|
const now = new Date();
|
|
284
377
|
const ym = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
285
378
|
let mtd = 0;
|
|
286
|
-
for (const [k, day] of Object.entries(agg.byDay || {}))
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const daysIn = now.getDate();
|
|
291
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
292
|
-
const forecast = (mtd / daysIn) * daysInMonth;
|
|
293
|
-
out.push('');
|
|
294
|
-
out.push(`${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd).padStart(8)}${C.reset}`);
|
|
295
|
-
out.push(`${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast).padStart(8)}${C.reset}`);
|
|
296
|
-
}
|
|
379
|
+
for (const [k, day] of Object.entries(agg.byDay || {})) if (k.startsWith(ym)) mtd += day.cost || 0;
|
|
380
|
+
const forecast = mtd > 0 ? (mtd / now.getDate()) * new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate() : 0;
|
|
381
|
+
out.push('');
|
|
382
|
+
out.push(`${C.dim}today${C.reset} ${C.cyan}${v.todayCostFmt}${C.reset} ${C.dim}week${C.reset} ${C.cyan}${v.weekCostFmt}${C.reset} ${C.dim}month-to-date${C.reset} ${C.cyan}${fmtCost(mtd)}${C.reset} ${C.dim}forecast${C.reset} ${C.bold}${fmtCost(forecast)}${C.reset}`);
|
|
297
383
|
return out;
|
|
298
384
|
}
|
|
299
385
|
|
|
300
|
-
function tabCode(
|
|
386
|
+
function tabCode(data, cw) {
|
|
301
387
|
const v = data.vars;
|
|
302
388
|
const agg = data.aggregate;
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
out.push(`${C.bold}Code churn${C.reset} ${C.green}+${v.linesAddedFmt}${C.reset} / ${C.red}−${v.linesRemovedFmt}${C.reset} ${C.dim}net ${v.linesNetFmt}${C.reset}`);
|
|
306
|
-
out.push('');
|
|
307
|
-
out.push(`${C.dim}today${C.reset} ${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`);
|
|
308
|
-
out.push(`${C.dim}this week${C.reset} ${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`);
|
|
309
|
-
|
|
389
|
+
const [lw, rw] = split2(cw);
|
|
390
|
+
const head = headline('code churn', `+${v.linesAddedFmt} / −${v.linesRemovedFmt}`, `net ${v.linesNetFmt}`);
|
|
310
391
|
const langs = Object.entries(agg.languages || {}).sort((a, b) => (b[1].edits || 0) - (a[1].edits || 0)).slice(0, 6);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
return out;
|
|
392
|
+
const maxL = langs[0] ? (langs[0][1].edits || 1) : 1;
|
|
393
|
+
const langBody = [
|
|
394
|
+
...statRows([
|
|
395
|
+
['today', `${C.green}+${v.todayLinesAddedFmt}${C.reset} ${C.dim}(${v.todayLinesNetFmt} net)${C.reset}`],
|
|
396
|
+
['this week', `${C.green}+${v.weekLinesAddedFmt}${C.reset} ${C.dim}(${v.weekLinesNetFmt} net)${C.reset}`],
|
|
397
|
+
], lw - 4),
|
|
398
|
+
'',
|
|
399
|
+
...(langs.length ? langs.map(([n, l]) => barRow(n, fmtNum(l.edits || 0), (l.edits || 0) / maxL, lw - 4)) : [`${C.dim}no edits yet${C.reset}`]),
|
|
400
|
+
];
|
|
401
|
+
const churn = panel('languages · by edits', langBody, lw);
|
|
402
|
+
|
|
403
|
+
const bash = Object.entries(agg.bashCommands || {}).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
404
|
+
const dom = Object.entries(agg.webDomains || {}).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
|
405
|
+
const rb = [];
|
|
406
|
+
if (bash.length) { rb.push(`${C.dim}top bash${C.reset}`); for (const [k, n] of bash) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
|
|
407
|
+
if (dom.length) { rb.push(''); rb.push(`${C.dim}web domains${C.reset}`); for (const [k, n] of dom) rb.push(`${String(k).slice(0, rw - 12).padEnd(rw - 12)} ${C.cyan}${String(n).padStart(6)}${C.reset}`); }
|
|
408
|
+
if (!rb.length) rb.push(`${C.dim}no shell/web activity${C.reset}`);
|
|
409
|
+
return [...head, ...columns([churn, panel('shell & web', rb, rw)])];
|
|
330
410
|
}
|
|
331
411
|
|
|
332
412
|
const TAB_RENDERERS = [tabNow, tabToday, tabWeek, tabStreak, tabLifetime, tabCost, tabCode];
|
|
333
413
|
|
|
334
|
-
// ──
|
|
335
|
-
function
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
414
|
+
// ── Frame composition ─────────────────────────────────────────────────────────
|
|
415
|
+
function topBorder(w, title, statusColored) {
|
|
416
|
+
const fill = Math.max(0, w - title.length - visLen(statusColored) - 8);
|
|
417
|
+
return `${C.gray}${BX.tl}${BX.h} ${C.bold}${title}${C.reset}${C.gray} ${BX.h.repeat(fill)} ${C.reset}${statusColored}${C.gray} ${BX.h}${BX.tr}${C.reset}`;
|
|
418
|
+
}
|
|
419
|
+
function bottomBorder(w) { return `${C.gray}${BX.bl}${BX.h.repeat(w - 2)}${BX.br}${C.reset}`; }
|
|
420
|
+
function sepLine(w) { return `${C.gray}${BX.sl}${BX.h.repeat(w - 2)}${BX.sr}${C.reset}`; }
|
|
421
|
+
function row(content, w) { return `${C.gray}${BX.v}${C.reset} ${fit(content, w - 4)} ${C.gray}${BX.v}${C.reset}`; }
|
|
422
|
+
|
|
423
|
+
export function renderFrame(data, { width, height, tab }) {
|
|
424
|
+
const w = Math.max(64, Math.min(120, width || 100));
|
|
425
|
+
const h = Math.max(20, Math.min(32, height || 28));
|
|
426
|
+
const cw = w - 4;
|
|
427
|
+
|
|
428
|
+
const status = data.pid
|
|
429
|
+
? `${C.green}●${C.reset} ${C.dim}running · pid ${data.pid}${C.reset}`
|
|
430
|
+
: `${C.red}○${C.reset} ${C.dim}daemon not running${C.reset}`;
|
|
431
|
+
const tabBar = TABS.map((t, i) => (i === tab
|
|
432
|
+
? `${C.bold}${C.cyan}‹ ${t.label} ›${C.reset}`
|
|
433
|
+
: `${C.dim}${t.label}${C.reset}`)).join(' ');
|
|
434
|
+
const footer = `${C.dim}1-7${C.reset} jump ${C.gray}·${C.reset} ${C.dim}←→ h l${C.reset} tab ${C.gray}·${C.reset} ${C.dim}r${C.reset} refresh ${C.gray}·${C.reset} ${C.dim}q${C.reset} quit`;
|
|
435
|
+
|
|
436
|
+
// Header breathes: blank line, centred tab bar, blank line, then the rule —
|
|
437
|
+
// so the top reads as a header band with air, not a squished edge strip.
|
|
438
|
+
const B = Math.max(1, h - 8); // header(border+blank+tabs+blank+rule=5) + footer(rule+keys+border=3)
|
|
439
|
+
const bodyContent = TAB_RENDERERS[tab](data, cw);
|
|
440
|
+
const body = bodyContent.slice(0, B);
|
|
441
|
+
while (body.length < B) body.push('');
|
|
442
|
+
|
|
443
|
+
const lines = [
|
|
444
|
+
topBorder(w, 'claude-rpc', status),
|
|
445
|
+
row('', w),
|
|
446
|
+
row(center(tabBar, cw), w),
|
|
447
|
+
row('', w),
|
|
448
|
+
sepLine(w),
|
|
449
|
+
];
|
|
450
|
+
for (const b of body) lines.push(row(b, w));
|
|
451
|
+
lines.push(sepLine(w), row(center(footer, cw), w), bottomBorder(w));
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
343
454
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
455
|
+
// ── Live render / input / lifecycle ───────────────────────────────────────────
|
|
456
|
+
// Frame size + centring margins. The frame caps out (max 120×32) and centres
|
|
457
|
+
// in the terminal, so a big full-screen window reads as a centred dashboard with
|
|
458
|
+
// breathing room instead of a frame glued to the top-left edge.
|
|
459
|
+
function frameLayout(termW, termH) {
|
|
460
|
+
const w = Math.max(64, Math.min(120, termW - 4));
|
|
461
|
+
const h = Math.max(20, Math.min(28, termH - 2));
|
|
462
|
+
return { w, h, left: Math.max(0, (termW - w) >> 1), top: Math.max(0, (termH - h) >> 1) };
|
|
463
|
+
}
|
|
348
464
|
|
|
349
|
-
|
|
465
|
+
function render() {
|
|
466
|
+
const termW = process.stdout.columns || 100;
|
|
467
|
+
const termH = process.stdout.rows || 30;
|
|
468
|
+
const { w, h, left, top } = frameLayout(termW, termH);
|
|
469
|
+
const pad = ' '.repeat(left);
|
|
470
|
+
const frame = renderFrame(loadSnapshot(), { width: w, height: h, tab: currentTab })
|
|
471
|
+
.split('\n').map((l) => pad + l).join('\n');
|
|
472
|
+
process.stdout.write(CLEAR + '\n'.repeat(top) + frame);
|
|
350
473
|
}
|
|
351
474
|
|
|
352
|
-
// ── Input / lifecycle ───────────────────────────────────────────────────────
|
|
353
475
|
function cleanup() {
|
|
354
476
|
if (exiting) return;
|
|
355
477
|
exiting = true;
|
|
356
478
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
357
479
|
try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
|
|
358
480
|
process.stdin.pause();
|
|
359
|
-
process.stdout.write(SHOW_CURSOR +
|
|
481
|
+
process.stdout.write(SHOW_CURSOR + ALT_OFF);
|
|
360
482
|
process.exit(0);
|
|
361
483
|
}
|
|
362
484
|
|
|
@@ -364,18 +486,9 @@ function handleKey(buf) {
|
|
|
364
486
|
const key = buf.toString();
|
|
365
487
|
if (key === '\x03' || key.toLowerCase() === 'q') return cleanup();
|
|
366
488
|
if (key.toLowerCase() === 'r') return render();
|
|
367
|
-
if (key.length === 1 && key >= '1' && key <= String(TABS.length)) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') {
|
|
372
|
-
currentTab = (currentTab + 1) % TABS.length;
|
|
373
|
-
return render();
|
|
374
|
-
}
|
|
375
|
-
if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') {
|
|
376
|
-
currentTab = (currentTab - 1 + TABS.length) % TABS.length;
|
|
377
|
-
return render();
|
|
378
|
-
}
|
|
489
|
+
if (key.length === 1 && key >= '1' && key <= String(TABS.length)) { currentTab = Number(key) - 1; return render(); }
|
|
490
|
+
if (key === '\x1b[C' || key === '\x1b[B' || key === '\t' || key === 'l' || key === 'j') { currentTab = (currentTab + 1) % TABS.length; return render(); }
|
|
491
|
+
if (key === '\x1b[D' || key === '\x1b[A' || key === 'h' || key === 'k') { currentTab = (currentTab - 1 + TABS.length) % TABS.length; return render(); }
|
|
379
492
|
}
|
|
380
493
|
|
|
381
494
|
export function startTui() {
|
|
@@ -383,8 +496,7 @@ export function startTui() {
|
|
|
383
496
|
console.error('claude-rpc status: not a TTY. Use `claude-rpc status --dump` for plain output.');
|
|
384
497
|
process.exit(1);
|
|
385
498
|
}
|
|
386
|
-
process.stdout.write(HIDE_CURSOR);
|
|
387
|
-
|
|
499
|
+
process.stdout.write(ALT_ON + HIDE_CURSOR);
|
|
388
500
|
try { process.stdin.setRawMode(true); } catch { /* not a tty — TUI will print once and exit */ }
|
|
389
501
|
process.stdin.resume();
|
|
390
502
|
process.stdin.on('data', handleKey);
|
|
@@ -394,7 +506,7 @@ export function startTui() {
|
|
|
394
506
|
process.on('SIGHUP', cleanup);
|
|
395
507
|
process.on('exit', () => {
|
|
396
508
|
try { process.stdin.setRawMode(false); } catch { /* not a tty (CI, pipe) — no-op */ }
|
|
397
|
-
process.stdout.write(SHOW_CURSOR);
|
|
509
|
+
process.stdout.write(SHOW_CURSOR + ALT_OFF);
|
|
398
510
|
});
|
|
399
511
|
process.stdout.on('resize', () => render());
|
|
400
512
|
|