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,390 @@
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
3
+ import { Logo } from './Logo.js';
4
+ import { theme } from './theme.js';
5
+ import { StatusTab } from './tabs/StatusTab.js';
6
+ import { UsageTab } from './tabs/UsageTab.js';
7
+ import { SessionsTab } from './tabs/SessionsTab.js';
8
+ import { DoctorTab } from './tabs/DoctorTab.js';
9
+ import { LogsTab } from './tabs/LogsTab.js';
10
+ import { FleetTab } from './tabs/FleetTab.js';
11
+ import { PromptsTab } from './tabs/PromptsTab.js';
12
+ import {
13
+ loadStatusSnapshot,
14
+ loadUsageSnapshot,
15
+ loadSessionsSnapshot,
16
+ loadDoctorSnapshot,
17
+ loadLogsSnapshot,
18
+ loadFleetSnapshot,
19
+ flattenFleetStopped,
20
+ } from './data.js';
21
+ import { TAGLINE } from './mark.js';
22
+ import { MouseProvider, useMouse, useMouseWheel, Clickable } from './mouse.js';
23
+ import { isMouseNoise } from './mouse-protocol.js';
24
+ import { remoteAction } from '../fleet.js';
25
+
26
+ const h = React.createElement;
27
+
28
+ const TABS = [
29
+ { id: 'status', label: 'Status', key: '1' },
30
+ { id: 'usage', label: 'Usage', key: '2' },
31
+ { id: 'sessions', label: 'Sessions', key: '3' },
32
+ { id: 'doctor', label: 'Doctor', key: '4' },
33
+ { id: 'logs', label: 'Logs', key: '5' },
34
+ { id: 'fleet', label: 'Fleet', key: '6' },
35
+ { id: 'prompts', label: 'Prompts', key: '7' },
36
+ ];
37
+
38
+ const HELP_ROWS = [
39
+ ['1–7', 'switch tab'],
40
+ ['tab / shift-tab', 'next / prev tab'],
41
+ ['j k / ↓ ↑', 'move selection'],
42
+ ['r', 'refresh now'],
43
+ ['R / C', 'fleet: resume / cancel selected remote session'],
44
+ ['m', 'toggle mouse (click tabs/rows, wheel scroll)'],
45
+ ['shift+click', 'select text while mouse is on (Option in iTerm2)'],
46
+ ['?', 'toggle this help'],
47
+ ['q', 'quit'],
48
+ ];
49
+
50
+ function TabRow({ active, onSelect }) {
51
+ return h(Box, { flexDirection: 'row' },
52
+ ...TABS.map((t, i) =>
53
+ h(Clickable, { key: t.id, onClick: () => onSelect(i), flexDirection: 'row' },
54
+ h(Text, { color: i === active ? theme.accent : theme.muted, bold: i === active },
55
+ ` ${t.key} `),
56
+ h(Text, {
57
+ color: i === active ? 'black' : theme.muted,
58
+ backgroundColor: i === active ? theme.accent : undefined,
59
+ bold: i === active,
60
+ }, `${t.label}`),
61
+ h(Text, null, ' '),
62
+ ),
63
+ ),
64
+ );
65
+ }
66
+
67
+ function HelpOverlay({ cols, rows }) {
68
+ const w = 40;
69
+ const hgt = HELP_ROWS.length + 4;
70
+ return h(Box, {
71
+ position: 'absolute',
72
+ top: Math.max(1, Math.floor((rows - hgt) / 2)),
73
+ left: Math.max(1, Math.floor((cols - w) / 2)),
74
+ width: w,
75
+ flexDirection: 'column',
76
+ borderStyle: 'round',
77
+ borderColor: theme.accent,
78
+ paddingX: 2,
79
+ paddingY: 1,
80
+ },
81
+ h(Text, { color: theme.accent, bold: true }, 'keys'),
82
+ ...HELP_ROWS.map(([k, desc], i) =>
83
+ h(Text, { key: i },
84
+ h(Text, { color: theme.bright }, k.padEnd(17)),
85
+ h(Text, { color: theme.muted }, desc),
86
+ ),
87
+ ),
88
+ );
89
+ }
90
+
91
+ function Dashboard({ initialTab = 'status' } = {}) {
92
+ const { exit } = useApp();
93
+ const { columns, rows: winRows } = useWindowSize();
94
+ const cols = columns || process.stdout.columns || 80;
95
+ const rows = winRows || process.stdout.rows || 24;
96
+
97
+ const startIdx = Math.max(0, TABS.findIndex(t => t.id === initialTab));
98
+ const [tab, setTab] = useState(startIdx >= 0 ? startIdx : 0);
99
+ const [selected, setSelected] = useState(0);
100
+ const [logScroll, setLogScroll] = useState(0);
101
+ const [showHelp, setShowHelp] = useState(false);
102
+ const [lastRefresh, setLastRefresh] = useState(Date.now());
103
+ const [nowTick, setNowTick] = useState(Date.now());
104
+ const [statusData, setStatusData] = useState(null);
105
+ const [usageData, setUsageData] = useState(null);
106
+ const [sessionsData, setSessionsData] = useState(null);
107
+ const [doctorData, setDoctorData] = useState(null);
108
+ const [logsData, setLogsData] = useState(null);
109
+ const [fleetData, setFleetData] = useState(null);
110
+ const [usageBusy, setUsageBusy] = useState(false);
111
+ const [fleetBusy, setFleetBusy] = useState(false);
112
+ const [err, setErr] = useState(null);
113
+ // Prompts tab's add-form owns raw keyboard input while a TextInput/step
114
+ // selector has focus (typing "r" or "1" into a prompt must not refresh or
115
+ // switch tabs underneath it) — set true while that form is open, false the
116
+ // instant it closes or unmounts. No pre-existing suppression flag: the `?`
117
+ // help overlay doesn't actually gate any keys today, it only toggles its
118
+ // own visibility — so this is a new, narrowly-scoped mechanism rather than
119
+ // an extension of one.
120
+ const [inputCaptured, setInputCaptured] = useState(false);
121
+ const tabRef = useRef(tab);
122
+ tabRef.current = tab;
123
+
124
+ const refreshStatus = useCallback(() => {
125
+ try {
126
+ setStatusData(loadStatusSnapshot());
127
+ setLastRefresh(Date.now());
128
+ setErr(null);
129
+ } catch (e) {
130
+ setErr(e.message);
131
+ }
132
+ }, []);
133
+
134
+ const refreshUsage = useCallback(async () => {
135
+ setUsageBusy(true);
136
+ try {
137
+ setUsageData(await loadUsageSnapshot());
138
+ setLastRefresh(Date.now());
139
+ setErr(null);
140
+ } catch (e) {
141
+ setErr(e.message);
142
+ } finally {
143
+ setUsageBusy(false);
144
+ }
145
+ }, []);
146
+
147
+ const refreshSessions = useCallback(async () => {
148
+ try {
149
+ setSessionsData(await loadSessionsSnapshot());
150
+ setLastRefresh(Date.now());
151
+ } catch (e) {
152
+ setErr(e.message);
153
+ }
154
+ }, []);
155
+
156
+ const refreshDoctor = useCallback(async () => {
157
+ try {
158
+ setDoctorData(await loadDoctorSnapshot());
159
+ setLastRefresh(Date.now());
160
+ } catch (e) {
161
+ setErr(e.message);
162
+ }
163
+ }, []);
164
+
165
+ const refreshLogs = useCallback(() => {
166
+ try {
167
+ setLogsData(loadLogsSnapshot({ maxLines: 200 }));
168
+ setLastRefresh(Date.now());
169
+ } catch (e) {
170
+ setErr(e.message);
171
+ }
172
+ }, []);
173
+
174
+ // ssh fan-out — timeout-bounded per host inside fetchFleet, but still a
175
+ // real network round trip. Awaited like loadUsageSnapshot; a busy flag
176
+ // keeps the header honest without blocking other tabs (the interval below
177
+ // only fires this on the fleet tab, every 15 ticks, or on-demand via `r`).
178
+ const refreshFleet = useCallback(async () => {
179
+ setFleetBusy(true);
180
+ try {
181
+ setFleetData(await loadFleetSnapshot());
182
+ setLastRefresh(Date.now());
183
+ setErr(null);
184
+ } catch (e) {
185
+ setErr(e.message);
186
+ } finally {
187
+ setFleetBusy(false);
188
+ }
189
+ }, []);
190
+
191
+ const refreshActive = useCallback(() => {
192
+ refreshStatus();
193
+ const id = TABS[tabRef.current]?.id;
194
+ if (id === 'usage') refreshUsage();
195
+ else if (id === 'sessions') refreshSessions();
196
+ else if (id === 'doctor') refreshDoctor();
197
+ else if (id === 'logs') refreshLogs();
198
+ else if (id === 'fleet') refreshFleet();
199
+ }, [refreshStatus, refreshUsage, refreshSessions, refreshDoctor, refreshLogs, refreshFleet]);
200
+
201
+ useEffect(() => {
202
+ refreshStatus();
203
+ const id = TABS[tab]?.id;
204
+ if (id === 'usage') refreshUsage();
205
+ else if (id === 'sessions') refreshSessions();
206
+ else if (id === 'doctor') refreshDoctor();
207
+ else if (id === 'logs') refreshLogs();
208
+ else if (id === 'fleet') refreshFleet();
209
+ setSelected(0);
210
+ setLogScroll(0);
211
+ }, [tab]); // eslint-disable-line react-hooks/exhaustive-deps
212
+
213
+ useEffect(() => {
214
+ let n = 0;
215
+ const id = setInterval(() => {
216
+ n += 1;
217
+ setNowTick(Date.now());
218
+ refreshStatus();
219
+ const tid = TABS[tabRef.current]?.id;
220
+ if (tid === 'logs' && n % 2 === 0) refreshLogs();
221
+ if (tid === 'usage' && n % 5 === 0) refreshUsage();
222
+ if (tid === 'sessions' && n % 3 === 0) refreshSessions();
223
+ if (tid === 'fleet' && n % 15 === 0) refreshFleet();
224
+ }, 1000);
225
+ return () => clearInterval(id);
226
+ }, [refreshStatus, refreshUsage, refreshSessions, refreshLogs, refreshFleet]);
227
+
228
+ const mouse = useMouse();
229
+
230
+ // Fire-and-forget: remote resume/cancel act on marks only (the remote's
231
+ // own daemon does the typing under its own gates — see src/fleet.js).
232
+ // Result surfaces in the header error slot like any other dashboard error.
233
+ const handleFleetAction = useCallback((verb) => {
234
+ const rows = flattenFleetStopped(fleetData);
235
+ if (rows.length === 0) return;
236
+ const row = rows[Math.min(selected, rows.length - 1)];
237
+ if (!row) return;
238
+ const { host, dest, entry, session } = row;
239
+ const id8 = session.key ? session.key.slice(0, 8) : (session.sessionId || '').slice(0, 8);
240
+ // Prefer the full host descriptor (carries auth/source/etc) so a
241
+ // password-auth host resumed/cancelled from the dashboard actually uses
242
+ // its configured auth instead of silently falling back to key auth —
243
+ // a bare dest string loses those fields.
244
+ remoteAction(host, entry || dest, verb, session.key)
245
+ .then((res) => {
246
+ setErr(res.ok
247
+ ? `fleet: ${verb} ${host}/${id8} ok`
248
+ : `fleet: ${verb} ${host}/${id8} failed — ${res.error}`);
249
+ })
250
+ .catch((e) => setErr(`fleet: ${verb} ${host}/${id8} failed — ${e.message}`));
251
+ }, [fleetData, selected]);
252
+
253
+ useInput((input, key) => {
254
+ if (isMouseNoise(input)) return; // leaked SGR fragments are not keys
255
+ // Prompts tab's add-form has raw keyboard focus (a TextInput or a step
256
+ // selector) — every one of the globals below (including q) must stay
257
+ // silent so typing "quit" or "7pm" into a field can't quit or jump tabs.
258
+ if (inputCaptured) return;
259
+ if (input === 'm') { mouse.toggle(); return; }
260
+ if (input === 'q') { exit(); return; }
261
+ if (input === '?') { setShowHelp(s => !s); return; }
262
+ if (key.escape && showHelp) { setShowHelp(false); return; }
263
+ if (input === 'r') { refreshActive(); return; }
264
+ if (key.tab) {
265
+ setTab(t => (key.shift ? (t + TABS.length - 1) % TABS.length : (t + 1) % TABS.length));
266
+ return;
267
+ }
268
+ if (input >= '1' && input <= '7') { setTab(Number(input) - 1); return; }
269
+ if (TABS[tabRef.current]?.id === 'fleet' && input === 'R') { handleFleetAction('resume'); return; }
270
+ if (TABS[tabRef.current]?.id === 'fleet' && input === 'C') { handleFleetAction('cancel'); return; }
271
+ if (input === 'j' || key.downArrow) { setSelected(s => s + 1); return; }
272
+ if (input === 'k' || key.upArrow) { setSelected(s => Math.max(0, s - 1)); }
273
+ });
274
+
275
+ useMouseWheel((ev) => {
276
+ if (!ev.wheel) return;
277
+ const id = TABS[tabRef.current]?.id;
278
+ if (id === 'status' || id === 'sessions' || id === 'fleet') {
279
+ if (ev.wheel === 'down') setSelected(s => s + 1);
280
+ else setSelected(s => Math.max(0, s - 1));
281
+ } else if (id === 'logs') {
282
+ setLogScroll(s => ev.wheel === 'up' ? s + 3 : Math.max(0, s - 3));
283
+ }
284
+ });
285
+
286
+ const listLen = TABS[tab]?.id === 'status'
287
+ ? (statusData?.sessions?.length || 0)
288
+ : TABS[tab]?.id === 'sessions'
289
+ ? (sessionsData?.length || 0)
290
+ : TABS[tab]?.id === 'fleet'
291
+ ? flattenFleetStopped(fleetData).length
292
+ : TABS[tab]?.id === 'prompts'
293
+ ? (statusData?.promptQueue?.length || 0)
294
+ : 0;
295
+ const sel = listLen ? Math.min(selected, listLen - 1) : 0;
296
+ const ago = Math.max(0, Math.round((nowTick - lastRefresh) / 1000));
297
+ const active = TABS[tab];
298
+ const statusWithNow = statusData ? { ...statusData, now: nowTick } : null;
299
+
300
+ // Floor test: full 6-row mark needs vertical room; short terminals get the
301
+ // one-line compact mark so data keeps the space.
302
+ const fullLogo = rows >= 24;
303
+ // Wordmark: chevron(8) + z(10) + UNSNOOZE(71) + right meta(~26) — needs width.
304
+ const showWordmark = fullLogo && cols >= 118;
305
+ const headerRows = (fullLogo ? 6 : 1) + 1 + (showWordmark ? 1 : 0); // + margin + tagline
306
+ const bodyRows = Math.max(3, rows - headerRows - 4); // tab row + rules + footer
307
+
308
+ let main;
309
+ switch (active?.id) {
310
+ case 'usage': main = h(UsageTab, { data: usageData }); break;
311
+ case 'sessions': main = h(SessionsTab, { data: sessionsData, selected: sel, onSelect: setSelected }); break;
312
+ case 'doctor': main = h(DoctorTab, { data: doctorData }); break;
313
+ case 'logs': main = h(LogsTab, { data: logsData, maxRows: bodyRows - 2, scroll: logScroll }); break;
314
+ case 'fleet': main = h(FleetTab, { data: fleetData, selected: sel, onSelect: setSelected }); break;
315
+ case 'prompts': main = h(PromptsTab, {
316
+ data: statusData?.promptQueue, now: nowTick, sessions: statusData?.sessions,
317
+ selected: sel, onSelect: setSelected, onRefresh: refreshStatus, onStatus: setErr,
318
+ onFormOpenChange: setInputCaptured,
319
+ }); break;
320
+ default: main = h(StatusTab, { data: statusWithNow, selected: sel, onSelect: setSelected });
321
+ }
322
+
323
+ const daemon = statusData?.daemonRunning
324
+ ? h(Text, null, h(Text, { color: theme.ok }, '● '), h(Text, { color: theme.muted }, `daemon ${statusData.resumerPid}`))
325
+ : h(Text, null, h(Text, { color: theme.crit }, '○ '), h(Text, { color: theme.muted }, 'daemon off'));
326
+
327
+ return h(Box, {
328
+ flexDirection: 'column',
329
+ width: cols,
330
+ height: rows,
331
+ paddingX: 1,
332
+ },
333
+ // Header: animated mark + live meta (breathing room above the tab row)
334
+ h(Box, { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: showWordmark ? 0 : 1 },
335
+ h(Logo, { compact: !fullLogo, wordmark: showWordmark }),
336
+ h(Box, { flexDirection: 'column', alignItems: 'flex-end' },
337
+ // The big wordmark says it already — keep the title only when hidden
338
+ showWordmark ? null : h(Text, null,
339
+ h(Text, { color: theme.accent, bold: true }, 'unsnooze'),
340
+ h(Text, { color: theme.muted }, ' live dashboard'),
341
+ ),
342
+ daemon,
343
+ h(Text, { color: theme.muted, dimColor: true },
344
+ `refreshed ${ago}s ago${usageBusy || fleetBusy ? ' · scanning…' : ''}`),
345
+ ),
346
+ ),
347
+ showWordmark
348
+ ? h(Box, { marginBottom: 1 },
349
+ h(Text, { color: theme.accent, bold: true }, '❯ '),
350
+ h(Text, { color: theme.muted }, TAGLINE.slice(2)),
351
+ )
352
+ : null,
353
+ // Tab row over a dim rule
354
+ h(TabRow, { active: tab, onSelect: setTab }),
355
+ h(Text, { color: theme.muted, dimColor: true }, '─'.repeat(Math.max(0, cols - 2))),
356
+ // Body
357
+ h(Box, { flexDirection: 'column', flexGrow: 1, paddingX: 1, paddingTop: 1 },
358
+ err ? h(Text, { color: theme.crit }, `error: ${err}`) : null,
359
+ main,
360
+ ),
361
+ // Footer: context keys + brand mark
362
+ h(Text, { color: theme.muted, dimColor: true }, '─'.repeat(Math.max(0, cols - 2))),
363
+ h(Box, { flexDirection: 'row', justifyContent: 'space-between' },
364
+ h(Box, { flexDirection: 'row' },
365
+ h(Text, { color: theme.muted }, ' 1-7 tabs · j/k move · '),
366
+ h(Clickable, { onClick: refreshActive }, h(Text, { color: theme.muted }, 'r refresh')),
367
+ h(Text, { color: theme.muted }, ' · '),
368
+ h(Clickable, { onClick: () => setShowHelp(s => !s) },
369
+ h(Text, { color: theme.muted }, h(Text, { color: theme.bright }, '?'), ' help')),
370
+ h(Text, { color: theme.muted }, ' · '),
371
+ h(Clickable, { onClick: () => mouse.toggle() },
372
+ h(Text, { color: theme.muted }, `m mouse ${mouse.enabled ? '✓' : '✗'}`)),
373
+ h(Text, { color: theme.muted }, ' · '),
374
+ h(Clickable, { onClick: exit }, h(Text, { color: theme.muted }, 'q quit')),
375
+ ),
376
+ h(Text, { color: theme.accent, bold: true }, '❯ '),
377
+ ),
378
+ showHelp
379
+ ? h(Clickable, {
380
+ onClick: () => setShowHelp(false),
381
+ position: 'absolute', top: 0, left: 0, width: cols, height: rows,
382
+ }, h(HelpOverlay, { cols, rows }))
383
+ : null,
384
+ );
385
+ }
386
+
387
+ export function App({ initialTab = 'status', mouseEnabled = true } = {}) {
388
+ return h(MouseProvider, { initialEnabled: mouseEnabled },
389
+ h(Dashboard, { initialTab }));
390
+ }
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { Box, Text, useAnimation, useIsScreenReaderEnabled } from 'ink';
3
+ import { CHEVRON, Z_FRAMES, WORDMARK, WORDMARK_SPLIT, MARK_LINE, markPlainText } from './mark.js';
4
+ import { theme } from './theme.js';
5
+
6
+ const h = React.createElement;
7
+
8
+ /*
9
+ * Animated brand mark: big "❯" chevron + z's rising off its tip.
10
+ * Slow idle loop (~2.7s cycle) — chrome animation should whisper, not flicker.
11
+ * Static under screen readers (frame pinned so the mark stays announceable).
12
+ */
13
+
14
+ const FRAME_MS = 450;
15
+
16
+ export function Logo({ compact = false, wordmark = false } = {}) {
17
+ const screenReader = useIsScreenReaderEnabled();
18
+ const { frame } = useAnimation({ interval: FRAME_MS, isActive: !screenReader && !compact });
19
+
20
+ if (compact) {
21
+ return h(Box, { flexDirection: 'row' },
22
+ h(Text, { color: theme.accent, bold: true }, '❯ '),
23
+ h(Text, { color: theme.accent2 }, 'z z z'),
24
+ );
25
+ }
26
+
27
+ const zs = screenReader ? Z_FRAMES[2] : Z_FRAMES[frame % Z_FRAMES.length];
28
+
29
+ return h(Box, { flexDirection: 'row', alignItems: 'flex-start' },
30
+ h(Box, { flexDirection: 'column' },
31
+ ...CHEVRON.map((line, i) =>
32
+ h(Text, { key: 'c' + i, color: theme.accent, bold: true }, line),
33
+ ),
34
+ ),
35
+ h(Box, { flexDirection: 'column', marginLeft: 1 },
36
+ ...zs.map((line, i) =>
37
+ h(Text, {
38
+ key: 'z' + i,
39
+ color: theme.accent2,
40
+ // The big Z leading the drift reads bright; freshly-spawned z's dim
41
+ bold: /Z/.test(line),
42
+ dimColor: /z/.test(line) && i >= 2,
43
+ }, line || ' '),
44
+ ),
45
+ ),
46
+ wordmark
47
+ ? h(Box, { flexDirection: 'column', marginLeft: 2 },
48
+ // "UN" amber, "SNOOZE" bright — the banner's two-tone treatment
49
+ ...WORDMARK.map((line, i) =>
50
+ h(Text, { key: 'w' + i },
51
+ h(Text, { color: theme.accent, bold: true }, line.slice(0, WORDMARK_SPLIT)),
52
+ h(Text, { color: theme.bright, bold: true }, line.slice(WORDMARK_SPLIT)),
53
+ ),
54
+ ),
55
+ )
56
+ : null,
57
+ );
58
+ }
59
+
60
+ export function logoContainsBrand() {
61
+ const blob = CHEVRON.join('\n') + Z_FRAMES.flat().join('\n');
62
+ return {
63
+ prompt: '❯',
64
+ hasZ: /z/i.test(blob),
65
+ hasChevron: /█|▀|▄/.test(blob),
66
+ frames: Z_FRAMES,
67
+ };
68
+ }
69
+
70
+ export function logoPlainText() {
71
+ return markPlainText();
72
+ }
73
+
74
+ export { MARK_LINE };
@@ -0,0 +1,63 @@
1
+ // Minimal controlled single-line text input — Ink ships no text-input
2
+ // primitive, and pulling in a dependency for one field type isn't worth it.
3
+ // `applyKey` is the pure edit reducer (the unit-test surface); the component
4
+ // is just useInput + a thin cursor-position wrapper around it.
5
+ import React, { useState } from 'react';
6
+ import { Text, useInput } from 'ink';
7
+ import { theme } from '../theme.js';
8
+
9
+ const h = React.createElement;
10
+
11
+ // Control bytes (incl. DEL) stripped from any inserted text — paste can
12
+ // arrive as a multi-char chunk carrying \n/\t/escape fragments; a single-line
13
+ // field never wants any of them, typed or pasted.
14
+ const CONTROL_RE = /[\x00-\x1f\x7f]/g; // eslint-disable-line no-control-regex
15
+
16
+ // state = {value, cursor}. Enter/Escape are submit/cancel signals owned by
17
+ // the component (not edits) — the reducer leaves state untouched for them.
18
+ export function applyKey(state, input, key) {
19
+ const { value, cursor } = state;
20
+ if (key.return || key.escape) return { value, cursor };
21
+ if (key.leftArrow) return { value, cursor: Math.max(0, cursor - 1) };
22
+ if (key.rightArrow) return { value, cursor: Math.min(value.length, cursor + 1) };
23
+ if (key.home || (key.ctrl && input === 'a')) return { value, cursor: 0 };
24
+ if (key.end || (key.ctrl && input === 'e')) return { value, cursor: value.length };
25
+ if (key.ctrl || key.meta) return { value, cursor }; // no other modifier combos edit
26
+ if (key.backspace || key.delete) {
27
+ if (cursor === 0) return { value, cursor };
28
+ return { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 };
29
+ }
30
+ if (!input) return { value, cursor };
31
+ const clean = input.replace(CONTROL_RE, '');
32
+ if (!clean) return { value, cursor };
33
+ return { value: value.slice(0, cursor) + clean + value.slice(cursor), cursor: cursor + clean.length };
34
+ }
35
+
36
+ export function TextInput({ value = '', onChange, onSubmit, onCancel, focus = true, placeholder = '' }) {
37
+ const [cursor, setCursor] = useState(value.length);
38
+ const c = Math.min(cursor, value.length);
39
+
40
+ useInput((input, key) => {
41
+ if (key.return) { onSubmit?.(value); return; }
42
+ if (key.escape) { onCancel?.(); return; }
43
+ const next = applyKey({ value, cursor: c }, input, key);
44
+ if (next.cursor !== c) setCursor(next.cursor);
45
+ if (next.value !== value) onChange?.(next.value);
46
+ }, { isActive: focus });
47
+
48
+ if (!value) {
49
+ return h(Text, { color: theme.muted, dimColor: true }, placeholder || (focus ? '█' : ' '));
50
+ }
51
+
52
+ const before = value.slice(0, c);
53
+ const after = value.slice(c + 1);
54
+ const atCursor = c < value.length ? value[c] : null;
55
+
56
+ return h(Text, null,
57
+ before,
58
+ atCursor != null
59
+ ? h(Text, { inverse: true }, atCursor)
60
+ : (focus ? '█' : ''),
61
+ after,
62
+ );
63
+ }
@@ -0,0 +1,143 @@
1
+ import { readFileSync, existsSync, statSync } from 'node:fs';
2
+ import { readState } from '../state.js';
3
+ import { getConfig } from '../settings.js';
4
+ import { LOG_FILE } from '../config.js';
5
+ import { queueList } from '../prompt-queue.js';
6
+ import {
7
+ buildUsageReport,
8
+ collectClaudeSamples,
9
+ collectCodexSamples,
10
+ readExactClaudeFromStatusline,
11
+ parseUsageWarnAt,
12
+ readUsageStore,
13
+ } from '../usage.js';
14
+ import { listOwnedSessions } from '../reap.js';
15
+ import { runDoctor } from '../doctor.js';
16
+ import { fetchFleet, readHosts } from '../fleet.js';
17
+
18
+ function pidAlive(pid) {
19
+ if (!pid) return false;
20
+ try { process.kill(pid, 0); return true; } catch { return false; }
21
+ }
22
+
23
+ export function loadStatusSnapshot() {
24
+ const state = readState();
25
+ const sessions = Object.values(state.sessions || {});
26
+ const daemonRunning = pidAlive(state.resumerPid);
27
+ return {
28
+ sessions,
29
+ // A recorded pid that no longer answers is stale — never show it as live.
30
+ resumerPid: daemonRunning ? state.resumerPid : null,
31
+ daemonRunning,
32
+ paused: !getConfig('autoResume'),
33
+ now: Date.now(),
34
+ promptQueue: queueList(),
35
+ };
36
+ }
37
+
38
+ export async function loadUsageSnapshot() {
39
+ const now = Date.now();
40
+ const state = readState();
41
+ const store = readUsageStore();
42
+ // Throttled cold scan — callers should not fire more than every ~5s
43
+ const claudeSamples = collectClaudeSamples({ now });
44
+ const codexSamples = collectCodexSamples({ now });
45
+ const exactClaude = readExactClaudeFromStatusline({ now });
46
+ const report = buildUsageReport({
47
+ now,
48
+ claudeSamples: [
49
+ ...(store.samples || []).filter(s => s.agent === 'claude' && s.weighted != null),
50
+ ...claudeSamples,
51
+ ],
52
+ codexSamples: [
53
+ ...(store.samples || []).filter(s => s.agent === 'codex' && s.primary),
54
+ ...codexSamples,
55
+ ],
56
+ calibration: state.calibration || {},
57
+ exactClaude,
58
+ ewma: store.ewma || {},
59
+ sessions: state.sessions,
60
+ exactPctHistory: store.exactPct?.claude5h || [],
61
+ });
62
+ report.daemonRunning = (() => {
63
+ if (!state.resumerPid) return false;
64
+ try { process.kill(state.resumerPid, 0); return true; } catch { return false; }
65
+ })();
66
+ report.warnAt = parseUsageWarnAt(getConfig('usageWarnAt'));
67
+ return report;
68
+ }
69
+
70
+ export async function loadSessionsSnapshot() {
71
+ return listOwnedSessions();
72
+ }
73
+
74
+ export async function loadDoctorSnapshot() {
75
+ return runDoctor({});
76
+ }
77
+
78
+ // Thin wrapper over fetchFleet() — ssh fan-out is per-host timeout-bounded
79
+ // there, so this can just be awaited like loadUsageSnapshot. `dest` and the
80
+ // full host `entry` (descriptor: dest/auth/source/env/service/account/cmd)
81
+ // are stitched back onto each result (fetchHost doesn't carry either) so
82
+ // tabs can build ssh -t attach hints and so App.js's R/C actions can pass
83
+ // the whole descriptor into remoteAction — a bare dest string loses the
84
+ // auth fields, which silently downgrades password hosts to key auth.
85
+ export async function loadFleetSnapshot() {
86
+ const hosts = readHosts();
87
+ const results = await fetchFleet({ hosts });
88
+ return results.map(r => ({ ...r, dest: hosts[r.host]?.dest, entry: hosts[r.host] }));
89
+ }
90
+
91
+ // Flat (host, dest, entry, session) rows for every stopped session across
92
+ // the fleet, in host order — the single source of truth for row ordering so
93
+ // FleetTab's rendered list and App's selection/keybinding math never drift
94
+ // apart. `entry` (the full host descriptor) rides along so R/C actions can
95
+ // hand remoteAction auth-aware args instead of a bare dest string.
96
+ export function flattenFleetStopped(fleetData) {
97
+ if (!fleetData) return [];
98
+ const out = [];
99
+ for (const r of fleetData) {
100
+ const sessions = (r.envelope?.sessions ?? []).filter(s => s.status === 'stopped');
101
+ for (const s of sessions) out.push({ host: r.host, dest: r.dest || r.host, entry: r.entry, session: s });
102
+ }
103
+ return out;
104
+ }
105
+
106
+ export function loadLogsSnapshot({ maxLines = 40 } = {}) {
107
+ if (!existsSync(LOG_FILE)) return { lines: [], path: LOG_FILE, missing: true };
108
+ try {
109
+ const text = readFileSync(LOG_FILE, 'utf-8');
110
+ const lines = text.split('\n').filter(Boolean).slice(-maxLines);
111
+ return { lines, path: LOG_FILE, missing: false, size: statSync(LOG_FILE).size };
112
+ } catch (err) {
113
+ return { lines: [`(read error: ${err.message})`], path: LOG_FILE, missing: false };
114
+ }
115
+ }
116
+
117
+ export function fmtCountdown(ms) {
118
+ if (ms <= 0) return 'due now';
119
+ const m = Math.round(ms / 60_000);
120
+ if (m < 60) return `${m}m`;
121
+ const h = Math.floor(m / 60);
122
+ return `${h}h ${m % 60}m`;
123
+ }
124
+
125
+ // Gauge with eighth-block partials for sub-cell precision (▏▎▍▌▋▊▉█).
126
+ const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉'];
127
+
128
+ export function bar(pct, width = 16) {
129
+ const p = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
130
+ const cells = (p / 100) * width;
131
+ const full = Math.floor(cells);
132
+ const rem = Math.round((cells - full) * 8);
133
+ const partial = full < width ? EIGHTHS[Math.min(rem, 7)] : '';
134
+ const used = full + (partial ? 1 : 0);
135
+ return '█'.repeat(full) + partial + '░'.repeat(Math.max(0, width - used));
136
+ }
137
+
138
+ export function countdownPct(resetAt, now = Date.now(), windowMs = 5 * 3_600_000) {
139
+ if (!Number.isFinite(resetAt)) return null;
140
+ const remain = resetAt - now;
141
+ if (remain <= 0) return 100;
142
+ return Math.min(100, Math.max(0, 100 - (remain / windowMs) * 100));
143
+ }