unsnooze 1.12.2 → 1.14.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +286 -22
  3. package/bin/unsnooze.js +52 -3
  4. package/package.json +7 -4
  5. package/src/agents/agy.js +2 -0
  6. package/src/agents/claude.js +2 -0
  7. package/src/agents/codex.js +2 -0
  8. package/src/agents/grok.js +2 -0
  9. package/src/agents/index.js +7 -1
  10. package/src/agents/kimi.js +2 -0
  11. package/src/agents/opencode.js +2 -0
  12. package/src/agents/qwen.js +2 -0
  13. package/src/askpass.js +188 -0
  14. package/src/cli.js +149 -25
  15. package/src/config.js +15 -0
  16. package/src/dashboard/App.js +390 -0
  17. package/src/dashboard/Logo.js +74 -0
  18. package/src/dashboard/components/TextInput.js +63 -0
  19. package/src/dashboard/data.js +143 -0
  20. package/src/dashboard/mark.js +51 -0
  21. package/src/dashboard/mouse-protocol.js +75 -0
  22. package/src/dashboard/mouse.js +125 -0
  23. package/src/dashboard/run.js +108 -0
  24. package/src/dashboard/tabs/DoctorTab.js +35 -0
  25. package/src/dashboard/tabs/FleetTab.js +94 -0
  26. package/src/dashboard/tabs/LogsTab.js +28 -0
  27. package/src/dashboard/tabs/PromptsTab.js +406 -0
  28. package/src/dashboard/tabs/SessionsTab.js +33 -0
  29. package/src/dashboard/tabs/StatusTab.js +58 -0
  30. package/src/dashboard/tabs/UsageTab.js +68 -0
  31. package/src/dashboard/theme.js +32 -0
  32. package/src/doctor.js +22 -16
  33. package/src/fleet.js +927 -0
  34. package/src/hook.js +19 -0
  35. package/src/install.js +9 -0
  36. package/src/monitor.js +14 -0
  37. package/src/prompt-queue.js +561 -0
  38. package/src/prompt.js +411 -0
  39. package/src/remote.js +223 -0
  40. package/src/resumer.js +60 -16
  41. package/src/settings.js +13 -0
  42. package/src/state.js +33 -9
  43. package/src/time-parser.js +15 -1
  44. package/src/tui.js +395 -0
  45. package/src/usage.js +1537 -0
  46. package/src/watcher.js +79 -8
  47. package/src/watchers/claude.js +3 -0
  48. package/src/watchers/codex.js +8 -8
  49. package/src/wizard.js +2 -1
