@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.
Files changed (89) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +136 -2
  3. package/dist/checks.d.ts +9 -0
  4. package/dist/checks.js +51 -0
  5. package/dist/commands/args.d.ts +13 -0
  6. package/dist/commands/args.js +22 -0
  7. package/dist/commands/config.d.ts +1 -0
  8. package/dist/commands/config.js +181 -0
  9. package/dist/commands/doctor.d.ts +3 -0
  10. package/dist/commands/doctor.js +52 -0
  11. package/dist/commands/help.d.ts +3 -0
  12. package/dist/commands/help.js +59 -0
  13. package/dist/commands/setup.d.ts +2 -0
  14. package/dist/commands/setup.js +135 -0
  15. package/dist/commands/status.d.ts +2 -0
  16. package/dist/commands/status.js +60 -0
  17. package/dist/commands/tools.d.ts +9 -0
  18. package/dist/commands/tools.js +111 -0
  19. package/dist/commands/update.d.ts +2 -0
  20. package/dist/commands/update.js +79 -0
  21. package/dist/config.d.ts +9 -0
  22. package/dist/config.js +29 -0
  23. package/dist/dotted.d.ts +9 -0
  24. package/dist/dotted.js +86 -0
  25. package/dist/loader.d.ts +6 -0
  26. package/dist/loader.js +51 -0
  27. package/dist/main.d.ts +2 -0
  28. package/dist/main.js +93 -0
  29. package/dist/registry.d.ts +8 -0
  30. package/dist/registry.js +26 -0
  31. package/dist/tool.d.ts +51 -0
  32. package/dist/tool.js +4 -0
  33. package/dist/tui/actions.d.ts +17 -0
  34. package/dist/tui/actions.js +99 -0
  35. package/dist/tui/app.d.ts +6 -0
  36. package/dist/tui/app.js +246 -0
  37. package/dist/tui/context.d.ts +62 -0
  38. package/dist/tui/context.js +48 -0
  39. package/dist/tui/data.d.ts +196 -0
  40. package/dist/tui/data.js +302 -0
  41. package/dist/tui/format.d.ts +11 -0
  42. package/dist/tui/format.js +54 -0
  43. package/dist/tui/home.d.ts +51 -0
  44. package/dist/tui/home.js +336 -0
  45. package/dist/tui/keys.d.ts +2 -0
  46. package/dist/tui/keys.js +107 -0
  47. package/dist/tui/nav.d.ts +24 -0
  48. package/dist/tui/nav.js +32 -0
  49. package/dist/tui/palette.d.ts +32 -0
  50. package/dist/tui/palette.js +269 -0
  51. package/dist/tui/run.d.ts +1 -0
  52. package/dist/tui/run.js +20 -0
  53. package/dist/tui/screens/agents.d.ts +6 -0
  54. package/dist/tui/screens/agents.js +44 -0
  55. package/dist/tui/screens/awake.d.ts +23 -0
  56. package/dist/tui/screens/awake.js +209 -0
  57. package/dist/tui/screens/checks.d.ts +11 -0
  58. package/dist/tui/screens/checks.js +106 -0
  59. package/dist/tui/screens/homes.d.ts +14 -0
  60. package/dist/tui/screens/homes.js +99 -0
  61. package/dist/tui/screens/list-window.d.ts +6 -0
  62. package/dist/tui/screens/list-window.js +18 -0
  63. package/dist/tui/screens/log.d.ts +26 -0
  64. package/dist/tui/screens/log.js +266 -0
  65. package/dist/tui/screens/memory.d.ts +24 -0
  66. package/dist/tui/screens/memory.js +174 -0
  67. package/dist/tui/screens/project-form.d.ts +14 -0
  68. package/dist/tui/screens/project-form.js +137 -0
  69. package/dist/tui/screens/projects.d.ts +17 -0
  70. package/dist/tui/screens/projects.js +109 -0
  71. package/dist/tui/screens/runs.d.ts +43 -0
  72. package/dist/tui/screens/runs.js +420 -0
  73. package/dist/tui/screens/settings.d.ts +17 -0
  74. package/dist/tui/screens/settings.js +153 -0
  75. package/dist/tui/screens/workflow-form.d.ts +58 -0
  76. package/dist/tui/screens/workflow-form.js +375 -0
  77. package/dist/tui/screens/workflows.d.ts +41 -0
  78. package/dist/tui/screens/workflows.js +174 -0
  79. package/dist/tui/screens.d.ts +30 -0
  80. package/dist/tui/screens.js +172 -0
  81. package/dist/tui/theme.d.ts +14 -0
  82. package/dist/tui/theme.js +18 -0
  83. package/dist/tui/widgets.d.ts +122 -0
  84. package/dist/tui/widgets.js +264 -0
  85. package/dist/ui.d.ts +5 -0
  86. package/dist/ui.js +82 -0
  87. package/dist/version.d.ts +3 -0
  88. package/dist/version.js +17 -0
  89. package/package.json +59 -4
