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,51 @@
|
|
|
1
|
+
// Brand mark: a big "❯" prompt chevron + rising z's — matches assets/banner.svg.
|
|
2
|
+
// Pure data (no ink/react) so both the live dashboard (Logo.js) and the plain
|
|
3
|
+
// CLI formatters (tui.js) can consume it.
|
|
4
|
+
|
|
5
|
+
// Two diagonal strokes meeting in a point — reads as "❯" / ">".
|
|
6
|
+
export const CHEVRON = [
|
|
7
|
+
'██▄ ',
|
|
8
|
+
' ▀██▄ ',
|
|
9
|
+
' ▀██▄',
|
|
10
|
+
' ▄██▀',
|
|
11
|
+
' ▄██▀ ',
|
|
12
|
+
'██▀ ',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// Rising z's, aligned row-for-row with CHEVRON: each z spawns at the chevron
|
|
16
|
+
// tip and drifts up-right, growing as it goes — accumulate, then break off.
|
|
17
|
+
export const Z_FRAMES = [
|
|
18
|
+
[' ', ' ', ' z ', ' ', ' ', ' '],
|
|
19
|
+
[' ', ' z ', ' z ', ' ', ' ', ' '],
|
|
20
|
+
[' Z ', ' z ', ' z ', ' ', ' ', ' '],
|
|
21
|
+
[' Z', ' z ', ' ', ' ', ' ', ' '],
|
|
22
|
+
[' ', ' z', ' ', ' ', ' ', ' '],
|
|
23
|
+
[' ', ' ', ' z ', ' ', ' ', ' '],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// UNSNOOZE wordmark — ANSI-shadow block caps (71 cols, 6 rows, same height
|
|
27
|
+
// as the chevron). WORDMARK_SPLIT is where "UN" ends: amber to the left,
|
|
28
|
+
// bright to the right — matches the banner treatment.
|
|
29
|
+
export const WORDMARK = [
|
|
30
|
+
'██╗ ██╗███╗ ██╗███████╗███╗ ██╗ ██████╗ ██████╗ ███████╗███████╗',
|
|
31
|
+
'██║ ██║████╗ ██║██╔════╝████╗ ██║██╔═══██╗██╔═══██╗╚══███╔╝██╔════╝',
|
|
32
|
+
'██║ ██║██╔██╗ ██║███████╗██╔██╗ ██║██║ ██║██║ ██║ ███╔╝ █████╗ ',
|
|
33
|
+
'██║ ██║██║╚██╗██║╚════██║██║╚██╗██║██║ ██║██║ ██║ ███╔╝ ██╔══╝ ',
|
|
34
|
+
'╚██████╔╝██║ ╚████║███████║██║ ╚████║╚██████╔╝╚██████╔╝███████╗███████╗',
|
|
35
|
+
' ╚═════╝ ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝',
|
|
36
|
+
];
|
|
37
|
+
export const WORDMARK_SPLIT = 19; // '██╗ ██╗' (U) + '███╗ ██╗' (N)
|
|
38
|
+
|
|
39
|
+
export const TAGLINE = '❯ wakes every limit-stopped AI session the moment the limit resets';
|
|
40
|
+
|
|
41
|
+
// One-line mark for compact chrome (footers, tiny terminals).
|
|
42
|
+
export const MARK_LINE = '❯ z z z';
|
|
43
|
+
|
|
44
|
+
export function markRows(frame = 2) {
|
|
45
|
+
const zs = Z_FRAMES[((frame % Z_FRAMES.length) + Z_FRAMES.length) % Z_FRAMES.length];
|
|
46
|
+
return CHEVRON.map((row, i) => ({ chevron: row, zs: zs[i] || '' }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function markPlainText(frame = 2) {
|
|
50
|
+
return markRows(frame).map(r => (r.chevron + ' ' + r.zs).trimEnd()).join('\n');
|
|
51
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SGR-1006 mouse protocol — pure functions, no React/Ink imports.
|
|
2
|
+
// Enable: button-event tracking (1002) + SGR encoding (1006). 1003 (any
|
|
3
|
+
// motion) is deliberately never requested: it floods stdin and is the worst
|
|
4
|
+
// leak class when cleanup is missed.
|
|
5
|
+
export const MOUSE_ENABLE = '\x1b[?1002h\x1b[?1006h';
|
|
6
|
+
// Disable every mode we (or a crashed predecessor) might have set — turning
|
|
7
|
+
// off a mode that was never on is harmless (tcell does the same).
|
|
8
|
+
export const MOUSE_DISABLE_ALL = '\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l\x1b[?1015l';
|
|
9
|
+
|
|
10
|
+
// \x1b[<b;x;yM (press/motion/wheel) or \x1b[<b;x;ym (release)
|
|
11
|
+
const SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
12
|
+
|
|
13
|
+
// b bit layout: 0/1/2 = left/middle/right (or wheel dir), +4 shift, +8 meta,
|
|
14
|
+
// +16 ctrl, +32 motion, +64 wheel.
|
|
15
|
+
function decodeEvent(b, x1, y1, final) {
|
|
16
|
+
const base = b & 3;
|
|
17
|
+
const ev = {
|
|
18
|
+
x: x1 - 1, // SGR is 1-based; everything downstream is 0-based
|
|
19
|
+
y: y1 - 1,
|
|
20
|
+
shift: (b & 4) !== 0,
|
|
21
|
+
meta: (b & 8) !== 0,
|
|
22
|
+
ctrl: (b & 16) !== 0,
|
|
23
|
+
wheel: null,
|
|
24
|
+
button: null,
|
|
25
|
+
};
|
|
26
|
+
if ((b & 64) !== 0 && b < 128) {
|
|
27
|
+
const wheel = base === 0 ? 'up' : base === 1 ? 'down' : null;
|
|
28
|
+
return { ...ev, type: 'wheel', wheel };
|
|
29
|
+
}
|
|
30
|
+
// b >= 128 means extra buttons (xterm 8-11); these map to button: null
|
|
31
|
+
const button = b >= 128 ? null : (base === 0 ? 'left' : base === 1 ? 'middle' : base === 2 ? 'right' : null);
|
|
32
|
+
if (final === 'm') return { ...ev, type: 'release', button };
|
|
33
|
+
if ((b & 32) !== 0) return { ...ev, type: button ? 'drag' : 'move', button };
|
|
34
|
+
return { ...ev, type: 'press', button };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Parse every complete SGR report in `text`. A report can be split across
|
|
38
|
+
// stdin chunks — `rest` returns the trailing partial (from its ESC) so the
|
|
39
|
+
// caller can prepend it to the next chunk.
|
|
40
|
+
export function parseSgrEvents(text) {
|
|
41
|
+
const events = [];
|
|
42
|
+
let lastEnd = 0;
|
|
43
|
+
SGR_RE.lastIndex = 0;
|
|
44
|
+
let m;
|
|
45
|
+
while ((m = SGR_RE.exec(text))) {
|
|
46
|
+
events.push(decodeEvent(parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10), m[4]));
|
|
47
|
+
lastEnd = m.index + m[0].length;
|
|
48
|
+
}
|
|
49
|
+
const tail = text.slice(lastEnd);
|
|
50
|
+
const escAt = tail.lastIndexOf('\x1b');
|
|
51
|
+
let rest = '';
|
|
52
|
+
if (escAt !== -1) {
|
|
53
|
+
const candidate = tail.slice(escAt);
|
|
54
|
+
// Only carry prefixes that can still become an SGR report.
|
|
55
|
+
// Cap at 32 chars to prevent unbounded growth on malformed input.
|
|
56
|
+
if (candidate.length <= 32 && /^\x1b(\[(<[\d;]*)?)?$/.test(candidate)) rest = candidate;
|
|
57
|
+
}
|
|
58
|
+
return { events, rest };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Ink strips the ESC prefix from sequences its key parser can't resolve, so
|
|
62
|
+
// useInput handlers can receive fragments like "[<35;10;5M". Guard with this.
|
|
63
|
+
export function isMouseNoise(input) {
|
|
64
|
+
return /\[?<\d+;\d+;\d+[Mm]/.test(String(input ?? ''));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Zones are plain rects ({x, y, width, height, ...payload}). Registration
|
|
68
|
+
// order is stacking order: the LAST hit wins so overlays beat what's under.
|
|
69
|
+
export function hitTest(rects, col, row) {
|
|
70
|
+
for (let i = rects.length - 1; i >= 0; i--) {
|
|
71
|
+
const r = rects[i];
|
|
72
|
+
if (col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height) return r;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Mouse context for the dashboard: enables SGR tracking, parses raw stdin,
|
|
2
|
+
// hit-tests presses/wheels against zones registered by <Clickable>.
|
|
3
|
+
// Keyboard parity is a hard rule — every click action has a key equivalent.
|
|
4
|
+
import React, { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react';
|
|
5
|
+
import { Box, useStdin, useStdout, measureElement } from 'ink';
|
|
6
|
+
import { MOUSE_ENABLE, MOUSE_DISABLE_ALL, parseSgrEvents, hitTest } from './mouse-protocol.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const MouseContext = createContext(null);
|
|
10
|
+
|
|
11
|
+
let zoneSeq = 0;
|
|
12
|
+
|
|
13
|
+
// Exported for tests: zones (insertion-ordered) → hitTest rects.
|
|
14
|
+
// `measure` is injectable; production uses ink's measureElement.
|
|
15
|
+
export function zonesToRects(zones, measure = measureElement) {
|
|
16
|
+
const rects = [];
|
|
17
|
+
for (const z of zones) {
|
|
18
|
+
if (!z.ref.current) continue;
|
|
19
|
+
let m;
|
|
20
|
+
try { m = measure(z.ref.current); } catch { continue; }
|
|
21
|
+
if (!m || m.width <= 0 || m.height <= 0) continue;
|
|
22
|
+
rects.push({ ...m, ...z });
|
|
23
|
+
}
|
|
24
|
+
return rects;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function MouseProvider({ initialEnabled = true, children }) {
|
|
28
|
+
const { stdin, isRawModeSupported } = useStdin();
|
|
29
|
+
const { stdout } = useStdout();
|
|
30
|
+
const supported = Boolean(isRawModeSupported && stdin && stdout);
|
|
31
|
+
const [enabled, setEnabled] = useState(initialEnabled && supported);
|
|
32
|
+
const zones = useRef(new Map()); // id -> { id, ref, onClick, onWheel }
|
|
33
|
+
const wheelFallbacks = useRef(new Set());
|
|
34
|
+
const carry = useRef('');
|
|
35
|
+
|
|
36
|
+
const dispatch = useCallback((ev) => {
|
|
37
|
+
if (ev.type === 'press' && ev.button === 'left') {
|
|
38
|
+
const withClick = [...zones.current.values()].filter(z => z.onClick);
|
|
39
|
+
const hit = hitTest(zonesToRects(withClick), ev.x, ev.y);
|
|
40
|
+
if (hit?.onClick) hit.onClick(ev);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (ev.type === 'wheel') {
|
|
44
|
+
const withWheel = [...zones.current.values()].filter(z => z.onWheel);
|
|
45
|
+
const hit = hitTest(zonesToRects(withWheel), ev.x, ev.y);
|
|
46
|
+
if (hit?.onWheel) { hit.onWheel(ev); return; }
|
|
47
|
+
for (const fn of wheelFallbacks.current) fn(ev);
|
|
48
|
+
}
|
|
49
|
+
// release / drag / move: no consumers today — deliberately dropped.
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!enabled || !supported) return undefined;
|
|
54
|
+
carry.current = '';
|
|
55
|
+
stdout.write(MOUSE_ENABLE);
|
|
56
|
+
const onData = (chunk) => {
|
|
57
|
+
const { events, rest } = parseSgrEvents(carry.current + String(chunk));
|
|
58
|
+
carry.current = rest;
|
|
59
|
+
for (const ev of events) dispatch(ev);
|
|
60
|
+
};
|
|
61
|
+
stdin.on('data', onData);
|
|
62
|
+
// Ctrl-Z suspend: ink restores raw mode/alt screen on resume, but mouse
|
|
63
|
+
// modes are ours — re-assert on SIGCONT (research: lazygit #1764 class).
|
|
64
|
+
const onCont = () => stdout.write(MOUSE_ENABLE);
|
|
65
|
+
process.on('SIGCONT', onCont);
|
|
66
|
+
return () => {
|
|
67
|
+
stdin.off('data', onData);
|
|
68
|
+
process.off('SIGCONT', onCont);
|
|
69
|
+
stdout.write(MOUSE_DISABLE_ALL);
|
|
70
|
+
};
|
|
71
|
+
}, [enabled, supported, stdin, stdout, dispatch]);
|
|
72
|
+
|
|
73
|
+
const toggle = useCallback(() => setEnabled(e => supported && !e), [supported]);
|
|
74
|
+
const registerZone = useCallback((zone) => {
|
|
75
|
+
zones.current.set(zone.id, zone);
|
|
76
|
+
return () => zones.current.delete(zone.id);
|
|
77
|
+
}, []);
|
|
78
|
+
const registerWheelFallback = useCallback((fn) => {
|
|
79
|
+
wheelFallbacks.current.add(fn);
|
|
80
|
+
return () => wheelFallbacks.current.delete(fn);
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
return h(MouseContext.Provider, {
|
|
84
|
+
value: { enabled, supported, toggle, registerZone, registerWheelFallback },
|
|
85
|
+
}, children);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useMouse() {
|
|
89
|
+
const ctx = useContext(MouseContext);
|
|
90
|
+
return ctx
|
|
91
|
+
? { enabled: ctx.enabled, supported: ctx.supported, toggle: ctx.toggle }
|
|
92
|
+
: { enabled: false, supported: false, toggle: () => {} };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useMouseWheel(handler) {
|
|
96
|
+
const ctx = useContext(MouseContext);
|
|
97
|
+
const ref = useRef(handler);
|
|
98
|
+
ref.current = handler;
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!ctx) return undefined;
|
|
101
|
+
return ctx.registerWheelFallback((ev) => ref.current?.(ev));
|
|
102
|
+
}, [ctx]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A Box whose on-screen rect is a mouse zone. All Box props pass through.
|
|
106
|
+
export function Clickable({ onClick, onWheel, children, ...boxProps }) {
|
|
107
|
+
const ctx = useContext(MouseContext);
|
|
108
|
+
const ref = useRef(null);
|
|
109
|
+
const idRef = useRef(null);
|
|
110
|
+
if (idRef.current == null) idRef.current = `zone-${++zoneSeq}`;
|
|
111
|
+
const cbs = useRef({ onClick, onWheel });
|
|
112
|
+
cbs.current = { onClick, onWheel };
|
|
113
|
+
// Handler presence is fixed at mount (undefined→function post-mount is a no-op; remount to change).
|
|
114
|
+
// Handler identity stays fresh via cbs ref, so onClick/onWheel changes do not require re-registration.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!ctx) return undefined;
|
|
117
|
+
return ctx.registerZone({
|
|
118
|
+
id: idRef.current,
|
|
119
|
+
ref,
|
|
120
|
+
onClick: cbs.current.onClick ? (ev) => cbs.current.onClick?.(ev) : cbs.current.onClick,
|
|
121
|
+
onWheel: cbs.current.onWheel ? (ev) => cbs.current.onWheel?.(ev) : cbs.current.onWheel,
|
|
122
|
+
});
|
|
123
|
+
}, [ctx]);
|
|
124
|
+
return h(Box, { ref, ...boxProps }, children);
|
|
125
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Live full-screen dashboard — alternate screen until q / Ctrl+C.
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { App } from './App.js';
|
|
5
|
+
import { MOUSE_DISABLE_ALL } from './mouse-protocol.js';
|
|
6
|
+
import { getConfig } from '../settings.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
// The alt-screen reset does NOT clear mouse tracking modes — a crash or
|
|
11
|
+
// signal-kill without this leaves the user's shell with a hijacked mouse
|
|
12
|
+
// (the lazygit #1764 / opencode #26198 failure class). `exit` doesn't fire
|
|
13
|
+
// on signal default-kills and signal handlers don't cover process.exit(),
|
|
14
|
+
// so install both. Idempotent: only the first install registers.
|
|
15
|
+
let cleanupInstalled = false;
|
|
16
|
+
export function installMouseCleanup(stdout = process.stdout) {
|
|
17
|
+
if (cleanupInstalled) return () => {};
|
|
18
|
+
cleanupInstalled = true;
|
|
19
|
+
let done = false;
|
|
20
|
+
const writeDisable = () => {
|
|
21
|
+
try { stdout.write(MOUSE_DISABLE_ALL); } catch { /* stream gone */ }
|
|
22
|
+
};
|
|
23
|
+
const off = () => {
|
|
24
|
+
if (done) return;
|
|
25
|
+
done = true;
|
|
26
|
+
writeDisable();
|
|
27
|
+
};
|
|
28
|
+
const onExit = () => off();
|
|
29
|
+
// process.exit() here routes through signal-exit's patched reallyExit
|
|
30
|
+
// (installed by Ink), which runs Ink's unmount — restoring the alt-screen —
|
|
31
|
+
// before the process actually terminates. Fragile coupling: if Ink ever
|
|
32
|
+
// stops patching reallyExit, exit() would terminate before unmount runs.
|
|
33
|
+
const onSignal = (sig) => () => {
|
|
34
|
+
off();
|
|
35
|
+
process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
36
|
+
};
|
|
37
|
+
const onInt = onSignal('SIGINT');
|
|
38
|
+
const onTerm = onSignal('SIGTERM');
|
|
39
|
+
const onTstp = () => {
|
|
40
|
+
// Write mouse-off before ink suspends; MouseProvider re-enables on SIGCONT.
|
|
41
|
+
// Bypasses the `done` gate intentionally: SIGTSTP can fire repeatedly
|
|
42
|
+
// (stop/cont/stop) across a single process lifetime, unlike exit/INT/TERM.
|
|
43
|
+
writeDisable();
|
|
44
|
+
// Installing a SIGTSTP listener removes Node's default stop disposition,
|
|
45
|
+
// so an external `kill -TSTP` would otherwise no-op instead of suspending
|
|
46
|
+
// the process. Re-raise with SIGSTOP (uncatchable) to restore that
|
|
47
|
+
// behavior; SIGCONT resume re-enables the mouse via the provider's
|
|
48
|
+
// existing handler.
|
|
49
|
+
if (process.platform !== 'win32') process.kill(process.pid, 'SIGSTOP');
|
|
50
|
+
};
|
|
51
|
+
process.on('exit', onExit);
|
|
52
|
+
process.on('SIGINT', onInt);
|
|
53
|
+
process.on('SIGTERM', onTerm);
|
|
54
|
+
process.on('SIGTSTP', onTstp);
|
|
55
|
+
return () => {
|
|
56
|
+
done = true;
|
|
57
|
+
cleanupInstalled = false;
|
|
58
|
+
process.off('exit', onExit);
|
|
59
|
+
process.off('SIGINT', onInt);
|
|
60
|
+
process.off('SIGTERM', onTerm);
|
|
61
|
+
process.off('SIGTSTP', onTstp);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function shouldUseDashboard({
|
|
66
|
+
force = null,
|
|
67
|
+
json = false,
|
|
68
|
+
isTTY = process.stdout?.isTTY && process.stdin?.isTTY,
|
|
69
|
+
env = process.env,
|
|
70
|
+
} = {}) {
|
|
71
|
+
if (force === true) return true;
|
|
72
|
+
if (force === false) return false;
|
|
73
|
+
if (json) return false;
|
|
74
|
+
if (env.NO_COLOR != null && env.NO_COLOR !== '') return false;
|
|
75
|
+
if (env.CI === 'true' || env.CI === '1') return false;
|
|
76
|
+
if (env.TERM === 'dumb') return false;
|
|
77
|
+
return !!isTTY;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runDashboard({ tab = 'status' } = {}) {
|
|
81
|
+
if (!shouldUseDashboard()) {
|
|
82
|
+
console.error('unsnooze dashboard: requires an interactive TTY (not a pipe/CI).');
|
|
83
|
+
// Still show the brand so users see the logo even when falling back
|
|
84
|
+
const { logoPlainText } = await import('./Logo.js');
|
|
85
|
+
console.error('\n' + logoPlainText() + '\n');
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
installMouseCleanup(process.stdout);
|
|
90
|
+
const instance = render(h(App, { initialTab: tab, mouseEnabled: getConfig('mouse') !== false }), {
|
|
91
|
+
exitOnCtrlC: true,
|
|
92
|
+
// Full-screen: separate buffer like vim / htop / less — original scrollback restored on quit
|
|
93
|
+
alternateScreen: true,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await instance.waitUntilExit();
|
|
97
|
+
// Belt and braces: normal quit also clears modes (provider already did).
|
|
98
|
+
process.stdout.write(MOUSE_DISABLE_ALL);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function cmdDashboard(args = []) {
|
|
103
|
+
const tabArg = args.find(a => !a.startsWith('-'));
|
|
104
|
+
const tab = ['status', 'usage', 'sessions', 'doctor', 'logs', 'fleet', 'prompts'].includes(tabArg)
|
|
105
|
+
? tabArg
|
|
106
|
+
: 'status';
|
|
107
|
+
return runDashboard({ tab });
|
|
108
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
export function DoctorTab({ data }) {
|
|
8
|
+
if (!data) return h(Text, { color: theme.muted }, 'Running checks…');
|
|
9
|
+
const legacy = data.findings.filter(f => f.kind === 'legacy');
|
|
10
|
+
const health = data.findings.filter(f => f.kind === 'health');
|
|
11
|
+
const info = data.findings.filter(f => f.kind === 'info');
|
|
12
|
+
|
|
13
|
+
if (data.healthy) {
|
|
14
|
+
return h(Box, { flexDirection: 'column' },
|
|
15
|
+
h(Text, { color: theme.ok }, '✓ install is healthy'),
|
|
16
|
+
...info.map((f, i) => h(Text, { key: i, color: theme.muted }, `· ${f.title}`)),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return h(Box, { flexDirection: 'column' },
|
|
21
|
+
legacy.length ? h(Text, { color: theme.warn, bold: true }, 'legacy csg leftovers') : null,
|
|
22
|
+
...legacy.map((f, i) => h(Box, { key: 'l' + i, flexDirection: 'column' },
|
|
23
|
+
h(Text, { color: theme.crit }, `✗ ${f.title}`),
|
|
24
|
+
f.detail ? h(Text, { color: theme.muted }, ` ${String(f.detail).split('\n')[0]}`) : null,
|
|
25
|
+
)),
|
|
26
|
+
health.length ? h(Text, { color: theme.warn, bold: true }, 'install health') : null,
|
|
27
|
+
...health.map((f, i) => h(Box, { key: 'h' + i, flexDirection: 'column' },
|
|
28
|
+
h(Text, { color: theme.crit }, `✗ ${f.title}`),
|
|
29
|
+
f.detail ? h(Text, { color: theme.muted }, ` ${String(f.detail).split('\n')[0]}`) : null,
|
|
30
|
+
)),
|
|
31
|
+
...info.map((f, i) => h(Text, { key: 'i' + i, color: theme.muted }, `· ${f.title}`)),
|
|
32
|
+
h(Text, null, ' '),
|
|
33
|
+
h(Text, { color: theme.muted }, 'Run `unsnooze doctor --fix` outside the dashboard to apply fixes.'),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { fmtCountdown, flattenFleetStopped } from '../data.js';
|
|
4
|
+
import { theme } from '../theme.js';
|
|
5
|
+
import { Clickable } from '../mouse.js';
|
|
6
|
+
import { attachHintRemote } from '../../fleet.js';
|
|
7
|
+
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
|
|
10
|
+
// Per-host auth badge: `key` (default) or `pw:<source>` (env/keychain/command/
|
|
11
|
+
// prompt), so you can tell at a glance what a host authenticates with — and,
|
|
12
|
+
// for a needs-auth host, why. Null when the descriptor is absent (legacy row).
|
|
13
|
+
function authBadge(entry) {
|
|
14
|
+
if (!entry || !entry.auth) return null;
|
|
15
|
+
if (entry.auth === 'password') return `pw:${entry.source || 'prompt'}`;
|
|
16
|
+
return 'key';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Host-header glyph/label/color for each fetchFleet() state.
|
|
20
|
+
function hostHeader(r, now) {
|
|
21
|
+
if (r.state === 'online') {
|
|
22
|
+
const lat = r.latencyMs != null ? ` (${r.latencyMs}ms)` : '';
|
|
23
|
+
return { glyph: '●', color: theme.ok, label: `online${lat}` };
|
|
24
|
+
}
|
|
25
|
+
if (r.state === 'stale') {
|
|
26
|
+
const age = Number.isFinite(r.cachedAt) ? ` (${fmtCountdown(now - r.cachedAt)} old)` : '';
|
|
27
|
+
return { glyph: '◌', color: theme.muted, label: `stale${age}` };
|
|
28
|
+
}
|
|
29
|
+
if (r.state === 'skew') {
|
|
30
|
+
return { glyph: '△', color: theme.warn, label: 'version skew — update remote' };
|
|
31
|
+
}
|
|
32
|
+
if (r.state === 'needs-auth') {
|
|
33
|
+
return { glyph: '◐', color: theme.warn, label: `needs-auth${r.error ? ` (${r.error})` : ''}` };
|
|
34
|
+
}
|
|
35
|
+
if (r.state === 'unreachable') {
|
|
36
|
+
return { glyph: '○', color: theme.crit, label: `unreachable${r.error ? ` (${r.error})` : ''}` };
|
|
37
|
+
}
|
|
38
|
+
return { glyph: '○', color: theme.crit, label: `error${r.error ? ` (${r.error})` : ''}` };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function FleetTab({ data, selected = 0, onSelect } = {}) {
|
|
42
|
+
if (!data) return h(Text, { color: theme.muted }, 'Loading…');
|
|
43
|
+
if (data.length === 0) {
|
|
44
|
+
return h(Box, { flexDirection: 'column' },
|
|
45
|
+
h(Text, { color: theme.muted }, 'No hosts registered.'),
|
|
46
|
+
h(Text, null, ' '),
|
|
47
|
+
h(Text, { color: theme.info }, ' unsnooze hosts add <name> [ssh-destination]'),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
// Shared with App.js's R/C keybinding math so the rendered order and the
|
|
53
|
+
// "selected row" the keys act on can never drift apart.
|
|
54
|
+
const indexOf = new Map(flattenFleetStopped(data).map((f, i) => [f.session, i]));
|
|
55
|
+
|
|
56
|
+
return h(Box, { flexDirection: 'column' },
|
|
57
|
+
h(Text, { color: theme.muted }, `${data.length} host(s)`),
|
|
58
|
+
h(Text, null, ' '),
|
|
59
|
+
...data.map((r) => {
|
|
60
|
+
const head = hostHeader(r, now);
|
|
61
|
+
const badge = authBadge(r.entry);
|
|
62
|
+
const sessions = (r.envelope?.sessions ?? []).filter(s => s.status === 'stopped');
|
|
63
|
+
const queuedCount = (r.envelope?.queue ?? []).filter(e => e.status === 'pending' || e.status === 'launching').length;
|
|
64
|
+
return h(Box, { key: r.host, flexDirection: 'column', marginBottom: 1 },
|
|
65
|
+
h(Text, null,
|
|
66
|
+
h(Text, { color: head.color }, `${head.glyph} `),
|
|
67
|
+
h(Text, { color: theme.bright, bold: true }, r.host),
|
|
68
|
+
h(Text, { color: head.color }, ` ${head.label}`),
|
|
69
|
+
badge ? h(Text, { color: theme.muted, dimColor: true }, ` · ${badge}`) : null,
|
|
70
|
+
queuedCount > 0 ? h(Text, { color: theme.muted, dimColor: true }, ` · ${queuedCount} queued`) : null,
|
|
71
|
+
),
|
|
72
|
+
sessions.length === 0
|
|
73
|
+
? h(Text, { color: theme.muted, dimColor: true }, ' (no stopped sessions)')
|
|
74
|
+
: sessions.map((s) => {
|
|
75
|
+
const i = indexOf.get(s);
|
|
76
|
+
const sel = i === selected;
|
|
77
|
+
const id = s.sessionId ? s.sessionId.slice(0, 8) : (s.key ? s.key.slice(0, 8) : '(no id)');
|
|
78
|
+
const cd = Number.isFinite(s.resetAt) ? fmtCountdown(s.resetAt - now) : '?';
|
|
79
|
+
const hint = s.muxSession ? attachHintRemote(r.dest || r.host, s.mux || 'tmux', s.muxSession) : null;
|
|
80
|
+
return h(Clickable, { key: s.key || i, onClick: () => onSelect?.(i), flexDirection: 'column' },
|
|
81
|
+
h(Text, null,
|
|
82
|
+
h(Text, { color: theme.accent, bold: true }, sel ? '❯ ' : ' '),
|
|
83
|
+
h(Text, { color: theme.warn }, `● STOPPED `),
|
|
84
|
+
h(Text, { color: theme.bright, bold: sel }, ` ${id}`),
|
|
85
|
+
h(Text, { color: sel ? theme.bright : theme.muted }, ` ${(s.agent || 'claude').padEnd(6)}`),
|
|
86
|
+
h(Text, { color: theme.muted }, ` resets ${cd}`),
|
|
87
|
+
),
|
|
88
|
+
hint ? h(Text, { color: theme.info }, ` attach: ${hint}`) : null,
|
|
89
|
+
);
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../theme.js';
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
export function LogsTab({ data, maxRows = 20, scroll = 0 }) {
|
|
8
|
+
if (!data) return h(Text, { color: theme.muted }, 'Loading logs…');
|
|
9
|
+
if (data.missing) return h(Text, { color: theme.muted }, `No log file yet (${data.path})`);
|
|
10
|
+
const fit = Math.max(1, maxRows - 2);
|
|
11
|
+
// Alt-screen has no scrollback — the wheel scrolls this window instead.
|
|
12
|
+
const maxScroll = Math.max(0, data.lines.length - fit);
|
|
13
|
+
const back = Math.min(scroll, maxScroll);
|
|
14
|
+
const end = data.lines.length - back;
|
|
15
|
+
const visible = data.lines.slice(Math.max(0, end - fit), end);
|
|
16
|
+
return h(Box, { flexDirection: 'column' },
|
|
17
|
+
h(Text, { color: theme.muted, dimColor: true },
|
|
18
|
+
data.path + (back > 0 ? ` · scrolled ↑${back} (wheel down for live tail)` : '')),
|
|
19
|
+
h(Text, null, ' '),
|
|
20
|
+
...visible.map((line, i) =>
|
|
21
|
+
h(Text, {
|
|
22
|
+
key: i,
|
|
23
|
+
color: /\[hook\]|\[watcher\]/.test(line) ? theme.bright : theme.muted,
|
|
24
|
+
wrap: 'truncate-end',
|
|
25
|
+
}, line),
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
}
|