@voidlabs.gg/voidshift 0.0.0-stage → 0.4.1
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/LICENSE +202 -0
- package/README.md +136 -2
- package/dist/checks.d.ts +9 -0
- package/dist/checks.js +51 -0
- package/dist/commands/args.d.ts +13 -0
- package/dist/commands/args.js +22 -0
- package/dist/commands/config.d.ts +1 -0
- package/dist/commands/config.js +181 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.js +52 -0
- package/dist/commands/help.d.ts +3 -0
- package/dist/commands/help.js +59 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +135 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +60 -0
- package/dist/commands/tools.d.ts +9 -0
- package/dist/commands/tools.js +111 -0
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +79 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +29 -0
- package/dist/dotted.d.ts +9 -0
- package/dist/dotted.js +86 -0
- package/dist/loader.d.ts +6 -0
- package/dist/loader.js +51 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +93 -0
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +26 -0
- package/dist/tool.d.ts +51 -0
- package/dist/tool.js +4 -0
- package/dist/tui/actions.d.ts +17 -0
- package/dist/tui/actions.js +99 -0
- package/dist/tui/app.d.ts +6 -0
- package/dist/tui/app.js +246 -0
- package/dist/tui/context.d.ts +62 -0
- package/dist/tui/context.js +48 -0
- package/dist/tui/data.d.ts +196 -0
- package/dist/tui/data.js +302 -0
- package/dist/tui/format.d.ts +11 -0
- package/dist/tui/format.js +54 -0
- package/dist/tui/home.d.ts +51 -0
- package/dist/tui/home.js +336 -0
- package/dist/tui/keys.d.ts +2 -0
- package/dist/tui/keys.js +107 -0
- package/dist/tui/nav.d.ts +24 -0
- package/dist/tui/nav.js +32 -0
- package/dist/tui/palette.d.ts +32 -0
- package/dist/tui/palette.js +269 -0
- package/dist/tui/run.d.ts +1 -0
- package/dist/tui/run.js +20 -0
- package/dist/tui/screens/agents.d.ts +6 -0
- package/dist/tui/screens/agents.js +44 -0
- package/dist/tui/screens/awake.d.ts +23 -0
- package/dist/tui/screens/awake.js +209 -0
- package/dist/tui/screens/checks.d.ts +11 -0
- package/dist/tui/screens/checks.js +106 -0
- package/dist/tui/screens/homes.d.ts +14 -0
- package/dist/tui/screens/homes.js +99 -0
- package/dist/tui/screens/list-window.d.ts +6 -0
- package/dist/tui/screens/list-window.js +18 -0
- package/dist/tui/screens/log.d.ts +26 -0
- package/dist/tui/screens/log.js +266 -0
- package/dist/tui/screens/memory.d.ts +24 -0
- package/dist/tui/screens/memory.js +174 -0
- package/dist/tui/screens/project-form.d.ts +14 -0
- package/dist/tui/screens/project-form.js +137 -0
- package/dist/tui/screens/projects.d.ts +17 -0
- package/dist/tui/screens/projects.js +109 -0
- package/dist/tui/screens/runs.d.ts +43 -0
- package/dist/tui/screens/runs.js +420 -0
- package/dist/tui/screens/settings.d.ts +17 -0
- package/dist/tui/screens/settings.js +153 -0
- package/dist/tui/screens/workflow-form.d.ts +58 -0
- package/dist/tui/screens/workflow-form.js +375 -0
- package/dist/tui/screens/workflows.d.ts +41 -0
- package/dist/tui/screens/workflows.js +174 -0
- package/dist/tui/screens.d.ts +30 -0
- package/dist/tui/screens.js +172 -0
- package/dist/tui/theme.d.ts +14 -0
- package/dist/tui/theme.js +18 -0
- package/dist/tui/widgets.d.ts +122 -0
- package/dist/tui/widgets.js +264 -0
- package/dist/ui.d.ts +5 -0
- package/dist/ui.js +82 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +17 -0
- package/package.json +59 -4
package/dist/tui/home.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// The Home screen: one live overview of every added tool. Tab moves between
|
|
2
|
+
// sections, Enter opens the focused one. The functions at the top are pure
|
|
3
|
+
// shaping of the store's data into lines (tested); the component below draws them.
|
|
4
|
+
import React, { useEffect, useState } from 'react';
|
|
5
|
+
import { Box, Text } from 'ink';
|
|
6
|
+
import { useData, runsThisWeek, updateNotice } from "./data.js";
|
|
7
|
+
import { useHints, useKeys, useScreen } from "./context.js";
|
|
8
|
+
import { clock, clockOrStamp, duration, minutesText, plural, secondsToNextCheck, tilde, truncate } from "./format.js";
|
|
9
|
+
import { theme } from "./theme.js";
|
|
10
|
+
import { Lines, Panel, ProgressSteps, seg, SplitRow } from "./widgets.js";
|
|
11
|
+
const h = React.createElement;
|
|
12
|
+
// Sections of tools that are not added are hidden; Checks is always there.
|
|
13
|
+
export function sectionsFor(tools) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const t of ['agents', 'awake', 'memory'])
|
|
16
|
+
if (tools.includes(t))
|
|
17
|
+
out.push(t);
|
|
18
|
+
out.push('checks');
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
// Tab moves to the next section, Shift+Tab to the one before; both wrap.
|
|
22
|
+
export function nextSection(sections, current, dir) {
|
|
23
|
+
const i = sections.indexOf(current);
|
|
24
|
+
if (i === -1)
|
|
25
|
+
return sections[0];
|
|
26
|
+
return sections[(i + dir + sections.length) % sections.length];
|
|
27
|
+
}
|
|
28
|
+
// The focused section, or the first one when it just disappeared.
|
|
29
|
+
export const clampFocus = (sections, current) => (sections.includes(current) ? current : sections[0]);
|
|
30
|
+
const titleCase = (id) => {
|
|
31
|
+
const t = id.replace(/^vs-/, '').replace(/-/g, ' ');
|
|
32
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
33
|
+
};
|
|
34
|
+
// The card being worked on, with the progress of each step of its workflow.
|
|
35
|
+
export function liveCard(state, nowMs) {
|
|
36
|
+
const current = state.agents?.current;
|
|
37
|
+
if (!current || !current.card)
|
|
38
|
+
return null;
|
|
39
|
+
const run = state.runs.find((r) => r.card === current.card && r.record?.outcome === 'running' && (!current.runDir || r.dir === current.runDir));
|
|
40
|
+
const rec = run?.record;
|
|
41
|
+
const workflow = state.workflows.find((w) => w.name === (current.workflow ?? rec?.workflow));
|
|
42
|
+
const recSteps = rec?.steps ?? [];
|
|
43
|
+
const labelOf = (id) => state.steps?.steps.find((s) => s.id === id)?.label ?? recSteps.find((s) => s.step === id)?.label ?? titleCase(id);
|
|
44
|
+
const ids = workflow?.steps ?? recSteps.map((s) => s.step).filter((s, i, all) => all.indexOf(s) === i);
|
|
45
|
+
const runningRec = [...recSteps].reverse().find((s) => !s.endedAt);
|
|
46
|
+
const byLabel = ids.find((id) => labelOf(id) === current.step);
|
|
47
|
+
const runningId = runningRec?.step ?? byLabel;
|
|
48
|
+
const runningPos = runningRec ? recSteps.indexOf(runningRec) : recSteps.length;
|
|
49
|
+
const timeout = typeof state.agentsConfig?.timeoutMinutes === 'number' ? state.agentsConfig.timeoutMinutes : 45;
|
|
50
|
+
const steps = ids.map((id) => {
|
|
51
|
+
const label = labelOf(id);
|
|
52
|
+
if (id === runningId) {
|
|
53
|
+
const started = Date.parse(runningRec?.startedAt ?? current.startedAt ?? '');
|
|
54
|
+
const min = Number.isNaN(started) ? 0 : (nowMs - started) / 60_000;
|
|
55
|
+
return { label, state: 'running', fraction: Math.max(0.25, Math.min(0.9, min / timeout)) };
|
|
56
|
+
}
|
|
57
|
+
let last = -1;
|
|
58
|
+
recSteps.forEach((s, i) => {
|
|
59
|
+
if (s.step === id)
|
|
60
|
+
last = i;
|
|
61
|
+
});
|
|
62
|
+
const done = last >= 0 && last < runningPos && !!recSteps[last].endedAt;
|
|
63
|
+
if (!done)
|
|
64
|
+
return { label, state: 'pending' };
|
|
65
|
+
return { label, state: recSteps[last].result === 'FIX' ? 'failed' : 'done' };
|
|
66
|
+
});
|
|
67
|
+
const startedAt = Date.parse(current.startedAt ?? rec?.startedAt ?? '');
|
|
68
|
+
return {
|
|
69
|
+
card: current.card,
|
|
70
|
+
title: current.title ?? rec?.title ?? '',
|
|
71
|
+
workflow: current.workflow ?? rec?.workflow ?? '',
|
|
72
|
+
step: current.step ?? '',
|
|
73
|
+
minutes: current.elapsedMinutes ?? (Number.isNaN(startedAt) ? 0 : Math.max(0, Math.round((nowMs - startedAt) / 60_000))),
|
|
74
|
+
round: (current.round ?? 0) + 1,
|
|
75
|
+
maxRounds: workflow?.maxRounds ?? null,
|
|
76
|
+
steps,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// "● Runner running" and "last check 14:03 · next in 41s".
|
|
80
|
+
export function runnerLines(agents, nowMs) {
|
|
81
|
+
const left = agents.runner === 'running'
|
|
82
|
+
? [seg('● ', theme.green), seg('Runner running')]
|
|
83
|
+
: agents.runner === 'paused'
|
|
84
|
+
? [seg('● ', theme.yellow), seg('Runner paused'), seg(' p resumes it', theme.dim)]
|
|
85
|
+
: [seg('○ ', theme.dim), seg('Runner stopped'), seg(' vsf agents start', theme.dim)];
|
|
86
|
+
const right = [];
|
|
87
|
+
if (agents.lastTick) {
|
|
88
|
+
const next = agents.runner === 'running' ? secondsToNextCheck(agents.lastTick.at, nowMs) : null;
|
|
89
|
+
const parts = [`last check ${clock(new Date(agents.lastTick.at))}`];
|
|
90
|
+
if (next !== null)
|
|
91
|
+
parts.push(next > 0 ? `next in ${next}s` : 'due now');
|
|
92
|
+
right.push(seg(parts.join(' · '), theme.dim));
|
|
93
|
+
}
|
|
94
|
+
return { left, right };
|
|
95
|
+
}
|
|
96
|
+
// Parked and interrupted cards, the ones `r` can retry.
|
|
97
|
+
export function parkedRows(agents) {
|
|
98
|
+
return [
|
|
99
|
+
...agents.parked.map((p) => ({ card: p.card, kind: 'parked', text: p.reason ? `parked: ${truncate(p.reason, 70)}` : 'parked' })),
|
|
100
|
+
...agents.interrupted.map((c) => ({ card: c, kind: 'interrupted', text: 'interrupted' })),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
export function countsLine(agents, runs, nowMs) {
|
|
104
|
+
return [plural(agents.workflows, 'workflow'), plural(agents.projects, 'project'), plural(runsThisWeek(runs, nowMs), 'run') + ' this week'].join(' · ');
|
|
105
|
+
}
|
|
106
|
+
// ---- Awake ----------------------------------------------------------------
|
|
107
|
+
export function awakeLines(a) {
|
|
108
|
+
const power = a.power.onBattery ? `on battery, ${a.power.percent ?? '?'}%` : 'on power adapter';
|
|
109
|
+
const dog = a.watchdog ? seg('watchdog ok', theme.dim) : seg('watchdog not loaded', theme.yellow);
|
|
110
|
+
if (a.on) {
|
|
111
|
+
const stops = a.until && a.minutesLeft !== null ? `stops ${clock(new Date(a.until))} (${minutesText(a.minutesLeft)}) or at ${a.batteryFloor}%` : `stops at ${a.batteryFloor}%`;
|
|
112
|
+
return [
|
|
113
|
+
[seg('● ', theme.green), seg(`On since ${a.since ? clock(new Date(a.since)) : '?'}, the lid can close`)],
|
|
114
|
+
[seg(` ${stops}`)],
|
|
115
|
+
[seg(` ${power} · `, theme.dim), dog],
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
if (a.sleepDisabledByOther) {
|
|
119
|
+
return [[seg('● ', theme.yellow), seg('Sleep is off, but not by VoidShift')], [seg(` ${power}`, theme.dim)]];
|
|
120
|
+
}
|
|
121
|
+
const lines = [[seg('○ ', theme.dim), seg('Off. The Mac sleeps normally.')]];
|
|
122
|
+
if (a.lastReason)
|
|
123
|
+
lines.push([seg(` last session ended because ${a.lastReason}`, theme.dim)]);
|
|
124
|
+
lines.push([seg(` ${power} · `, theme.dim), dog]);
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
// ---- Memory ---------------------------------------------------------------
|
|
128
|
+
const kindLabel = (k) => (k === 'codex' ? 'Codex' : 'Claude Code');
|
|
129
|
+
export function memoryLines(m, now = new Date()) {
|
|
130
|
+
if (!m.isVault)
|
|
131
|
+
return [[seg('Vault has no INDEX.md', theme.yellow)], [seg(tilde(m.vault), theme.dim)]];
|
|
132
|
+
const first = [seg(plural(m.notes, 'note'))];
|
|
133
|
+
if (m.unindexed.count)
|
|
134
|
+
first.push(seg(' · '), seg(`${m.unindexed.count} not in INDEX.md`, theme.yellow));
|
|
135
|
+
const lines = [first];
|
|
136
|
+
for (const home of m.homes.slice(0, 2)) {
|
|
137
|
+
const recall = home.recall === 'on' ? 'recall on' : home.recall === 'other' ? 'another hook recalls' : 'recall off';
|
|
138
|
+
lines.push([seg(`${recall} in ${kindLabel(home.kind)} `), seg(tilde(home.dir), theme.dim)]);
|
|
139
|
+
}
|
|
140
|
+
if (m.lastUpdated)
|
|
141
|
+
lines.push([seg(`last note ${clockOrStamp(new Date(m.lastUpdated), now)}`, theme.dim)]);
|
|
142
|
+
return lines;
|
|
143
|
+
}
|
|
144
|
+
export function checksView(checks) {
|
|
145
|
+
const problems = checks.filter((c) => c.status !== 'ok');
|
|
146
|
+
return {
|
|
147
|
+
ok: checks.filter((c) => c.status === 'ok').length,
|
|
148
|
+
warn: checks.filter((c) => c.status === 'warn').length,
|
|
149
|
+
fail: checks.filter((c) => c.status === 'fail').length,
|
|
150
|
+
problems: [...problems.filter((c) => c.status === 'fail'), ...problems.filter((c) => c.status === 'warn')],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function checksLines(checks) {
|
|
154
|
+
const v = checksView(checks);
|
|
155
|
+
const counts = [seg(`✓ ${v.ok} ok`, theme.green)];
|
|
156
|
+
if (v.warn)
|
|
157
|
+
counts.push(seg(' '), seg(`! ${plural(v.warn, 'warning')}`, theme.yellow));
|
|
158
|
+
if (v.fail)
|
|
159
|
+
counts.push(seg(' '), seg(`✗ ${v.fail} failed`, theme.red));
|
|
160
|
+
const detail = (c) => [seg(`${c.name} ${c.detail}`), ...(c.fix ? [seg(` fix: ${c.fix}`, theme.dim)] : [])];
|
|
161
|
+
const rows = [];
|
|
162
|
+
const [first, ...rest] = v.problems;
|
|
163
|
+
rows.push({ left: first ? [...counts, seg(' '), ...detail(first)] : counts, right: first ? [seg('enter fixes it', theme.dim)] : [] });
|
|
164
|
+
for (const c of rest.slice(0, 2))
|
|
165
|
+
rows.push({ left: [seg(c.status === 'fail' ? '✗ ' : '! ', c.status === 'fail' ? theme.red : theme.yellow), ...detail(c)], right: [] });
|
|
166
|
+
if (rest.length > 2)
|
|
167
|
+
rows.push({ left: [seg(`+${rest.length - 2} more`, theme.dim)], right: [] });
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
|
170
|
+
// ---- hints ----------------------------------------------------------------
|
|
171
|
+
export function homeHints(opts) {
|
|
172
|
+
const hints = [
|
|
173
|
+
{ key: 'tab', label: 'section' },
|
|
174
|
+
{ key: 'enter', label: 'open' },
|
|
175
|
+
];
|
|
176
|
+
if (opts.runner)
|
|
177
|
+
hints.push({ key: 'p', label: opts.runner === 'paused' ? 'resume runner' : 'pause runner' });
|
|
178
|
+
if (opts.hasAwake)
|
|
179
|
+
hints.push({ key: 'a', label: 'awake' });
|
|
180
|
+
hints.push({ key: ':', label: 'command' }, { key: '?', label: 'keys' }, { key: 'q', label: 'quit' });
|
|
181
|
+
return hints;
|
|
182
|
+
}
|
|
183
|
+
// ---- component ------------------------------------------------------------
|
|
184
|
+
export const homeScreen = {
|
|
185
|
+
id: 'home',
|
|
186
|
+
title: 'VoidShift',
|
|
187
|
+
component: () => h(Home),
|
|
188
|
+
hints: () => [{ key: 'q', label: 'quit' }],
|
|
189
|
+
};
|
|
190
|
+
const SCREEN_OF = { agents: 'agents', awake: 'awake', memory: 'memory', checks: 'checks' };
|
|
191
|
+
export function Home() {
|
|
192
|
+
const ctx = useScreen();
|
|
193
|
+
const { state, refreshChecks } = useData();
|
|
194
|
+
const sections = sectionsFor(state.tools);
|
|
195
|
+
const [focus, setFocus] = useState('agents');
|
|
196
|
+
const [parkedIdx, setParkedIdx] = useState(0);
|
|
197
|
+
const [now, setNow] = useState(Date.now());
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
200
|
+
return () => clearInterval(t);
|
|
201
|
+
}, []);
|
|
202
|
+
const at = clampFocus(sections, focus);
|
|
203
|
+
const agents = state.agents;
|
|
204
|
+
const parked = agents ? parkedRows(agents) : [];
|
|
205
|
+
const sel = Math.min(parkedIdx, Math.max(0, parked.length - 1));
|
|
206
|
+
const update = updateNotice(state.update);
|
|
207
|
+
useHints(homeHints({ runner: agents?.runner, hasAwake: !!state.awake }));
|
|
208
|
+
useKeys((input, key) => {
|
|
209
|
+
if (key.tab)
|
|
210
|
+
setFocus(nextSection(sections, at, key.shift ? -1 : 1));
|
|
211
|
+
else if (key.return) {
|
|
212
|
+
const card = at === 'agents' ? parked[sel]?.card : undefined;
|
|
213
|
+
ctx.nav.push(SCREEN_OF[at], at === 'agents' ? { tab: 'agents.runs', ...(card ? { card } : {}) } : {});
|
|
214
|
+
}
|
|
215
|
+
else if (at === 'agents' && key.upArrow)
|
|
216
|
+
setParkedIdx(Math.max(0, sel - 1));
|
|
217
|
+
else if (at === 'agents' && key.downArrow)
|
|
218
|
+
setParkedIdx(Math.min(parked.length - 1, sel + 1));
|
|
219
|
+
else if (input === 'p' && agents) {
|
|
220
|
+
if (agents.runner === 'stopped')
|
|
221
|
+
ctx.toast('The runner is stopped. Start it with: vsf agents start', 'info');
|
|
222
|
+
else
|
|
223
|
+
void ctx.actions.run({
|
|
224
|
+
args: ['agents', agents.runner === 'paused' ? 'resume' : 'pause'],
|
|
225
|
+
title: 'Pause the runner?',
|
|
226
|
+
noConfirm: true,
|
|
227
|
+
done: agents.runner === 'paused' ? 'Runner resumed' : 'Runner paused. A card in progress carries on.',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else if (input === 'a' && state.awake) {
|
|
231
|
+
if (state.awake.on)
|
|
232
|
+
void ctx.actions.run({ args: ['awake', 'off'], title: 'Turn awake off?', noConfirm: true, done: 'Awake is off. The Mac sleeps normally.' });
|
|
233
|
+
else
|
|
234
|
+
void ctx.actions.run({
|
|
235
|
+
args: ['awake', 'on'],
|
|
236
|
+
title: 'Keep this Mac awake?',
|
|
237
|
+
body: `The lid can close and the Mac keeps running. It stays on until the battery drops to ${state.awake.batteryFloor}% or you run "vsf awake off".`,
|
|
238
|
+
confirmLabel: 'Turn on',
|
|
239
|
+
cancelLabel: 'Cancel',
|
|
240
|
+
done: 'Awake is on',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
else if (input === 'r' && parked[sel]) {
|
|
244
|
+
const card = parked[sel].card;
|
|
245
|
+
void ctx.actions.run({
|
|
246
|
+
args: ['agents', 'retry', card],
|
|
247
|
+
title: `Retry ${card}?`,
|
|
248
|
+
body: `Queues ${card} again. The runner picks it up on its next check.`,
|
|
249
|
+
confirmLabel: 'Retry card',
|
|
250
|
+
cancelLabel: 'Cancel',
|
|
251
|
+
done: `${card} queued again`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else if (input === 'u') {
|
|
255
|
+
void ctx.actions.run({
|
|
256
|
+
args: ['update'],
|
|
257
|
+
title: 'Update VoidShift?',
|
|
258
|
+
body: `Updates vsf${update ? ` to ${update}` : ''} and every tool you added. This can take a minute. Start vsf again afterwards to use it.`,
|
|
259
|
+
confirmLabel: 'Update',
|
|
260
|
+
cancelLabel: 'Cancel',
|
|
261
|
+
timeoutMs: 10 * 60_000,
|
|
262
|
+
done: 'Update finished. Start vsf again to use it.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
else if (input === 'c')
|
|
266
|
+
refreshChecks();
|
|
267
|
+
});
|
|
268
|
+
const panels = [];
|
|
269
|
+
if (sections.includes('agents'))
|
|
270
|
+
panels.push(h(AgentsPanel, { key: 'agents', focused: at === 'agents', selected: sel, now }));
|
|
271
|
+
const lower = [];
|
|
272
|
+
// Whole-number widths: halves of an odd width would misalign the borders.
|
|
273
|
+
const count = ['awake', 'memory'].filter((t) => state.tools.includes(t)).length;
|
|
274
|
+
const gap = count === 2 ? 1 : 0;
|
|
275
|
+
const left = count === 2 ? Math.floor((ctx.columns - gap) / 2) : ctx.columns;
|
|
276
|
+
if (state.tools.includes('awake'))
|
|
277
|
+
lower.push(h(Panel, { key: 'awake', title: 'Awake', focused: at === 'awake', width: left }, state.awake ? h(Lines, { lines: awakeLines(state.awake) }) : toolState(state.errors.awake)));
|
|
278
|
+
if (state.tools.includes('memory'))
|
|
279
|
+
lower.push(h(Box, { key: 'memory', marginLeft: lower.length ? gap : 0 }, h(Panel, { title: 'Memory', focused: at === 'memory', width: lower.length ? ctx.columns - gap - left : ctx.columns }, state.memory ? h(Lines, { lines: memoryLines(state.memory) }) : toolState(state.errors.memory))));
|
|
280
|
+
if (lower.length)
|
|
281
|
+
panels.push(h(Box, { key: 'lower', flexDirection: 'row' }, ...lower));
|
|
282
|
+
panels.push(h(ChecksPanel, { key: 'checks', focused: at === 'checks' }));
|
|
283
|
+
if (!state.tools.length && state.loaded)
|
|
284
|
+
panels.unshift(h(Text, { key: 'none', color: theme.dim }, ' No tools added yet. Run vsf setup in a terminal to pick some.'));
|
|
285
|
+
return h(Box, { flexDirection: 'column' }, ...panels);
|
|
286
|
+
}
|
|
287
|
+
const toolState = (error) => h(Text, { color: error ? theme.yellow : theme.dim, wrap: 'truncate-end' }, error ?? 'Loading...');
|
|
288
|
+
function AgentsPanel(props) {
|
|
289
|
+
const { state } = useData();
|
|
290
|
+
const agents = state.agents;
|
|
291
|
+
if (!agents)
|
|
292
|
+
return h(Panel, { title: 'Agents', focused: props.focused }, toolState(state.errors.agents));
|
|
293
|
+
const run = runnerLines(agents, props.now);
|
|
294
|
+
const live = liveCard(state, props.now);
|
|
295
|
+
const parked = parkedRows(agents);
|
|
296
|
+
const rows = [h(SplitRow, { key: 'runner', left: run.left, right: run.right })];
|
|
297
|
+
if (live) {
|
|
298
|
+
rows.push(h(Text, { key: 'g1' }, ' '));
|
|
299
|
+
rows.push(h(SplitRow, {
|
|
300
|
+
key: 'live',
|
|
301
|
+
left: [seg('▶ ', theme.accent), seg(live.card, theme.accent, { bold: true }), seg(` ${truncate(live.title, 60)}`)],
|
|
302
|
+
right: [seg(live.workflow, theme.dim)],
|
|
303
|
+
}));
|
|
304
|
+
rows.push(h(Box, { key: 'steps', flexDirection: 'row' }, h(Text, null, ' '), h(ProgressSteps, { steps: live.steps }), h(Text, { color: theme.dim }, ` running ${duration(live.minutes * 60_000)}${live.maxRounds ? ` · round ${live.round} of ${live.maxRounds}` : ` · round ${live.round}`}`)));
|
|
305
|
+
}
|
|
306
|
+
else if (agents.current) {
|
|
307
|
+
rows.push(h(Text, { key: 'g1' }, ' '));
|
|
308
|
+
rows.push(h(Text, { key: 'checking', color: theme.dim }, ' Checking Linear now.'));
|
|
309
|
+
}
|
|
310
|
+
if (parked.length) {
|
|
311
|
+
rows.push(h(Text, { key: 'g2' }, ' '));
|
|
312
|
+
parked.forEach((p, i) => {
|
|
313
|
+
const on = props.focused && i === props.selected;
|
|
314
|
+
rows.push(h(SplitRow, {
|
|
315
|
+
key: `p${p.card}`,
|
|
316
|
+
left: [seg(p.kind === 'parked' ? '! ' : '○ ', p.kind === 'parked' ? theme.yellow : theme.dim), seg(p.card, p.kind === 'parked' ? theme.yellow : theme.dim, { bold: on }), seg(` ${p.text}`)],
|
|
317
|
+
right: on || parked.length === 1 ? [seg('r retry · enter details', theme.dim)] : [],
|
|
318
|
+
}));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
rows.push(h(Text, { key: 'g3' }, ' '));
|
|
322
|
+
rows.push(h(Text, { key: 'counts', color: theme.dim }, countsLine(agents, state.runs, props.now)));
|
|
323
|
+
for (const key of ['agents.runs', 'agents']) {
|
|
324
|
+
if (state.errors[key])
|
|
325
|
+
rows.push(h(Text, { key: `e${key}`, color: theme.yellow, wrap: 'truncate-end' }, `! ${state.errors[key]}`));
|
|
326
|
+
}
|
|
327
|
+
return h(Panel, { title: 'Agents', focused: props.focused }, ...rows);
|
|
328
|
+
}
|
|
329
|
+
function ChecksPanel(props) {
|
|
330
|
+
const { state } = useData();
|
|
331
|
+
if (state.checks && state.checks.length === 0 && process.env.VSF_TUI_SKIP_CHECKS === '1')
|
|
332
|
+
return h(Panel, { title: 'Checks', focused: props.focused }, h(Text, { color: theme.dim }, 'Checks skipped'));
|
|
333
|
+
if (!state.checks)
|
|
334
|
+
return h(Panel, { title: 'Checks', focused: props.focused }, toolState(state.errors.checks ?? (state.checksAt === null ? 'Checking…' : undefined)));
|
|
335
|
+
return h(Panel, { title: 'Checks', focused: props.focused }, ...checksLines(state.checks).map((r, i) => h(SplitRow, { key: i, left: r.left, right: r.right })));
|
|
336
|
+
}
|
package/dist/tui/keys.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Key batching. A terminal can hand over several keys in one chunk (fast typing,
|
|
2
|
+
// key repeat, a paste). Ink hands a plain-text chunk such as "\r\r" to useInput
|
|
3
|
+
// as one key and runs a burst of keys without letting the screen update between
|
|
4
|
+
// them, so only one takes effect. This splits a chunk into single keys and
|
|
5
|
+
// delivers them one per tick.
|
|
6
|
+
import { useEffect, useRef } from 'react';
|
|
7
|
+
import { useStdin } from 'ink';
|
|
8
|
+
const ESC = '\u001b';
|
|
9
|
+
const PASTE_START = '\u001b[200~';
|
|
10
|
+
// Length of the escape sequence starting at s[i] (s[i] is ESC).
|
|
11
|
+
function escapeLength(s, i) {
|
|
12
|
+
let j = i + 1;
|
|
13
|
+
if (j >= s.length)
|
|
14
|
+
return 1;
|
|
15
|
+
if (s[j] === ESC) {
|
|
16
|
+
// ESC ESC [ ... : a double-prefixed sequence. Otherwise a lone ESC.
|
|
17
|
+
if (s[j + 1] !== '[' && s[j + 1] !== 'O')
|
|
18
|
+
return 1;
|
|
19
|
+
j++;
|
|
20
|
+
}
|
|
21
|
+
const kind = s[j];
|
|
22
|
+
if (kind === '[') {
|
|
23
|
+
j++;
|
|
24
|
+
if (s[j] === '[')
|
|
25
|
+
j++;
|
|
26
|
+
while (j < s.length) {
|
|
27
|
+
const c = s.charCodeAt(j);
|
|
28
|
+
j++;
|
|
29
|
+
if (c >= 0x40 && c <= 0x7e)
|
|
30
|
+
return j - i;
|
|
31
|
+
}
|
|
32
|
+
return s.length - i;
|
|
33
|
+
}
|
|
34
|
+
if (kind === 'O') {
|
|
35
|
+
j++;
|
|
36
|
+
while (j < s.length) {
|
|
37
|
+
const c = s.charCodeAt(j);
|
|
38
|
+
j++;
|
|
39
|
+
if (c >= 0x40 && c <= 0x7e)
|
|
40
|
+
return j - i;
|
|
41
|
+
}
|
|
42
|
+
return s.length - i;
|
|
43
|
+
}
|
|
44
|
+
// ESC plus one character (alt+key).
|
|
45
|
+
return j + (s.codePointAt(j) > 0xffff ? 2 : 1) - i;
|
|
46
|
+
}
|
|
47
|
+
// Splits a raw input chunk into single keys: escape sequences (\x1b[C, \x1bOA),
|
|
48
|
+
// a lone \x1b, \r, \t, control bytes and each printable character. A chunk with
|
|
49
|
+
// a bracketed paste is left whole.
|
|
50
|
+
export function splitKeys(chunk) {
|
|
51
|
+
if (chunk.includes(PASTE_START))
|
|
52
|
+
return [chunk];
|
|
53
|
+
const keys = [];
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < chunk.length) {
|
|
56
|
+
if (chunk[i] === ESC) {
|
|
57
|
+
const n = escapeLength(chunk, i);
|
|
58
|
+
keys.push(chunk.slice(i, i + n));
|
|
59
|
+
i += n;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const n = chunk.codePointAt(i) > 0xffff ? 2 : 1;
|
|
63
|
+
keys.push(chunk.slice(i, i + n));
|
|
64
|
+
i += n;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return keys;
|
|
68
|
+
}
|
|
69
|
+
// Makes the stdin Ink reads hand over one key per tick. While `keepWhole()` is
|
|
70
|
+
// true (a text input has focus) chunks go through unsplit, so typed or pasted
|
|
71
|
+
// text reaches the input as today.
|
|
72
|
+
export function useKeyBatching(keepWhole) {
|
|
73
|
+
const { stdin } = useStdin();
|
|
74
|
+
const whole = useRef(keepWhole);
|
|
75
|
+
whole.current = keepWhole;
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const s = stdin;
|
|
78
|
+
const original = s.read;
|
|
79
|
+
const queue = [];
|
|
80
|
+
let gate = false;
|
|
81
|
+
s.read = function () {
|
|
82
|
+
if (gate)
|
|
83
|
+
return null;
|
|
84
|
+
let item = queue.shift();
|
|
85
|
+
if (item === undefined) {
|
|
86
|
+
const raw = original.call(s);
|
|
87
|
+
if (raw === null || raw === undefined)
|
|
88
|
+
return raw ?? null;
|
|
89
|
+
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
|
|
90
|
+
const keys = whole.current() ? [text] : splitKeys(text);
|
|
91
|
+
item = keys[0];
|
|
92
|
+
queue.push(...keys.slice(1));
|
|
93
|
+
if (item === undefined)
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
gate = true;
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
gate = false;
|
|
99
|
+
s.emit('readable');
|
|
100
|
+
}, 0);
|
|
101
|
+
return item;
|
|
102
|
+
};
|
|
103
|
+
return () => {
|
|
104
|
+
s.read = original;
|
|
105
|
+
};
|
|
106
|
+
}, [stdin]);
|
|
107
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Params = Record<string, unknown>;
|
|
2
|
+
export interface Route {
|
|
3
|
+
id: string;
|
|
4
|
+
params: Params;
|
|
5
|
+
}
|
|
6
|
+
export type NavAction = {
|
|
7
|
+
type: 'push';
|
|
8
|
+
id: string;
|
|
9
|
+
params?: Params;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'replace';
|
|
12
|
+
id: string;
|
|
13
|
+
params?: Params;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'pop';
|
|
16
|
+
} | {
|
|
17
|
+
type: 'reset';
|
|
18
|
+
id: string;
|
|
19
|
+
params?: Params;
|
|
20
|
+
};
|
|
21
|
+
export declare const HOME = "home";
|
|
22
|
+
export declare const initialStack: () => Route[];
|
|
23
|
+
export declare function navReducer(stack: Route[], action: NavAction): Route[];
|
|
24
|
+
export declare function breadcrumb(stack: Route[], titles: (route: Route) => string[]): string[];
|
package/dist/tui/nav.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The screen stack. Pure, so it is easy to test: the app feeds actions in.
|
|
2
|
+
export const HOME = 'home';
|
|
3
|
+
export const initialStack = () => [{ id: HOME, params: {} }];
|
|
4
|
+
const same = (a, b) => a.id === b.id && JSON.stringify(a.params) === JSON.stringify(b.params);
|
|
5
|
+
export function navReducer(stack, action) {
|
|
6
|
+
switch (action.type) {
|
|
7
|
+
case 'push': {
|
|
8
|
+
const next = { id: action.id, params: action.params ?? {} };
|
|
9
|
+
// Pushing the screen you are already on does nothing.
|
|
10
|
+
return same(stack[stack.length - 1], next) ? stack : [...stack, next];
|
|
11
|
+
}
|
|
12
|
+
case 'replace':
|
|
13
|
+
return [...stack.slice(0, -1), { id: action.id, params: action.params ?? {} }];
|
|
14
|
+
case 'pop':
|
|
15
|
+
// The first screen never leaves the stack.
|
|
16
|
+
return stack.length > 1 ? stack.slice(0, -1) : stack;
|
|
17
|
+
case 'reset':
|
|
18
|
+
return [{ id: action.id, params: action.params ?? {} }];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Breadcrumb for the header. `titles` are each route's crumbs. Screens below
|
|
22
|
+
// the top one give only their first crumb, so [home, agents, agents.log]
|
|
23
|
+
// reads "Agents > ENG-1412 > QA log". Home is not part of the trail.
|
|
24
|
+
export function breadcrumb(stack, titles) {
|
|
25
|
+
const rest = stack.filter((r) => r.id !== HOME);
|
|
26
|
+
const out = [];
|
|
27
|
+
rest.forEach((r, i) => {
|
|
28
|
+
const t = titles(r);
|
|
29
|
+
out.push(...(i === rest.length - 1 ? t : t.slice(0, 1)));
|
|
30
|
+
});
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ActionResult } from './actions.ts';
|
|
3
|
+
import type { DataState } from './data.ts';
|
|
4
|
+
export interface PaletteEntry {
|
|
5
|
+
text: string;
|
|
6
|
+
desc?: string;
|
|
7
|
+
prefix: string;
|
|
8
|
+
runnable: boolean;
|
|
9
|
+
dynamic?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function splitCommand(text: string): string[];
|
|
12
|
+
export declare function makeEntry(text: string, desc?: string, dynamic?: boolean): PaletteEntry;
|
|
13
|
+
export declare function parseHelpLines(tool: string, text: string): PaletteEntry[];
|
|
14
|
+
export declare function parseUsages(usages: Record<string, string>): PaletteEntry[];
|
|
15
|
+
export declare function dynamicEntries(state: Pick<DataState, 'agents' | 'runs' | 'steps'>): PaletteEntry[];
|
|
16
|
+
export declare function filterPalette(entries: PaletteEntry[], query: string): PaletteEntry[];
|
|
17
|
+
export declare function completion(entry: PaletteEntry): string;
|
|
18
|
+
export type EnterAction = {
|
|
19
|
+
run: string[];
|
|
20
|
+
} | {
|
|
21
|
+
fill: string;
|
|
22
|
+
} | {
|
|
23
|
+
none: true;
|
|
24
|
+
};
|
|
25
|
+
export declare function onEnter(entry: PaletteEntry | undefined, query: string): EnterAction;
|
|
26
|
+
export interface PaletteProps {
|
|
27
|
+
umbrella: PaletteEntry[];
|
|
28
|
+
onClose(): void;
|
|
29
|
+
onRun(args: string[]): Promise<ActionResult>;
|
|
30
|
+
onTerminal(args: string[]): void;
|
|
31
|
+
}
|
|
32
|
+
export declare function Palette(props: PaletteProps): React.ReactElement;
|