@@ -0,0 +1,406 @@
1
+ // Prompts tab: list + manage the one-shot prompt queue (src/prompt-queue.js)
2
+ // straight from the dashboard. Writes go through the same locked
3
+ // updateState() the daemon uses — safe to call next to it.
4
+ import React, { useEffect, useState } from 'react';
5
+ import { Box, Text, useInput } from 'ink';
6
+ import { homedir } from 'node:os';
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { isAbsolute } from 'node:path';
9
+ import { theme } from '../theme.js';
10
+ import { fmtCountdown } from '../data.js';
11
+ import { TextInput } from '../components/TextInput.js';
12
+ import { queueAdd, queueRemove } from '../../prompt-queue.js';
13
+ import { listAgents } from '../../agents/index.js';
14
+ import { getConfig } from '../../settings.js';
15
+ import { readHosts, remoteQueueAdd } from '../../fleet.js';
16
+ import { parseAtTime } from '../../prompt.js';
17
+
18
+ const h = React.createElement;
19
+
20
+ // -- small local helpers ---------------------------------------------------
21
+
22
+ // Duplicated from cli.js's shortenHome rather than imported: cli.js pulls in
23
+ // this dashboard module transitively (cli.js -> dashboard/run.js -> App.js),
24
+ // so importing it back here would be a real import cycle, not just a scary
25
+ // one. Five lines is cheaper than untangling that.
26
+ function shortenCwd(p) {
27
+ if (typeof p !== 'string' || !p) return p;
28
+ const home = homedir();
29
+ if (p === home) return '~';
30
+ return p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
31
+ }
32
+
33
+ // Same enablement rule the CLI's `unsnooze prompt add` picker uses.
34
+ function enabledAgentIds() {
35
+ return listAgents().map(a => a.id).filter(id => getConfig(`agents.${id}`) !== false);
36
+ }
37
+
38
+ function fmtDue(e, now) {
39
+ const futureBackoff = Number.isFinite(e.notBefore) && e.notBefore > now ? e.notBefore : null;
40
+ const futureAt = Number.isFinite(e.atMs) && e.atMs > now ? e.atMs : null;
41
+ const future = futureBackoff ?? futureAt;
42
+ if (future != null) return fmtCountdown(future - now);
43
+ return e.mode === 'now' ? 'now' : 'next reset';
44
+ }
45
+
46
+ function truncate(text, n = 50) {
47
+ const s = String(text || '');
48
+ return s.length > n ? `${s.slice(0, n)}…` : s;
49
+ }
50
+
51
+ // -- add-form: pure step reducer (unit-test surface) -----------------------
52
+
53
+ export const WHEN_OPTIONS = ['next-reset', 'now', 'at'];
54
+ export const WHEN_LABELS = { 'next-reset': 'next reset', now: 'now', at: 'at…' };
55
+
56
+ export function initFormState({ path = '', agents = ['claude'], hosts = [] } = {}) {
57
+ return {
58
+ step: 'path',
59
+ path, pathError: null,
60
+ forHost: null, // set once a remote host is chosen
61
+ agents, agentIndex: Math.max(0, agents.indexOf('claude')),
62
+ whenIndex: 0,
63
+ atText: '', atMs: null, atError: null,
64
+ hosts, hostIndex: 0, // 0 = local
65
+ prompt: '',
66
+ submitError: null,
67
+ };
68
+ }
69
+
70
+ function clampIndex(i, len) {
71
+ if (len <= 0) return 0;
72
+ return ((i % len) + len) % len;
73
+ }
74
+
75
+ // One screen back per step; `null` means "nothing behind this — close the
76
+ // form" (only true step 1, with no host re-entry in progress).
77
+ function back(state) {
78
+ switch (state.step) {
79
+ case 'path': return state.forHost ? { ...state, step: 'host', forHost: null } : null;
80
+ case 'agent': return { ...state, step: 'path' };
81
+ case 'when': return { ...state, step: 'agent' };
82
+ case 'at': return { ...state, step: 'when' };
83
+ case 'host': return { ...state, step: 'when' };
84
+ case 'prompt':
85
+ if (state.forHost) return { ...state, step: 'path' };
86
+ if (state.hosts.length) return { ...state, step: 'host' };
87
+ return WHEN_OPTIONS[state.whenIndex] === 'at' ? { ...state, step: 'at' } : { ...state, step: 'when' };
88
+ default: return null;
89
+ }
90
+ }
91
+
92
+ // state -> state | null. `null` is a legitimate result (form closed) —
93
+ // callers feed it straight into setForm.
94
+ export function formReduce(state, event) {
95
+ switch (event.type) {
96
+ case 'PATH_CHANGE':
97
+ return { ...state, path: event.value, pathError: null };
98
+ case 'PATH_SUBMIT':
99
+ if (!event.ok) return { ...state, pathError: event.error };
100
+ // Coming back from a host pick: the re-entered path goes straight to
101
+ // the prompt step — agent/when/host were already chosen this round.
102
+ return { ...state, path: event.value, pathError: null, step: state.forHost ? 'prompt' : 'agent' };
103
+ case 'AGENT_SELECT':
104
+ return { ...state, agentIndex: clampIndex(event.index, state.agents.length) };
105
+ case 'AGENT_CONFIRM':
106
+ return { ...state, step: 'when' };
107
+ case 'WHEN_SELECT':
108
+ return { ...state, whenIndex: clampIndex(event.index, WHEN_OPTIONS.length) };
109
+ case 'WHEN_CONFIRM':
110
+ return WHEN_OPTIONS[state.whenIndex] === 'at'
111
+ ? { ...state, step: 'at' }
112
+ : { ...state, step: state.hosts.length ? 'host' : 'prompt' };
113
+ case 'AT_CHANGE':
114
+ return { ...state, atText: event.value, atError: null };
115
+ case 'AT_SUBMIT':
116
+ if (!event.ok) return { ...state, atError: event.error };
117
+ return {
118
+ ...state, atText: event.value, atMs: event.atMs, atError: null,
119
+ step: state.hosts.length ? 'host' : 'prompt',
120
+ };
121
+ case 'HOST_SELECT':
122
+ return { ...state, hostIndex: clampIndex(event.index, state.hosts.length + 1) };
123
+ case 'HOST_CONFIRM':
124
+ if (state.hostIndex === 0) return { ...state, forHost: null, step: 'prompt' };
125
+ // Remote path differs from local — clear the prefill and re-collect it.
126
+ return { ...state, forHost: state.hosts[state.hostIndex - 1], path: '', pathError: null, step: 'path' };
127
+ case 'PROMPT_CHANGE':
128
+ return { ...state, prompt: event.value, submitError: null };
129
+ case 'SUBMIT_ERROR':
130
+ return { ...state, submitError: event.error };
131
+ case 'BACK':
132
+ return back(state);
133
+ default:
134
+ return state;
135
+ }
136
+ }
137
+
138
+ // -- add-form rendering ------------------------------------------------------
139
+
140
+ function StepSelect({ title, options, index, onSelect, onConfirm, onBack, hint }) {
141
+ useInput((input, key) => {
142
+ if (key.escape) { onBack(); return; }
143
+ if (key.return) { onConfirm(); return; }
144
+ if (input === 'j' || key.downArrow) { onSelect((index + 1) % options.length); return; }
145
+ if (input === 'k' || key.upArrow) { onSelect((index - 1 + options.length) % options.length); }
146
+ });
147
+ return h(Box, { flexDirection: 'column' },
148
+ h(Text, { color: theme.accent, bold: true }, title),
149
+ h(Box, { marginTop: 1, flexDirection: 'column' },
150
+ ...options.map((label, i) =>
151
+ h(Text, { key: label + i, color: i === index ? theme.bright : theme.muted, bold: i === index },
152
+ `${i === index ? '❯ ' : ' '}${label}`)),
153
+ ),
154
+ h(Text, { color: theme.muted, dimColor: true }, hint || 'j/k select · enter confirm · esc back'),
155
+ );
156
+ }
157
+
158
+ function PathStep({ state, dispatch }) {
159
+ const label = state.forHost ? `path on ${state.forHost}` : 'project path';
160
+ return h(Box, { flexDirection: 'column' },
161
+ h(Text, { color: theme.accent, bold: true }, `add prompt — ${label}`),
162
+ h(Box, { marginTop: 1 },
163
+ h(Text, { color: theme.muted }, '> '),
164
+ h(TextInput, {
165
+ key: state.forHost || 'local',
166
+ value: state.path,
167
+ placeholder: '/absolute/path',
168
+ onChange: (value) => dispatch({ type: 'PATH_CHANGE', value }),
169
+ onSubmit: (value) => {
170
+ const v = value.trim();
171
+ if (!isAbsolute(v)) { dispatch({ type: 'PATH_SUBMIT', ok: false, error: 'path must be absolute' }); return; }
172
+ if (state.forHost) {
173
+ // Remote path — no local fs to check against.
174
+ dispatch({ type: 'PATH_SUBMIT', ok: true, value: v });
175
+ return;
176
+ }
177
+ if (!existsSync(v) || !statSync(v).isDirectory()) {
178
+ dispatch({ type: 'PATH_SUBMIT', ok: false, error: 'no such directory' });
179
+ return;
180
+ }
181
+ dispatch({ type: 'PATH_SUBMIT', ok: true, value: v });
182
+ },
183
+ onCancel: () => dispatch({ type: 'BACK' }),
184
+ }),
185
+ ),
186
+ state.pathError ? h(Text, { color: theme.crit }, state.pathError) : null,
187
+ h(Text, { color: theme.muted, dimColor: true }, 'enter to continue · esc to close'),
188
+ );
189
+ }
190
+
191
+ function AgentStep({ state, dispatch }) {
192
+ return h(StepSelect, {
193
+ title: 'add prompt — agent',
194
+ options: state.agents,
195
+ index: state.agentIndex,
196
+ onSelect: (i) => dispatch({ type: 'AGENT_SELECT', index: i }),
197
+ onConfirm: () => dispatch({ type: 'AGENT_CONFIRM' }),
198
+ onBack: () => dispatch({ type: 'BACK' }),
199
+ });
200
+ }
201
+
202
+ function WhenStep({ state, dispatch }) {
203
+ return h(StepSelect, {
204
+ title: 'add prompt — when',
205
+ options: WHEN_OPTIONS.map(o => WHEN_LABELS[o]),
206
+ index: state.whenIndex,
207
+ onSelect: (i) => dispatch({ type: 'WHEN_SELECT', index: i }),
208
+ onConfirm: () => dispatch({ type: 'WHEN_CONFIRM' }),
209
+ onBack: () => dispatch({ type: 'BACK' }),
210
+ });
211
+ }
212
+
213
+ function AtStep({ state, dispatch }) {
214
+ return h(Box, { flexDirection: 'column' },
215
+ h(Text, { color: theme.accent, bold: true }, 'add prompt — at (e.g. "7pm", "+2h30m", "2026-07-20 09:00")'),
216
+ h(Box, { marginTop: 1 },
217
+ h(Text, { color: theme.muted }, '> '),
218
+ h(TextInput, {
219
+ value: state.atText,
220
+ placeholder: '7pm',
221
+ onChange: (value) => dispatch({ type: 'AT_CHANGE', value }),
222
+ onSubmit: (value) => {
223
+ const atMs = parseAtTime(value);
224
+ if (atMs == null) { dispatch({ type: 'AT_SUBMIT', ok: false, error: `could not parse "${value}"` }); return; }
225
+ dispatch({ type: 'AT_SUBMIT', ok: true, value, atMs });
226
+ },
227
+ onCancel: () => dispatch({ type: 'BACK' }),
228
+ }),
229
+ ),
230
+ state.atError ? h(Text, { color: theme.crit }, state.atError) : null,
231
+ h(Text, { color: theme.muted, dimColor: true }, 'enter to continue · esc back'),
232
+ );
233
+ }
234
+
235
+ function HostStep({ state, dispatch }) {
236
+ return h(StepSelect, {
237
+ title: 'add prompt — host',
238
+ options: ['local', ...state.hosts],
239
+ index: state.hostIndex,
240
+ onSelect: (i) => dispatch({ type: 'HOST_SELECT', index: i }),
241
+ onConfirm: () => dispatch({ type: 'HOST_CONFIRM' }),
242
+ onBack: () => dispatch({ type: 'BACK' }),
243
+ });
244
+ }
245
+
246
+ function PromptStep({ state, dispatch, onSubmitFinal }) {
247
+ const agent = state.agents[state.agentIndex];
248
+ const when = WHEN_LABELS[WHEN_OPTIONS[state.whenIndex]];
249
+ return h(Box, { flexDirection: 'column' },
250
+ h(Text, { color: theme.accent, bold: true },
251
+ `add prompt — text (${state.forHost ? `${state.forHost}: ` : ''}${agent}, ${when})`),
252
+ h(Box, { marginTop: 1 },
253
+ h(Text, { color: theme.muted }, '> '),
254
+ h(TextInput, {
255
+ value: state.prompt,
256
+ placeholder: 'what should it do?',
257
+ onChange: (value) => dispatch({ type: 'PROMPT_CHANGE', value }),
258
+ onSubmit: onSubmitFinal,
259
+ onCancel: () => dispatch({ type: 'BACK' }),
260
+ }),
261
+ ),
262
+ state.submitError ? h(Text, { color: theme.crit }, state.submitError) : null,
263
+ h(Text, { color: theme.muted, dimColor: true }, 'enter to queue · esc back'),
264
+ );
265
+ }
266
+
267
+ const STEP_COMPONENTS = { agent: AgentStep, when: WhenStep, at: AtStep, host: HostStep };
268
+
269
+ // -- add-form: submit/remove handlers (unit-test surface) -------------------
270
+
271
+ // Core result-handling for the add-form's final "queue it" step, pulled out
272
+ // of the component so the local/remote branches, duplicate-error formatting,
273
+ // and success wiring (onRefresh/onStatus/setForm) can be unit-tested without
274
+ // mounting ink or hitting a real queue/ssh round trip. Deps are injected —
275
+ // the component calls this with the real queueAdd/remoteQueueAdd/readHosts;
276
+ // tests pass stubs.
277
+ export function submitFinal(state, value, {
278
+ setForm, dispatch, onRefresh, onStatus,
279
+ queueAddFn = queueAdd, remoteQueueAddFn = remoteQueueAdd, readHostsFn = readHosts,
280
+ } = {}) {
281
+ const agent = state.agents[state.agentIndex];
282
+ const mode = WHEN_OPTIONS[state.whenIndex];
283
+ const atMs = mode === 'at' ? state.atMs : null;
284
+
285
+ if (state.forHost) {
286
+ const hostName = state.forHost;
287
+ const cwd = state.path;
288
+ const entry = readHostsFn()[hostName];
289
+ setForm(null); // fire-and-forget — the ssh round trip reports back later
290
+ remoteQueueAddFn(hostName, { cwd, agent, prompt: value, mode, atMs }, { entryOrDest: entry })
291
+ .then((res) => {
292
+ onStatus?.(res.ok
293
+ ? `prompts: queued ${res.id || ''} for ${agent} on ${hostName}:${shortenCwd(cwd)}`
294
+ : `prompts: queue add on ${hostName} failed — ${res.error || 'unknown error'}`);
295
+ })
296
+ .catch((e) => onStatus?.(`prompts: queue add on ${hostName} failed — ${e.message}`));
297
+ return;
298
+ }
299
+
300
+ const result = queueAddFn({ cwd: state.path, agent, prompt: value, mode, atMs });
301
+ if (!result.ok) {
302
+ dispatch({
303
+ type: 'SUBMIT_ERROR',
304
+ error: result.error === 'duplicate'
305
+ ? `duplicate — matches existing queued prompt ${result.existing?.id}`
306
+ : result.error,
307
+ });
308
+ return;
309
+ }
310
+ setForm(null);
311
+ onRefresh?.();
312
+ onStatus?.(`prompts: queued ${result.entry.id} for ${agent} in ${shortenCwd(state.path)}`);
313
+ }
314
+
315
+ // Same idea for the list view's y/n remove confirmation — pulled out so the
316
+ // queueRemove call and its onRefresh wiring have a seam a test can drive
317
+ // with a stub, without going through useInput's raw keypress handling.
318
+ export function removeEntry(id, { setConfirmId, onRefresh, queueRemoveFn = queueRemove } = {}) {
319
+ const removed = queueRemoveFn(id);
320
+ setConfirmId(null);
321
+ onRefresh?.();
322
+ return removed;
323
+ }
324
+
325
+ // -- tab -------------------------------------------------------------------
326
+
327
+ export function PromptsTab({
328
+ data, now = Date.now(), sessions = [], selected = 0, onSelect,
329
+ onRefresh, onStatus, onFormOpenChange,
330
+ initialForm = null, // test seam only — App.js never passes this; lets tests
331
+ // mount straight into a given wizard step without
332
+ // needing to simulate raw keypresses through useInput.
333
+ initialConfirmId = null, // test seam only, same idea as initialForm — lets
334
+ // a test mount straight into the y/n remove
335
+ // confirmation without simulating raw keypresses.
336
+ } = {}) {
337
+ const [form, setForm] = useState(initialForm);
338
+ const [confirmId, setConfirmId] = useState(initialConfirmId);
339
+ const entries = data || [];
340
+
341
+ // Tell the parent whenever the form OR the remove y/n confirmation is
342
+ // active, so it can suppress the global tab-switch/quit/refresh/selection
343
+ // keys — otherwise 'q' during a confirm quits the whole dashboard instead
344
+ // of being swallowed by the "everything but y/n/esc" guard below, and j/k
345
+ // moves the list selection out from under the entry being confirmed. And
346
+ // on unmount (e.g. a mouse click away to another tab mid-form/confirm) so
347
+ // that flag can never get stuck true and lock out keyboard input.
348
+ useEffect(() => { onFormOpenChange?.(form != null || confirmId != null); }, [form, confirmId]); // eslint-disable-line react-hooks/exhaustive-deps
349
+ useEffect(() => () => onFormOpenChange?.(false), []); // eslint-disable-line react-hooks/exhaustive-deps
350
+
351
+ const dispatch = (event) => setForm(prev => (prev == null ? prev : formReduce(prev, event)));
352
+
353
+ const onSubmitFinal = (value) => submitFinal(form, value, { setForm, dispatch, onRefresh, onStatus });
354
+
355
+ useInput((input, key) => {
356
+ if (confirmId) {
357
+ if (input === 'y') { removeEntry(confirmId, { setConfirmId, onRefresh }); return; }
358
+ if (input === 'n' || key.escape) { setConfirmId(null); }
359
+ return; // swallow everything else while a remove is pending confirmation
360
+ }
361
+ if (input === 'a') {
362
+ setForm(initFormState({
363
+ path: sessions?.[0]?.cwd || process.cwd(),
364
+ agents: enabledAgentIds(),
365
+ hosts: Object.keys(readHosts()),
366
+ }));
367
+ return;
368
+ }
369
+ if ((input === 'd' || input === 'x') && entries.length) {
370
+ const entry = entries[Math.min(selected, entries.length - 1)];
371
+ if (entry) setConfirmId(entry.id);
372
+ }
373
+ }, { isActive: form == null });
374
+
375
+ if (!data) return h(Text, { color: theme.muted }, 'Loading…');
376
+
377
+ let formView = null;
378
+ if (form) {
379
+ if (form.step === 'path') formView = h(PathStep, { state: form, dispatch });
380
+ else if (form.step === 'prompt') formView = h(PromptStep, { state: form, dispatch, onSubmitFinal });
381
+ else {
382
+ const StepComp = STEP_COMPONENTS[form.step];
383
+ formView = StepComp ? h(StepComp, { state: form, dispatch }) : null;
384
+ }
385
+ return formView;
386
+ }
387
+
388
+ return h(Box, { flexDirection: 'column' },
389
+ h(Text, { color: theme.muted }, `${entries.length} queued prompt(s)`),
390
+ h(Text, null, ' '),
391
+ entries.length === 0
392
+ ? h(Text, { color: theme.muted, dimColor: true }, 'no queued prompts — press a to add')
393
+ : entries.map((e, i) => {
394
+ const sel = i === selected;
395
+ return h(Text, { key: e.id },
396
+ h(Text, { color: theme.accent, bold: true }, sel ? '❯ ' : ' '),
397
+ h(Text, { color: theme.bright, bold: sel }, e.id),
398
+ h(Text, { color: sel ? theme.bright : theme.muted },
399
+ ` ${(e.agent || 'claude').padEnd(6)} ${shortenCwd(e.cwd || '').slice(0, 24).padEnd(24)} `
400
+ + `${fmtDue(e, now).padEnd(10)} ${(e.status || '').padEnd(9)} "${truncate(e.prompt)}"`),
401
+ );
402
+ }),
403
+ confirmId ? h(Text, { color: theme.warn }, `remove ${confirmId}? (y/n)`) : null,
404
+ h(Text, { color: theme.muted, dimColor: true }, 'a add · d/x remove'),
405
+ );
406
+ }
@@ -0,0 +1,33 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { theme } from '../theme.js';
4
+ import { Clickable } from '../mouse.js';
5
+
6
+ const h = React.createElement;
7
+
8
+ export function SessionsTab({ data, selected = 0, onSelect } = {}) {
9
+ if (!data) return h(Text, { color: theme.muted }, 'Loading…');
10
+ if (data.length === 0) return h(Text, { color: theme.muted }, 'No unsnooze-owned mux sessions.');
11
+
12
+ return h(Box, { flexDirection: 'column' },
13
+ h(Text, { color: theme.muted }, `${data.length} mux session(s)`),
14
+ h(Text, null, ' '),
15
+ ...data.map((s, i) => {
16
+ const sel = i === selected;
17
+ return h(Clickable, { key: s.name + s.mux, onClick: () => onSelect?.(i), flexDirection: 'column', marginBottom: 1 },
18
+ h(Text, null,
19
+ h(Text, { color: theme.accent, bold: true }, sel ? '❯ ' : ' '),
20
+ h(Text, { color: s.exited ? theme.muted : theme.ok }, s.exited ? '○ exited' : '● live '),
21
+ h(Text, { color: theme.bright, bold: sel }, ` ${s.name}`),
22
+ h(Text, { color: theme.muted }, ` (${s.mux})`),
23
+ ),
24
+ h(Text, { color: theme.muted }, ` panes: ${s.panes?.length ? s.panes.join(', ') : '(none)'}`),
25
+ s.attach ? h(Text, { color: theme.info }, ` attach: ${s.attach}`) : null,
26
+ ...(s.records || []).map((r, j) =>
27
+ h(Text, { key: j, color: theme.muted, dimColor: true },
28
+ ` · ${r.status} ${(r.agent || '?').padEnd(6)} ${(r.cwd || '?').slice(0, 48)} pane ${r.pane ?? '-'}`),
29
+ ),
30
+ );
31
+ }),
32
+ );
33
+ }
@@ -0,0 +1,58 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { fmtCountdown, bar, countdownPct } from '../data.js';
4
+ import { theme, statusGlyph, gaugeColor } from '../theme.js';
5
+ import { Clickable } from '../mouse.js';
6
+
7
+ const h = React.createElement;
8
+
9
+ export function StatusTab({ data, selected = 0, onSelect } = {}) {
10
+ if (!data) return h(Text, { color: theme.muted }, 'Loading…');
11
+ const { sessions, paused, now, promptQueue } = data;
12
+ const sorted = [...sessions].sort((a, b) => (a.resetAt || 0) - (b.resetAt || 0));
13
+ const queuedCount = (promptQueue || []).filter(e => e.status === 'pending' || e.status === 'launching').length;
14
+
15
+ return h(Box, { flexDirection: 'column' },
16
+ h(Text, null,
17
+ h(Text, { color: theme.bright }, `${sessions.length} tracked`),
18
+ paused ? h(Text, { color: theme.warn }, ' · auto-resume PAUSED') : null,
19
+ ),
20
+ h(Text, null, ' '),
21
+ sorted.length === 0
22
+ ? h(Text, { color: theme.muted }, 'No tracked sessions — nothing is limit-stopped right now.')
23
+ : sorted.map((s, i) => {
24
+ const id = s.sessionId ? s.sessionId.slice(0, 8) : '(no id)';
25
+ const sel = i === selected;
26
+ const glyph = statusGlyph(s.status);
27
+ const pct = countdownPct(s.resetAt, now);
28
+ const cd = s.resetAt != null ? fmtCountdown(s.resetAt - now) : '?';
29
+ return h(Clickable, { key: s.key || i, onClick: () => onSelect?.(i), flexDirection: 'column', marginBottom: 1 },
30
+ h(Text, null,
31
+ h(Text, { color: theme.accent, bold: true }, sel ? '❯ ' : ' '),
32
+ h(Text, { color: glyph.color }, `${glyph.dot} ${(s.status || '?').toUpperCase().padEnd(9)}`),
33
+ h(Text, { color: theme.bright, bold: sel }, ` ${id}`),
34
+ h(Text, { color: sel ? theme.bright : theme.muted },
35
+ ` ${(s.agent || 'claude').padEnd(6)} ${(s.limitType || '?').padEnd(7)}`),
36
+ ),
37
+ s.cwd ? h(Text, { color: theme.muted }, ` ${s.cwd}`) : null,
38
+ s.resetAt
39
+ ? h(Text, null,
40
+ h(Text, { color: theme.muted }, ' resets '),
41
+ h(Text, { color: theme.bright }, cd),
42
+ pct != null
43
+ ? h(Text, { color: gaugeColor(pct) }, ` ${bar(pct, 14)}`)
44
+ : null,
45
+ )
46
+ : null,
47
+ h(Text, { color: theme.muted, dimColor: true },
48
+ ` mux ${s.mux ?? '-'} · pane ${s.pane ?? '-'} · attempts ${s.attempts ?? 0}`
49
+ + (s.lastError ? ` · err: ${String(s.lastError).slice(0, 40)}` : '')
50
+ + (s.workspaceHold ? ' · held' : ''),
51
+ ),
52
+ );
53
+ }),
54
+ queuedCount > 0
55
+ ? h(Text, { color: theme.muted }, `${queuedCount} prompt(s) queued — tab 7`)
56
+ : null,
57
+ );
58
+ }
@@ -0,0 +1,68 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import { bar } from '../data.js';
4
+ import { theme, gaugeColor } from '../theme.js';
5
+ import { fmtUsageProvenance, fmtDuration } from '../../usage.js';
6
+
7
+ const h = React.createElement;
8
+
9
+ function fmtTok(n) {
10
+ if (!Number.isFinite(n)) return '?';
11
+ if (n >= 1000) return `~${Math.round(n / 1000)}k`;
12
+ return `~${Math.round(n)}`;
13
+ }
14
+
15
+ export function UsageTab({ data }) {
16
+ if (!data) return h(Text, { color: theme.muted }, 'Loading usage… (cold scan)');
17
+ const warn = (data.warnAt || [80, 95]).join(',');
18
+ const daemon = data.daemonRunning ? 'running' : 'not running';
19
+
20
+ return h(Box, { flexDirection: 'column' },
21
+ h(Text, { color: theme.muted }, `daemon ${daemon} · warnings at ${warn}%`),
22
+ h(Text, null, ' '),
23
+ ...(data.agents || []).flatMap(a => {
24
+ if (!a.windows?.length) {
25
+ return [h(Text, { key: a.agent },
26
+ h(Text, { color: theme.bright, bold: true }, a.agent.padEnd(7)),
27
+ h(Text, { color: theme.muted }, ' (no recent usage data)'),
28
+ ), h(Text, { key: a.agent + '-sp' }, ' ')];
29
+ }
30
+ const rows = a.windows.map((w, i) => {
31
+ const pct = w.ladder?.pct;
32
+ const pctStr = pct != null
33
+ ? `${w.ladder.tier === 'exact' ? '' : '~'}${Math.round(pct)}%${w.ladder.tier === 'exact' ? ' used' : ''}`
34
+ : (w.ladder?.used != null ? `${fmtTok(w.ladder.used)} tok` : '—');
35
+ const lines = [
36
+ h(Text, { key: `${a.agent}-${w.label}-h` },
37
+ h(Text, { color: theme.bright, bold: i === 0 }, (i === 0 ? a.agent : '').padEnd(7)),
38
+ h(Text, { color: theme.muted }, ` ${String(w.label).padEnd(7)} `),
39
+ h(Text, { color: Number.isFinite(pct) ? gaugeColor(pct) : theme.muted }, bar(pct ?? 0, 20)),
40
+ h(Text, { color: theme.bright }, ` ${pctStr} `),
41
+ h(Text, { color: theme.muted, dimColor: true }, fmtUsageProvenance(w.ladder)),
42
+ ),
43
+ ];
44
+ if (w.burn && !w.infoOnly) {
45
+ let burnLine = 'idle — no active burn';
46
+ if (w.burn.warmingUp) burnLine = 'warming up (<10 active min)';
47
+ else if (!w.burn.idle && w.burn.unit === 'pct') {
48
+ burnLine = `~${w.burn.burnPerMin.toFixed(2)} %/min · ${Math.round(w.burn.activeMin)} active min`;
49
+ } else if (!w.burn.idle) {
50
+ burnLine = `${fmtTok(w.burn.burnPerMin)} tok/min · ${Math.round(w.burn.activeMin)} active min`;
51
+ }
52
+ lines.push(h(Text, { key: `${a.agent}-${w.label}-b`, color: theme.muted },
53
+ ` burn ${burnLine}`));
54
+ }
55
+ if (w.eta && !w.infoOnly) {
56
+ const lo = fmtDuration(w.eta.loMs);
57
+ const hi = fmtDuration(w.eta.hiMs);
58
+ const wall = lo && hi && lo !== hi ? `~${lo}–${hi}` : `~${lo || hi}`;
59
+ lines.push(h(Text, { key: `${a.agent}-${w.label}-e`, color: theme.warn },
60
+ ` wall ${wall} at this pace`));
61
+ }
62
+ return lines;
63
+ });
64
+ return [...rows.flat(), h(Text, { key: a.agent + '-gap' }, ' ')];
65
+ }),
66
+ h(Text, { color: theme.muted, dimColor: true }, 'Claude sums are a lower bound (account-pooled with claude.ai).'),
67
+ );
68
+ }
@@ -0,0 +1,32 @@
1
+ // Semantic color tokens (matches assets/banner.svg).
2
+ // Discipline: amber = brand/selection/focus ONLY; state colors stay ANSI so
3
+ // warnings/errors read correctly on any terminal theme and in monochrome.
4
+ export const theme = {
5
+ accent: '#f59e0b', // amber — brand, active tab, focus
6
+ accent2: '#fb7185', // rose — z's, critical burn escalation
7
+ ok: 'green',
8
+ warn: 'yellow',
9
+ crit: 'red',
10
+ info: 'cyan',
11
+ muted: 'gray',
12
+ bright: 'white',
13
+ };
14
+
15
+ // Gauge color by usage threshold (accent-free: semantic only).
16
+ export function gaugeColor(pct) {
17
+ if (!Number.isFinite(pct)) return theme.muted;
18
+ if (pct >= 95) return theme.crit;
19
+ if (pct >= 80) return theme.warn;
20
+ return theme.ok;
21
+ }
22
+
23
+ // Status → { dot, color } — ● live/active, ◐ transitioning, ○ inert.
24
+ export function statusGlyph(status) {
25
+ const s = String(status || '').toLowerCase();
26
+ if (s === 'stopped') return { dot: '●', color: theme.warn };
27
+ if (s === 'resumed') return { dot: '●', color: theme.ok };
28
+ if (s === 'resuming') return { dot: '◐', color: theme.info };
29
+ if (s === 'failed') return { dot: '●', color: theme.crit };
30
+ if (s === 'cancelled') return { dot: '○', color: theme.muted };
31
+ return { dot: '·', color: theme.muted };
32
+ }
package/src/doctor.js CHANGED
@@ -13,6 +13,7 @@ import { join, dirname } from 'node:path';
13
13
  import { CLAUDE_SETTINGS, RESUMER_LOCK } from './config.js';
