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.
- package/CHANGELOG.md +84 -0
- package/README.md +286 -22
- package/bin/unsnooze.js +52 -3
- package/package.json +7 -4
- package/src/agents/agy.js +2 -0
- package/src/agents/claude.js +2 -0
- package/src/agents/codex.js +2 -0
- package/src/agents/grok.js +2 -0
- package/src/agents/index.js +7 -1
- package/src/agents/kimi.js +2 -0
- package/src/agents/opencode.js +2 -0
- package/src/agents/qwen.js +2 -0
- package/src/askpass.js +188 -0
- package/src/cli.js +149 -25
- package/src/config.js +15 -0
- package/src/dashboard/App.js +390 -0
- package/src/dashboard/Logo.js +74 -0
- package/src/dashboard/components/TextInput.js +63 -0
- package/src/dashboard/data.js +143 -0
- package/src/dashboard/mark.js +51 -0
- package/src/dashboard/mouse-protocol.js +75 -0
- package/src/dashboard/mouse.js +125 -0
- package/src/dashboard/run.js +108 -0
- package/src/dashboard/tabs/DoctorTab.js +35 -0
- package/src/dashboard/tabs/FleetTab.js +94 -0
- package/src/dashboard/tabs/LogsTab.js +28 -0
- package/src/dashboard/tabs/PromptsTab.js +406 -0
- package/src/dashboard/tabs/SessionsTab.js +33 -0
- package/src/dashboard/tabs/StatusTab.js +58 -0
- package/src/dashboard/tabs/UsageTab.js +68 -0
- package/src/dashboard/theme.js +32 -0
- package/src/doctor.js +22 -16
- package/src/fleet.js +927 -0
- package/src/hook.js +19 -0
- package/src/install.js +9 -0
- package/src/monitor.js +14 -0
- package/src/prompt-queue.js +561 -0
- package/src/prompt.js +411 -0
- package/src/remote.js +223 -0
- package/src/resumer.js +60 -16
- package/src/settings.js +13 -0
- package/src/state.js +33 -9
- package/src/time-parser.js +15 -1
- package/src/tui.js +395 -0
- package/src/usage.js +1537 -0
- package/src/watcher.js +79 -8
- package/src/watchers/claude.js +3 -0
- package/src/watchers/codex.js +8 -8
- 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
|
+
}
|