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,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 (
|
|
273
|
-
print(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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) {
|