14
14
  import { getMultiplexer } from './multiplexer.js';
15
15
  import { makeLogger } from './logger.js';
16
+ import { shouldUseTui, formatDoctorTui } from './tui.js';
16
17
 
17
18
  const log = makeLogger('doctor');
18
19
 
@@ -269,25 +270,30 @@ export async function cmdDoctor(rest = [], deps = {}) {
269
270
  const health = report.findings.filter(f => f.kind === 'health');
270
271
  const info = report.findings.filter(f => f.kind === 'info');
271
272
 
272
- if (legacy.length) {
273
- print('unsnooze doctor — leftovers from the old claude-session-guard (csg) install:');
274
- for (const f of legacy) {
275
- print(` ✗ ${f.title}`);
276
- if (f.detail) print(f.detail);
273
+ if (shouldUseTui() && print === console.log) {
274
+ print(formatDoctorTui(report, { color: true }));
275
+ if (report.healthy) return 0;
276
+ } else {
277
+ if (legacy.length) {
278
+ print('unsnooze doctor — leftovers from the old claude-session-guard (csg) install:');
279
+ for (const f of legacy) {
280
+ print(` ✗ ${f.title}`);
281
+ if (f.detail) print(f.detail);
282
+ }
277
283
  }
278
- }
279
- if (health.length) {
280
- print('unsnooze doctor — install health:');
281
- for (const f of health) {
282
- print(` ✗ ${f.title}`);
283
- if (f.detail) print(f.detail);
284
+ if (health.length) {
285
+ print('unsnooze doctor — install health:');
286
+ for (const f of health) {
287
+ print(` ✗ ${f.title}`);
288
+ if (f.detail) print(f.detail);
289
+ }
284
290
  }
285
- }
286
- for (const f of info) print(` · ${f.title}`);
291
+ for (const f of info) print(` · ${f.title}`);
287
292
 
288
- if (report.healthy) {
289
- print('unsnooze doctor: all clear — install is healthy.');
290
- return 0;
293
+ if (report.healthy) {
294
+ print('unsnooze doctor: all clear — install is healthy.');
295
+ return 0;
296
+ }
291
297
  }
292
298
 
293
299
  if (wantFix) {