@@ -0,0 +1,420 @@
1
+ // The Runs tab of the agents screen: every run newest first, the selected run
2
+ // in detail below, and the keys to open its log, cancel, retry, run from a
3
+ // step, clean old runs and filter. Every action is a real vsf command.
4
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import { join } from 'node:path';
7
+ import { configDir } from "../../config.js";
8
+ import { runAction } from "../actions.js";
9
+ import { useBackGuard, useEquivalent, useHints, useKeys, useScreen } from "../context.js";
10
+ import { useData } from "../data.js";
11
+ import { duration, folderStamp, stamp, tilde, truncate } from "../format.js";
12
+ import { Lines, Panel, Select, Table, TextInput, outcomeColour, seg } from "../widgets.js";
13
+ import { theme } from "../theme.js";
14
+ const h = React.createElement;
15
+ // ---- pure helpers ----------------------------------------------------------
16
+ // Where "Run from step" writes the output of the detached run.
17
+ export const manualLogPath = (dir) => join(dir, 'agents', 'manual.log');
18
+ // Shared with the agents shell: while the step picker is on screen, left and
19
+ // right belong to it, not to the tab bar.
20
+ export const tabLock = { on: false };
21
+ // Runs whose card or workflow contains the text (case does not matter).
22
+ export function filterRuns(runs, text) {
23
+ const q = text.trim().toLowerCase();
24
+ if (!q)
25
+ return runs;
26
+ return runs.filter((r) => r.card.toLowerCase().includes(q) || (r.record?.workflow ?? '').toLowerCase().includes(q));
27
+ }
28
+ // "Dev → QA": step labels in order, each once.
29
+ export function stepsLabel(steps) {
30
+ const seen = [];
31
+ for (const s of steps)
32
+ if (!seen.includes(s.label))
33
+ seen.push(s.label);
34
+ return seen.join(' → ');
35
+ }
36
+ // How many times the first step started: the number of rounds begun.
37
+ export function roundsOf(steps) {
38
+ const first = steps[0]?.step;
39
+ return first ? steps.filter((s) => s.step === first).length : 0;
40
+ }
41
+ export function runMs(run, nowMs) {
42
+ const rec = run.record;
43
+ if (!rec)
44
+ return null;
45
+ const start = Date.parse(rec.startedAt);
46
+ if (Number.isNaN(start))
47
+ return null;
48
+ const end = rec.endedAt ? Date.parse(rec.endedAt) : nowMs;
49
+ return Math.max(0, end - start);
50
+ }
51
+ // The outcome shown for a run. A run that says "running" while the runner is
52
+ // busy with something else was cut off.
53
+ export function outcomeOf(run, state) {
54
+ const outcome = run.record?.outcome;
55
+ if (!outcome)
56
+ return 'old run';
57
+ if (outcome === 'running' && state.agents && state.agents.current?.card !== run.card)
58
+ return 'interrupted';
59
+ return outcome;
60
+ }
61
+ export function isLive(run, state) {
62
+ if (!run || run.record?.outcome !== 'running')
63
+ return false;
64
+ const cur = state.agents?.current;
65
+ return !!cur && cur.card === run.card && (!cur.runDir || cur.runDir === run.dir);
66
+ }
67
+ export function startedText(run) {
68
+ const t = run.record ? Date.parse(run.record.startedAt) : NaN;
69
+ return Number.isNaN(t) ? folderStamp(run.stamp) : stamp(new Date(t));
70
+ }
71
+ // The steps of a run for display. When the run is not live, a step that never
72
+ // got an end (cancelled, cut off) is shown as stopped at the end of the run.
73
+ export function settledSteps(run, state) {
74
+ const steps = run.record?.steps ?? [];
75
+ if (isLive(run, state))
76
+ return steps;
77
+ return steps.map((s) => (s.endedAt ? s : { ...s, endedAt: run.record?.endedAt ?? s.startedAt, result: 'stopped' }));
78
+ }
79
+ // Which glyph and colour a step gets. Plain checks (SHIP, SKIP, done) pass.
80
+ export function stepMark(step) {
81
+ if (!step.endedAt)
82
+ return { glyph: '◐', colour: theme.accent };
83
+ const r = (step.result ?? '').toUpperCase();
84
+ if (r === 'FIX')
85
+ return { glyph: '↻', colour: theme.yellow };
86
+ if (r === 'BLOCKED')
87
+ return { glyph: '✗', colour: theme.red };
88
+ if (r === 'STOPPED')
89
+ return { glyph: '-', colour: theme.dim };
90
+ return { glyph: '✓', colour: theme.green };
91
+ }
92
+ export const stepResult = (step) => (step.endedAt ? (step.result ?? 'done').toLowerCase() : '...');
93
+ export function stepMs(step, nowMs) {
94
+ const start = Date.parse(step.startedAt);
95
+ if (Number.isNaN(start))
96
+ return null;
97
+ const end = step.endedAt ? Date.parse(step.endedAt) : nowMs;
98
+ return Math.max(0, end - start);
99
+ }
100
+ export const stepDuration = (step, nowMs) => {
101
+ const ms = stepMs(step, nowMs);
102
+ return ms === null ? '' : duration(ms);
103
+ };
104
+ // One line per step: mark, name, result, time, summary.
105
+ export function stepLine(step, nowMs) {
106
+ const m = stepMark(step);
107
+ return [
108
+ seg(`${m.glyph} `, m.colour),
109
+ seg(step.label.padEnd(8)),
110
+ seg(stepResult(step).padEnd(7), step.endedAt ? undefined : theme.dim),
111
+ seg(stepDuration(step, nowMs).padStart(4) + ' ', theme.dim),
112
+ seg(truncate(step.summary ?? '', 300), theme.dim),
113
+ ];
114
+ }
115
+ // Window of `size` rows that keeps `selected` in view.
116
+ export function windowStart(total, selected, size, start) {
117
+ if (total <= size)
118
+ return 0;
119
+ let s = Math.min(start, total - size);
120
+ if (selected < s)
121
+ s = selected;
122
+ if (selected >= s + size)
123
+ s = selected - size + 1;
124
+ return Math.max(0, s);
125
+ }
126
+ export function runsKey(input, key) {
127
+ if (key.return)
128
+ return 'open';
129
+ if (key.upArrow)
130
+ return 'up';
131
+ if (key.downArrow)
132
+ return 'down';
133
+ if (input === 'c')
134
+ return 'cancel';
135
+ if (input === 'r')
136
+ return 'retry';
137
+ if (input === 'f')
138
+ return 'from';
139
+ if (input === 'x')
140
+ return 'clean';
141
+ if (input === '/')
142
+ return 'filter';
143
+ return null;
144
+ }
145
+ // The confirm text from the approved mockup.
146
+ export function cancelSpec(card, stepLabel) {
147
+ return {
148
+ args: ['agents', 'cancel', card],
149
+ title: `Cancel ${card}?`,
150
+ body: `Stops the ${stepLabel || 'running'} agent now. The card loses its VoidShift labels and gets a comment; add vs-plan again to restart it.`,
151
+ confirmLabel: 'Cancel card',
152
+ cancelLabel: 'Keep running',
153
+ done: `${card} cancelled`,
154
+ };
155
+ }
156
+ export const RUN_HINTS = [
157
+ { key: 'enter', label: 'live log' },
158
+ { key: 'c', label: 'cancel' },
159
+ { key: 'r', label: 'retry' },
160
+ { key: 'f', label: 'run from step' },
161
+ { key: 'x', label: 'clean old' },
162
+ { key: '/', label: 'filter' },
163
+ { key: 'esc', label: 'back' },
164
+ ];
165
+ // The step to open the log on: the running one, else the last that ran.
166
+ export function logStepOf(run) {
167
+ const steps = run.record?.steps ?? [];
168
+ return [...steps].reverse().find((s) => !s.endedAt) ?? steps[steps.length - 1];
169
+ }
170
+ // ---- labels from `vsf agents show CARD --json` -------------------------------
171
+ const labelCache = new Map();
172
+ const LABELS_TTL_MS = 60_000;
173
+ export async function fetchLabelsReal(card) {
174
+ const r = await runAction(['agents', 'show', card, '--json'], { timeoutMs: 15_000 });
175
+ if (r.code !== 0)
176
+ return null;
177
+ try {
178
+ const labels = JSON.parse(r.output).labels;
179
+ return Array.isArray(labels) ? labels.map(String) : null;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ // Swapped out in tests.
186
+ export const runsDeps = { fetchLabels: fetchLabelsReal, debounceMs: 500 };
187
+ function useLabels(card) {
188
+ const [labels, setLabels] = useState(card ? (labelCache.get(card)?.labels ?? null) : null);
189
+ useEffect(() => {
190
+ if (!card || process.env.VSF_TUI_NO_LABELS === '1') {
191
+ setLabels(null);
192
+ return;
193
+ }
194
+ const hit = labelCache.get(card);
195
+ setLabels(hit?.labels ?? null);
196
+ if (hit && Date.now() - hit.at < LABELS_TTL_MS)
197
+ return;
198
+ let live = true;
199
+ const t = setTimeout(() => {
200
+ runsDeps.fetchLabels(card).then((l) => {
201
+ labelCache.set(card, { at: Date.now(), labels: l });
202
+ if (live)
203
+ setLabels(l);
204
+ }, () => { });
205
+ }, runsDeps.debounceMs);
206
+ return () => {
207
+ live = false;
208
+ clearTimeout(t);
209
+ };
210
+ }, [card]);
211
+ return labels;
212
+ }
213
+ export function Runs(props) {
214
+ const ctx = useScreen();
215
+ const { state } = useData();
216
+ const [now, setNow] = useState(Date.now());
217
+ const [mode, setMode] = useState('list');
218
+ const [filter, setFilter] = useState('');
219
+ const [selDir, setSelDir] = useState(null);
220
+ const [from, setFrom] = useState('');
221
+ const start = useRef(0);
222
+ useEffect(() => {
223
+ const t = setInterval(() => setNow(Date.now()), 1000);
224
+ return () => clearInterval(t);
225
+ }, []);
226
+ const wanted = typeof props.params.card === 'string' ? props.params.card : null;
227
+ const runs = useMemo(() => filterRuns(state.runs, filter), [state.runs, filter]);
228
+ let index = selDir ? runs.findIndex((r) => r.dir === selDir) : -1;
229
+ if (index < 0 && !selDir && wanted)
230
+ index = runs.findIndex((r) => r.card === wanted);
231
+ if (index < 0)
232
+ index = 0;
233
+ const run = runs[index];
234
+ const record = run?.record;
235
+ const isLatest = !!run && state.runs.find((r) => r.card === run.card)?.dir === run.dir;
236
+ const labels = useLabels(isLatest && record ? run.card : undefined);
237
+ const live = isLive(run, state);
238
+ const liveStep = state.agents?.current?.step;
239
+ const workflow = state.workflows.find((w) => w.name === record?.workflow);
240
+ const stepIds = workflow?.steps ?? [...new Set((record?.steps ?? []).map((s) => s.step))];
241
+ const labelOf = (id) => state.steps?.steps.find((s) => s.id === id)?.label ?? record?.steps.find((s) => s.step === id)?.label ?? id;
242
+ const options = stepIds.map((id) => ({ value: id, label: labelOf(id) }));
243
+ useEquivalent(mode === 'pick' && run && from ? ['agents', 'run', run.card, '--from', from] : null);
244
+ useEffect(() => {
245
+ tabLock.on = mode === 'pick';
246
+ return () => {
247
+ tabLock.on = false;
248
+ };
249
+ }, [mode]);
250
+ useHints(mode === 'filter'
251
+ ? [
252
+ { key: 'enter', label: 'apply' },
253
+ { key: 'esc', label: 'clear' },
254
+ ]
255
+ : mode === 'pick'
256
+ ? [
257
+ { key: '←→', label: 'choose step' },
258
+ { key: 'enter', label: 'list' },
259
+ { key: 'g', label: 'run' },
260
+ { key: 'esc', label: 'cancel' },
261
+ ]
262
+ : filter
263
+ ? [...RUN_HINTS.slice(0, -1), { key: 'esc', label: 'clear filter' }]
264
+ : RUN_HINTS);
265
+ useBackGuard(() => {
266
+ if (mode === 'pick') {
267
+ setMode('list');
268
+ return true;
269
+ }
270
+ if (filter) {
271
+ setFilter('');
272
+ return true;
273
+ }
274
+ return false;
275
+ });
276
+ const move = (delta) => {
277
+ const next = runs[Math.max(0, Math.min(runs.length - 1, index + delta))];
278
+ if (next)
279
+ setSelDir(next.dir);
280
+ };
281
+ useKeys((input, key) => {
282
+ if (mode === 'filter') {
283
+ if (key.escape) {
284
+ setFilter('');
285
+ setMode('list');
286
+ }
287
+ return;
288
+ }
289
+ if (mode === 'pick') {
290
+ if (!ctx.capturing && input === 'g' && run && from) {
291
+ const label = labelOf(from);
292
+ setMode('list');
293
+ void ctx.actions
294
+ .confirm({
295
+ title: `Run ${run.card} from ${label}?`,
296
+ body: `Starts the ${label} step of ${record?.workflow ?? 'the workflow'} on ${run.card} in the background. Watch it here as it runs. Output goes to ${tilde(manualLogPath(configDir()))}.`,
297
+ confirmLabel: 'Run',
298
+ cancelLabel: 'Cancel',
299
+ })
300
+ .then((ok) => {
301
+ if (ok)
302
+ ctx.actions.detach(['agents', 'run', run.card, '--from', from], manualLogPath(configDir()), `Started ${run.card} from ${label}`);
303
+ });
304
+ }
305
+ return;
306
+ }
307
+ const action = runsKey(input, key);
308
+ if (action === 'up')
309
+ move(-1);
310
+ else if (action === 'down')
311
+ move(1);
312
+ else if (action === 'filter')
313
+ setMode('filter');
314
+ else if (action === 'clean')
315
+ void ctx.actions.run({
316
+ args: ['agents', 'clean', '--yes'],
317
+ title: 'Clean old runs?',
318
+ body: 'Removes run folders and worktrees older than 14 days. Work in progress is kept.',
319
+ confirmLabel: 'Clean',
320
+ cancelLabel: 'Keep them',
321
+ });
322
+ else if (!run)
323
+ return;
324
+ else if (action === 'open') {
325
+ const step = logStepOf(run);
326
+ if (!run.logs.length)
327
+ ctx.toast('This run has no logs yet.', 'info');
328
+ else
329
+ ctx.nav.push('agents.log', { card: run.card, dir: run.dir, ...(step ? { step: step.step, stepLabel: step.label } : {}) });
330
+ }
331
+ else if (action === 'cancel') {
332
+ if (!live)
333
+ ctx.toast(`${run.card} is not running, so there is nothing to cancel.`, 'info');
334
+ else
335
+ void ctx.actions.run(cancelSpec(run.card, liveStep));
336
+ }
337
+ else if (action === 'retry')
338
+ void ctx.actions.run({
339
+ args: ['agents', 'retry', run.card],
340
+ title: `Retry ${run.card}?`,
341
+ body: `Queues ${run.card} again. The runner picks it up on its next check.`,
342
+ confirmLabel: 'Retry card',
343
+ cancelLabel: 'Cancel',
344
+ done: `${run.card} queued again`,
345
+ });
346
+ else if (action === 'from') {
347
+ if (live)
348
+ ctx.toast(`${run.card} is running. Cancel it first.`, 'info');
349
+ else if (!options.length)
350
+ ctx.toast('No steps known for this run.', 'info');
351
+ else {
352
+ const last = record?.steps[record.steps.length - 1]?.step;
353
+ setFrom(last && stepIds.includes(last) ? last : stepIds[0]);
354
+ setMode('pick');
355
+ }
356
+ }
357
+ }, { whileCapturing: true });
358
+ // ---- layout ----
359
+ const steps = (run ? settledSteps(run, state) : []).slice(-8);
360
+ const detailRows = Math.max(2, steps.length) + 2 + (record?.outcome === 'parked' && record.reason ? 1 : 0) + (mode === 'pick' ? 2 : 0);
361
+ const listSize = Math.max(3, ctx.rows - 5 - 3 - (detailRows + 2));
362
+ start.current = windowStart(runs.length, index, listSize, start.current);
363
+ const shown = runs.slice(start.current, start.current + listSize);
364
+ const wide = ctx.columns >= 96;
365
+ const rows = shown.map((r) => {
366
+ const outcome = outcomeOf(r, state);
367
+ const ms = runMs(r, now);
368
+ return [
369
+ { text: r.card, bold: true },
370
+ startedText(r),
371
+ r.record?.workflow ?? '',
372
+ stepsLabel(r.record?.steps ?? []),
373
+ { text: outcome, colour: outcome === 'interrupted' ? theme.yellow : outcomeColour(outcome) },
374
+ String(roundsOf(r.record?.steps ?? [])),
375
+ ms === null ? '' : duration(ms),
376
+ ];
377
+ });
378
+ const cols = [
379
+ { title: 'CARD', width: 10 },
380
+ { title: 'STARTED', width: 14 },
381
+ { title: 'WORKFLOW', width: wide ? 20 : 14 },
382
+ { title: 'STEPS', width: wide ? 14 : 12 },
383
+ { title: 'OUTCOME', width: 12 },
384
+ { title: 'ROUNDS', width: 8 },
385
+ { title: 'TIME' },
386
+ ];
387
+ const empty = !state.loaded ? 'Loading...' : filter ? `No runs match "${filter}".` : 'No runs yet. They appear here when a card is picked up.';
388
+ const title = filter && mode !== 'filter' ? `Runs matching "${filter}"` : 'Runs, newest first';
389
+ const detail = steps.map((s) => stepLine(s, now));
390
+ if (record && !steps.length)
391
+ detail.push([seg('No steps yet.', theme.dim)]);
392
+ if (record?.outcome === 'parked' && record.reason)
393
+ detail.push([seg('Parked: ', theme.yellow), seg(truncate(record.reason, 300))]);
394
+ if (record) {
395
+ detail.push([]);
396
+ const tail = [];
397
+ if (record.prUrl)
398
+ tail.push(seg('PR ', theme.dim), seg(record.prUrl.replace(/^https?:\/\//, ''), theme.accent), seg(' '));
399
+ if (labels)
400
+ tail.push(seg('labels ', theme.dim), seg(labels.join(' ') || 'none'));
401
+ else if (!record.prUrl)
402
+ tail.push(seg('No PR yet', theme.dim));
403
+ detail.push(tail);
404
+ }
405
+ else if (run)
406
+ detail.push([seg('This run has logs only. Enter opens them.', theme.dim)]);
407
+ return h(Box, { flexDirection: 'column' }, h(Panel, { title, focused: mode !== 'pick', subtitle: mode === 'filter' ? undefined : runs.length > shown.length ? `${index + 1} of ${runs.length}` : undefined }, mode === 'filter'
408
+ ? h(Box, null, h(Text, { color: theme.dim }, 'Filter by card or workflow: '), h(TextInput, { value: filter, onChange: setFilter, onSubmit: () => setMode('list'), placeholder: 'ENG-12 or tether' }))
409
+ : null, h(Table, { columns: cols, rows, selected: index - start.current, empty })), run
410
+ ? h(Panel, { title: `${run.card}${record?.title ? ' ' + truncate(record.title, Math.max(10, ctx.columns - 20)) : ''}`, focused: mode === 'pick' }, h(Lines, { lines: detail }), mode === 'pick'
411
+ ? h(Box, { marginTop: 1 }, h(Text, { color: theme.dim }, `Run ${run.card} from: `), h(Select, { options, value: from, onChange: setFrom }))
412
+ : null)
413
+ : null);
414
+ }
415
+ export const runsScreen = {
416
+ id: 'agents.runs',
417
+ title: 'Runs',
418
+ component: Runs,
419
+ hints: () => RUN_HINTS,
420
+ };
@@ -0,0 +1,17 @@
1
+ import type { Screen } from '../screens.ts';
2
+ export interface SettingRow {
3
+ key: string;
4
+ shown: string;
5
+ edit: string;
6
+ secret: boolean;
7
+ hint: string;
8
+ }
9
+ export declare const isSkipped: (key: string) => boolean;
10
+ export declare function editText(value: unknown): string;
11
+ export declare function settingRows(tool: string, config: Record<string, unknown> | null): SettingRow[];
12
+ export declare const hasOtherScreens: (tool: string, config: Record<string, unknown> | null) => boolean;
13
+ export declare const setArgs: (key: string, value: string) => string[];
14
+ export declare const unsetArgs: (key: string) => string[];
15
+ export declare function problemLine(output: string): string;
16
+ export declare const nextIndex: (count: number, index: number, delta: number) => number;
17
+ export declare const settingsScreen: Screen;
@@ -0,0 +1,153 @@
1
+ // Settings: every config key of each added tool, edited inline. Saving runs
2
+ // `vsf config set KEY VALUE`, so the same validation as the command applies.
3
+ import React, { useState } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { flatten, mask, parseValue } from "../../dotted.js";
6
+ import { useData } from "../data.js";
7
+ import { useEquivalent, useHints, useKeys, useScreen } from "../context.js";
8
+ import { theme } from "../theme.js";
9
+ import { Panel, TextInput } from "../widgets.js";
10
+ import { fitCell } from "../widgets.js";
11
+ import { windowOf } from "./list-window.js";
12
+ const h = React.createElement;
13
+ // Big structured keys that have their own screens.
14
+ const SKIPPED = ['agents.projects', 'agents.workflows', 'agents.homes', 'agents.enabled', 'memory.homes'];
15
+ const HINTS = {
16
+ 'agents.engine': 'claude or codex',
17
+ 'agents.timeoutMinutes': 'minutes before a step is stopped',
18
+ 'agents.reviewState': 'Linear state for verified cards',
19
+ 'awake.battery': 'battery floor, 5 to 95',
20
+ 'awake.heat': 'stop when the Mac runs hot',
21
+ 'awake.revertOnReboot': 'turn off again after a reboot',
22
+ };
23
+ export const isSkipped = (key) => SKIPPED.some((s) => key === s || key.startsWith(s + '.'));
24
+ const show = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
25
+ // The text an edit starts with: strings as typed, everything else as JSON.
26
+ export function editText(value) {
27
+ if (typeof value === 'string')
28
+ return parseValue(value) === value ? value : JSON.stringify(value);
29
+ return JSON.stringify(value) ?? '';
30
+ }
31
+ // The rows for one tool, with its name in front of every key ("agents.engine").
32
+ export function settingRows(tool, config) {
33
+ const rows = [];
34
+ for (const [path, value] of flatten(config ?? {})) {
35
+ const key = `${tool}.${path}`;
36
+ if (isSkipped(key))
37
+ continue;
38
+ const masked = mask(key, value);
39
+ const secret = masked !== value;
40
+ rows.push({ key, shown: show(masked), edit: secret ? '' : editText(value), secret, hint: HINTS[key] ?? '' });
41
+ }
42
+ return rows;
43
+ }
44
+ // True when the tool has settings that live on other screens.
45
+ export const hasOtherScreens = (tool, config) => Object.keys(config ?? {}).some((k) => isSkipped(`${tool}.${k}`));
46
+ // `vsf config set KEY -- VALUE` keeps a value like -5 from being read as a flag.
47
+ export const setArgs = (key, value) => ['config', 'set', key, ...(value.startsWith('-') ? ['--'] : []), value];
48
+ export const unsetArgs = (key) => ['config', 'unset', key];
49
+ // The command's own problem line, for showing next to the row.
50
+ export function problemLine(output) {
51
+ return output.split('\n').map((l) => l.trim()).find(Boolean) ?? 'The change was not saved.';
52
+ }
53
+ export const nextIndex = (count, index, delta) => (count === 0 ? 0 : Math.max(0, Math.min(count - 1, index + delta)));
54
+ const BASE_HINTS = [
55
+ { key: 'enter', label: 'edit' },
56
+ { key: 'u', label: 'reset' },
57
+ { key: 'e', label: '$EDITOR' },
58
+ { key: 'esc', label: 'back' },
59
+ ];
60
+ function Settings(props) {
61
+ const ctx = useScreen();
62
+ const data = useData();
63
+ const tools = data.state.tools.filter((t) => data.readConfig(t) !== null);
64
+ const wanted = typeof props.params.tool === 'string' ? props.params.tool : tools[0];
65
+ const [toolName, setToolName] = useState(wanted);
66
+ const tool = toolName && tools.includes(toolName) ? toolName : tools[0];
67
+ const config = tool ? data.readConfig(tool) : null;
68
+ const rows = settingRows(tool ?? '', config);
69
+ const [sel, setSel] = useState(0);
70
+ const at = Math.min(sel, Math.max(0, rows.length - 1));
71
+ const row = rows[at];
72
+ const [editing, setEditing] = useState(false);
73
+ const [text, setText] = useState('');
74
+ const [error, setError] = useState('');
75
+ const [saving, setSaving] = useState(false);
76
+ useHints(editing ? [{ key: 'enter', label: 'save' }, { key: 'esc', label: 'cancel edit' }] : tools.length > 1 ? [...BASE_HINTS.slice(0, 3), { key: 'tab', label: 'tool' }, BASE_HINTS[3]] : BASE_HINTS);
77
+ useEquivalent(row ? (editing ? setArgs(row.key, text) : ['config', 'set', row.key]) : null);
78
+ const save = async (value) => {
79
+ if (!row || saving)
80
+ return;
81
+ setSaving(true);
82
+ const result = await ctx.actions.run({ args: setArgs(row.key, value), title: `Save ${row.key}`, noConfirm: true, done: `Saved ${row.key}` });
83
+ setSaving(false);
84
+ if (result && result.code === 0) {
85
+ setEditing(false);
86
+ setError('');
87
+ }
88
+ else if (result)
89
+ setError(problemLine(result.output));
90
+ };
91
+ useKeys((input, key) => {
92
+ if (editing) {
93
+ if (key.escape) {
94
+ setEditing(false);
95
+ setError('');
96
+ }
97
+ return;
98
+ }
99
+ if (key.upArrow)
100
+ setSel(nextIndex(rows.length, at, -1));
101
+ else if (key.downArrow)
102
+ setSel(nextIndex(rows.length, at, 1));
103
+ else if (key.tab && tools.length > 1) {
104
+ const i = tools.indexOf(tool ?? '');
105
+ setToolName(tools[(i + (key.shift ? tools.length - 1 : 1)) % tools.length]);
106
+ setSel(0);
107
+ setError('');
108
+ }
109
+ else if (key.return && row) {
110
+ setText(row.edit);
111
+ setError('');
112
+ setEditing(true);
113
+ }
114
+ else if (input === 'u' && row) {
115
+ void ctx.actions.run({
116
+ args: unsetArgs(row.key),
117
+ title: `Reset ${row.key}?`,
118
+ body: 'Removes your saved value, so the default applies again.',
119
+ confirmLabel: 'Reset',
120
+ cancelLabel: 'Keep it',
121
+ });
122
+ }
123
+ else if (input === 'e' && tool)
124
+ void ctx.actions.terminal(['config', 'edit', tool]);
125
+ }, { whileCapturing: true });
126
+ if (!tool) {
127
+ return h(Box, { paddingX: 1 }, h(Text, { color: theme.dim }, 'No added tool has settings yet.'));
128
+ }
129
+ const keyWidth = Math.min(34, Math.max(12, ...rows.map((r) => r.key.length - tool.length)) + 2);
130
+ const valueWidth = Math.min(26, Math.max(10, ...rows.map((r) => r.shown.length)) + 2);
131
+ const size = Math.max(4, ctx.rows - 14);
132
+ const win = windowOf(rows.length, at, size);
133
+ const other = hasOtherScreens(tool, config);
134
+ return h(Box, { flexDirection: 'row' }, h(Panel, { title: 'Tools', width: 16 }, ...tools.map((t) => h(Text, { key: t, color: t === tool ? theme.bold : theme.text, bold: t === tool }, `${t === tool ? '▶' : ' '} ${t}`))), h(Panel, { title: tool, focused: true, flexGrow: 1 }, rows.length === 0 ? h(Text, { color: theme.dim }, 'Nothing is saved yet. Defaults apply.') : null, ...rows.slice(win.start, win.end).map((r, i) => {
135
+ const index = win.start + i;
136
+ const on = index === at;
137
+ const bg = on ? theme.selected : undefined;
138
+ const name = r.key.slice(tool.length + 1);
139
+ return h(Box, { key: r.key, flexDirection: 'row' }, h(Text, { backgroundColor: bg, color: theme.accent }, on ? '▶ ' : ' '), h(Text, { backgroundColor: bg, bold: on, color: on ? theme.bold : theme.text }, fitCell(name, keyWidth)), on && editing
140
+ ? h(Box, { flexGrow: 1 }, h(TextInput, { value: text, onChange: setText, mask: r.secret, onSubmit: (v) => void save(v) }))
141
+ : h(Text, { backgroundColor: bg, bold: on }, fitCell(r.shown, valueWidth)), on && error
142
+ ? h(Text, { color: theme.red, wrap: 'truncate-end' }, ` ${error}`)
143
+ : on && editing
144
+ ? null
145
+ : h(Text, { color: theme.dim, wrap: 'truncate-end' }, ` ${r.hint}`));
146
+ }), rows.length > win.end - win.start ? h(Text, { color: theme.dim }, `${win.start + 1} to ${win.end} of ${rows.length}`) : null, other ? h(Box, { marginTop: 1 }, h(Text, { color: theme.dim }, 'Projects, workflows and folders have their own screens.')) : null));
147
+ }
148
+ export const settingsScreen = {
149
+ id: 'settings',
150
+ title: 'Settings',
151
+ component: (props) => h(Settings, { params: props.params }),
152
+ hints: () => BASE_HINTS,
153
+ };
@@ -0,0 +1,58 @@
1
+ import { type StepsCatalog, type WorkflowItem } from '../data.ts';
2
+ import type { Screen } from '../screens.ts';
3
+ import { type TriggerLike } from './workflows.ts';
4
+ export interface FormState {
5
+ name: string;
6
+ triggers: TriggerLike[];
7
+ steps: string[];
8
+ project: string;
9
+ rounds: number;
10
+ }
11
+ export type FormAction = {
12
+ type: 'name';
13
+ value: string;
14
+ } | {
15
+ type: 'addTrigger';
16
+ trigger: TriggerLike;
17
+ } | {
18
+ type: 'removeTrigger';
19
+ index: number;
20
+ } | {
21
+ type: 'addStep';
22
+ id: string;
23
+ } | {
24
+ type: 'removeStep';
25
+ index: number;
26
+ } | {
27
+ type: 'moveStep';
28
+ index: number;
29
+ dir: 1 | -1;
30
+ } | {
31
+ type: 'project';
32
+ value: string;
33
+ } | {
34
+ type: 'rounds';
35
+ value: number;
36
+ };
37
+ export declare function formReducer(s: FormState, a: FormAction): FormState;
38
+ export declare function initialForm(w: WorkflowItem | undefined, name: string, projects: string[]): FormState;
39
+ export declare function hasProducer(steps: string[], catalog: StepsCatalog | null): boolean;
40
+ export declare const effectiveRounds: (s: FormState, catalog: StepsCatalog | null) => number;
41
+ export declare function availableSteps(s: FormState, catalog: StepsCatalog | null): {
42
+ id: string;
43
+ label: string;
44
+ hint: string;
45
+ }[];
46
+ export declare function summaryLine(s: FormState, catalog: StepsCatalog | null): string;
47
+ export declare function formProblems(s: FormState, isNew: boolean, origName: string | null, existingNames: string[], projectNames: string[]): string[];
48
+ export declare function saveArgs(s: FormState, isNew: boolean, catalog: StepsCatalog | null): string[];
49
+ export declare const renameArgs: (from: string, to: string) => string[];
50
+ export declare function makeTrigger(team: string, label: string): {
51
+ trigger: TriggerLike;
52
+ } | {
53
+ error: string;
54
+ };
55
+ export declare function sharedTriggerWarnings(workflows: WorkflowItem[], s: FormState, origName: string | null): string[];
56
+ export type Section = 'name' | 'triggers' | 'steps' | 'project' | 'rounds';
57
+ export declare function sectionsFor(s: FormState, catalog: StepsCatalog | null): Section[];
58
+ export declare const workflowFormScreen: Screen;