@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,375 @@
1
+ // The workflow form: Name, Starts when (triggers), Steps, Project, Rounds.
2
+ // Saves with `vsf agents workflow add|edit` (and `rename` first when the name
3
+ // changed), each run as a child process.
4
+ import React, { useReducer, useState } from 'react';
5
+ import { Box, Text } from 'ink';
6
+ import { useData } from "../data.js";
7
+ import { useBackGuard, useEquivalent, useHints, useKeys, useScreen } from "../context.js";
8
+ import { theme } from "../theme.js";
9
+ import { Panel, Select, TextInput } from "../widgets.js";
10
+ import { tilde } from "../format.js";
11
+ import { matchWarnings, stepLabel, stepName, triggerText, warningText, sameTriggerLike } from "./workflows.js";
12
+ const h = React.createElement;
13
+ export function formReducer(s, a) {
14
+ switch (a.type) {
15
+ case 'name':
16
+ return { ...s, name: a.value };
17
+ case 'addTrigger':
18
+ return s.triggers.some((t) => sameTriggerLike(t, a.trigger)) ? s : { ...s, triggers: [...s.triggers, a.trigger] };
19
+ case 'removeTrigger':
20
+ return { ...s, triggers: s.triggers.filter((_, i) => i !== a.index) };
21
+ case 'addStep':
22
+ return s.steps.includes(a.id) ? s : { ...s, steps: [...s.steps, a.id] };
23
+ case 'removeStep':
24
+ return { ...s, steps: s.steps.filter((_, i) => i !== a.index) };
25
+ case 'moveStep': {
26
+ const to = a.index + a.dir;
27
+ if (to < 0 || to >= s.steps.length || a.index < 0 || a.index >= s.steps.length)
28
+ return s;
29
+ const steps = [...s.steps];
30
+ [steps[a.index], steps[to]] = [steps[to], steps[a.index]];
31
+ return { ...s, steps };
32
+ }
33
+ case 'project':
34
+ return { ...s, project: a.value };
35
+ case 'rounds':
36
+ return { ...s, rounds: Math.max(1, Math.min(9, Math.round(a.value))) };
37
+ }
38
+ }
39
+ export function initialForm(w, name, projects) {
40
+ if (w)
41
+ return { name, triggers: w.triggers.map((t) => ({ team: t.team, label: t.label })), steps: [...w.steps], project: w.project, rounds: w.maxRounds };
42
+ return { name: '', triggers: [], steps: [], project: projects.length === 1 ? projects[0] : '', rounds: 3 };
43
+ }
44
+ const FALLBACK_PRODUCERS = ['vs-plan', 'vs-dev'];
45
+ // A producer (Plan or Dev) is what a failed check sends the work back to.
46
+ export function hasProducer(steps, catalog) {
47
+ return steps.some((id) => {
48
+ const s = catalog?.steps.find((x) => x.id === id);
49
+ return s ? s.output === 'plan' || s.output === 'commits' : FALLBACK_PRODUCERS.includes(id);
50
+ });
51
+ }
52
+ export const effectiveRounds = (s, catalog) => (hasProducer(s.steps, catalog) ? s.rounds : 1);
53
+ // Steps that can still be added, in catalog order.
54
+ export function availableSteps(s, catalog) {
55
+ const all = catalog?.steps.map((x) => ({ id: x.id, label: x.label, hint: x.hint })) ?? [
56
+ { id: 'vs-plan', label: 'Plan', hint: '' },
57
+ { id: 'vs-plan-review', label: 'Plan review', hint: '' },
58
+ { id: 'vs-publish', label: 'Publish', hint: '' },
59
+ { id: 'vs-dev', label: 'Dev', hint: '' },
60
+ { id: 'vs-qa', label: 'QA', hint: '' },
61
+ ];
62
+ return all.filter((x) => !s.steps.includes(x.id));
63
+ }
64
+ // "when ENG + vs-plan: Dev → QA in tether-relay, 3 rounds"
65
+ export function summaryLine(s, catalog) {
66
+ const when = s.triggers.length ? s.triggers.map(triggerText).join(', ') : '(no trigger yet)';
67
+ const steps = s.steps.length ? s.steps.map((id) => stepLabel(catalog, id)).join(' → ') : '(no steps yet)';
68
+ const project = s.project || '(no project yet)';
69
+ const n = effectiveRounds(s, catalog);
70
+ return `when ${when}: ${steps} in ${project}, ${n} ${n === 1 ? 'round' : 'rounds'}`;
71
+ }
72
+ export function formProblems(s, isNew, origName, existingNames, projectNames) {
73
+ const out = [];
74
+ const name = s.name.trim();
75
+ if (!name)
76
+ out.push('Give the workflow a name.');
77
+ else if (/\s/.test(name))
78
+ out.push('The name cannot have spaces.');
79
+ else if ((isNew || name !== origName) && existingNames.includes(name))
80
+ out.push(`A workflow named ${name} already exists.`);
81
+ if (!s.triggers.length)
82
+ out.push('Add at least one trigger (press a on Starts when).');
83
+ if (!s.steps.length)
84
+ out.push('Add at least one step (press + on Steps).');
85
+ if (!projectNames.length)
86
+ out.push('There are no projects yet. Add one on the Projects tab first.');
87
+ else if (!s.project || !projectNames.includes(s.project))
88
+ out.push('Choose a project.');
89
+ return out;
90
+ }
91
+ // The command a save runs (after `rename`, when the name changed).
92
+ export function saveArgs(s, isNew, catalog) {
93
+ return [
94
+ 'agents',
95
+ 'workflow',
96
+ isNew ? 'add' : 'edit',
97
+ s.name.trim(),
98
+ ...s.triggers.flatMap((t) => ['--trigger', `${t.team}:${t.label}`]),
99
+ '--steps',
100
+ s.steps.map((id) => stepName(catalog, id)).join(','),
101
+ '--project',
102
+ s.project,
103
+ '--rounds',
104
+ String(effectiveRounds(s, catalog)),
105
+ ];
106
+ }
107
+ export const renameArgs = (from, to) => ['agents', 'workflow', 'rename', from, to];
108
+ // A trigger from the two text fields, or a complaint.
109
+ export function makeTrigger(team, label) {
110
+ const t = team.trim().toUpperCase();
111
+ const l = label.trim();
112
+ if (!t)
113
+ return { error: 'Give the Linear team, like ENG.' };
114
+ if (t.includes(':') || /\s/.test(t))
115
+ return { error: 'The team is a short key like ENG.' };
116
+ if (!l)
117
+ return { error: 'Give the label, like vs-plan.' };
118
+ return { trigger: { team: t, label: l } };
119
+ }
120
+ // Warnings for triggers this workflow shares with another. In place when
121
+ // editing, last in order when new.
122
+ export function sharedTriggerWarnings(workflows, s, origName) {
123
+ const me = { name: s.name.trim() || 'this workflow', triggers: s.triggers };
124
+ const others = workflows.map((w) => ({ name: w.name, triggers: w.triggers }));
125
+ const at = origName === null ? -1 : others.findIndex((w) => w.name === origName);
126
+ const merged = at >= 0 ? others.map((w, i) => (i === at ? me : w)) : [...others, me];
127
+ return matchWarnings(merged)
128
+ .filter((w) => w.loser === me.name || w.winner === me.name)
129
+ .map(warningText);
130
+ }
131
+ export function sectionsFor(s, catalog) {
132
+ return hasProducer(s.steps, catalog) ? ['name', 'triggers', 'steps', 'project', 'rounds'] : ['name', 'triggers', 'steps', 'project'];
133
+ }
134
+ const KEY_HINTS = [
135
+ { key: 'tab', label: 'next field' },
136
+ { key: 'ctrl+s', label: 'save' },
137
+ { key: 'esc', label: 'discard' },
138
+ ];
139
+ // ---- component ----------------------------------------------------------------
140
+ function Label(props) {
141
+ return h(Box, { width: 13, flexShrink: 0 }, h(Text, { bold: props.focused, color: props.focused ? theme.accent : theme.text }, props.text));
142
+ }
143
+ function WorkflowForm(props) {
144
+ const ctx = useScreen();
145
+ const { state } = useData();
146
+ const catalog = state.steps;
147
+ const origName = typeof props.params.name === 'string' ? props.params.name : null;
148
+ const isNew = origName === null;
149
+ const projectNames = state.projects.map((p) => p.name);
150
+ const [initial] = useState(() => initialForm(origName ? state.workflows.find((w) => w.name === origName) : undefined, origName ?? '', projectNames));
151
+ const [form, dispatch] = useReducer(formReducer, initial);
152
+ const [focus, setFocus] = useState('name');
153
+ const [tCursor, setTCursor] = useState(0);
154
+ const [sCursor, setSCursor] = useState(0);
155
+ const [adding, setAdding] = useState(null);
156
+ const [newTeam, setNewTeam] = useState('');
157
+ const [newLabel, setNewLabel] = useState('');
158
+ const [addError, setAddError] = useState(null);
159
+ const [picking, setPicking] = useState(null);
160
+ const [attempted, setAttempted] = useState(false);
161
+ const [busy, setBusy] = useState(false);
162
+ const [error, setError] = useState(null);
163
+ const sections = sectionsFor(form, catalog);
164
+ const where = sections.includes(focus) ? focus : 'steps';
165
+ const producer = hasProducer(form.steps, catalog);
166
+ const avail = availableSteps(form, catalog);
167
+ const dirty = JSON.stringify(form) !== JSON.stringify(initial);
168
+ const existing = state.workflows.map((w) => w.name);
169
+ const problems = formProblems(form, isNew, origName, existing, projectNames);
170
+ const nameTaken = form.name.trim() !== '' && (isNew || form.name.trim() !== origName) && existing.includes(form.name.trim());
171
+ const warnings = sharedTriggerWarnings(state.workflows, form, origName);
172
+ const typing = (where === 'name' && adding === null && picking === null) || adding !== null;
173
+ useHints(adding !== null ? [{ key: 'enter', label: 'add trigger' }, { key: 'tab', label: 'team / label' }, { key: 'esc', label: 'cancel' }] : picking !== null ? [{ key: '↑↓', label: 'choose' }, { key: 'enter', label: 'add step' }, { key: 'esc', label: 'cancel' }] : KEY_HINTS);
174
+ useEquivalent(form.name.trim() ? saveArgs(form, isNew, catalog) : null);
175
+ useBackGuard(() => true);
176
+ const move = (delta) => setFocus(sections[(sections.indexOf(where) + delta + sections.length) % sections.length]);
177
+ const save = async () => {
178
+ if (busy)
179
+ return;
180
+ if (problems.length) {
181
+ setAttempted(true);
182
+ return;
183
+ }
184
+ setBusy(true);
185
+ setError(null);
186
+ const name = form.name.trim();
187
+ if (!isNew && origName && name !== origName) {
188
+ const r = await ctx.actions.run({ args: renameArgs(origName, name), title: 'Rename workflow', noConfirm: true });
189
+ if (!r || r.code !== 0) {
190
+ setBusy(false);
191
+ if (r)
192
+ setError(r.output || 'Could not rename the workflow.');
193
+ return;
194
+ }
195
+ }
196
+ const r = await ctx.actions.run({ args: saveArgs(form, isNew, catalog), title: 'Save workflow', noConfirm: true, done: `Saved ${name}` });
197
+ setBusy(false);
198
+ if (r && r.code === 0)
199
+ ctx.nav.pop();
200
+ else if (r)
201
+ setError(r.output || `vsf exited with code ${r.code}`);
202
+ };
203
+ const discard = async () => {
204
+ if (dirty) {
205
+ const ok = await ctx.actions.confirm({ title: 'Discard changes?', body: 'What you changed here will be lost.', confirmLabel: 'Discard', cancelLabel: 'Keep editing' });
206
+ if (!ok)
207
+ return;
208
+ }
209
+ ctx.nav.pop();
210
+ };
211
+ const commitTrigger = () => {
212
+ const r = makeTrigger(newTeam, newLabel);
213
+ if ('error' in r) {
214
+ setAddError(r.error);
215
+ setAdding(newTeam.trim() ? 'label' : 'team');
216
+ return;
217
+ }
218
+ dispatch({ type: 'addTrigger', trigger: r.trigger });
219
+ setTCursor(form.triggers.length);
220
+ setAdding(null);
221
+ setAddError(null);
222
+ setNewTeam('');
223
+ setNewLabel('');
224
+ };
225
+ useKeys((input, key) => {
226
+ // A Select that is open owns the keys.
227
+ if (ctx.capturing && !typing)
228
+ return;
229
+ if (key.escape) {
230
+ if (adding !== null) {
231
+ setAdding(null);
232
+ setAddError(null);
233
+ }
234
+ else if (picking !== null)
235
+ setPicking(null);
236
+ else
237
+ void discard();
238
+ return;
239
+ }
240
+ if (key.ctrl && input === 's') {
241
+ void save();
242
+ return;
243
+ }
244
+ if (adding !== null) {
245
+ if (key.tab)
246
+ setAdding(adding === 'team' ? 'label' : 'team');
247
+ return;
248
+ }
249
+ if (picking !== null) {
250
+ if (key.upArrow)
251
+ setPicking((i) => Math.max(0, (i ?? 0) - 1));
252
+ else if (key.downArrow)
253
+ setPicking((i) => Math.min(avail.length - 1, (i ?? 0) + 1));
254
+ else if (key.return) {
255
+ const pick = avail[picking];
256
+ if (pick) {
257
+ dispatch({ type: 'addStep', id: pick.id });
258
+ setSCursor(form.steps.length);
259
+ }
260
+ setPicking(null);
261
+ }
262
+ return;
263
+ }
264
+ if (key.tab) {
265
+ move(key.shift ? -1 : 1);
266
+ return;
267
+ }
268
+ if (where === 'name') {
269
+ if (key.downArrow)
270
+ move(1);
271
+ else if (key.upArrow)
272
+ move(-1);
273
+ return;
274
+ }
275
+ if (where === 'triggers') {
276
+ if (input === 'a') {
277
+ setAdding('team');
278
+ setNewTeam('');
279
+ setNewLabel('');
280
+ setAddError(null);
281
+ }
282
+ else if (input === 'x' && form.triggers.length) {
283
+ dispatch({ type: 'removeTrigger', index: tCursor });
284
+ setTCursor(Math.max(0, Math.min(tCursor, form.triggers.length - 2)));
285
+ }
286
+ else if (key.upArrow)
287
+ (tCursor > 0 ? setTCursor(tCursor - 1) : move(-1));
288
+ else if (key.downArrow)
289
+ (tCursor < form.triggers.length - 1 ? setTCursor(tCursor + 1) : move(1));
290
+ return;
291
+ }
292
+ if (where === 'steps') {
293
+ if ((input === '+' || input === 'a') && avail.length)
294
+ setPicking(0);
295
+ else if (input === 'x' && form.steps.length) {
296
+ dispatch({ type: 'removeStep', index: sCursor });
297
+ setSCursor(Math.max(0, Math.min(sCursor, form.steps.length - 2)));
298
+ }
299
+ else if (input === 'J' || input === 'K') {
300
+ const dir = input === 'J' ? 1 : -1;
301
+ if (sCursor + dir >= 0 && sCursor + dir < form.steps.length) {
302
+ dispatch({ type: 'moveStep', index: sCursor, dir });
303
+ setSCursor(sCursor + dir);
304
+ }
305
+ }
306
+ else if (key.leftArrow)
307
+ setSCursor(Math.max(0, sCursor - 1));
308
+ else if (key.rightArrow)
309
+ setSCursor(Math.min(form.steps.length - 1, sCursor + 1));
310
+ else if (key.upArrow)
311
+ move(-1);
312
+ else if (key.downArrow)
313
+ move(1);
314
+ return;
315
+ }
316
+ if (where === 'project') {
317
+ if (key.upArrow)
318
+ move(-1);
319
+ else if (key.downArrow)
320
+ move(1);
321
+ return;
322
+ }
323
+ if (where === 'rounds') {
324
+ if (/^[1-9]$/.test(input))
325
+ dispatch({ type: 'rounds', value: Number(input) });
326
+ else if (key.leftArrow || input === '-')
327
+ dispatch({ type: 'rounds', value: form.rounds - 1 });
328
+ else if (key.rightArrow || input === '+')
329
+ dispatch({ type: 'rounds', value: form.rounds + 1 });
330
+ else if (key.upArrow)
331
+ move(-1);
332
+ else if (key.downArrow)
333
+ move(1);
334
+ }
335
+ }, { whileCapturing: true });
336
+ const projectOptions = state.projects.map((p) => ({ value: p.name, label: p.name, hint: `${tilde(p.path)}, PRs into ${p.base ?? 'the default branch'}` }));
337
+ // Steps line: "1 Dev → 2 QA", the focused one highlighted.
338
+ const stepNodes = [];
339
+ form.steps.forEach((id, i) => {
340
+ const on = where === 'steps' && i === sCursor;
341
+ if (i > 0)
342
+ stepNodes.push(h(Text, { key: `a${i}`, color: theme.dim }, ' → '));
343
+ stepNodes.push(h(Text, { key: id, backgroundColor: on ? theme.selected : undefined, bold: on, color: on ? theme.bold : undefined }, `${i + 1} ${stepLabel(catalog, id)}`));
344
+ });
345
+ return h(Panel, { title: isNew ? 'New workflow' : 'Edit workflow', focused: true },
346
+ // Name
347
+ h(Box, null, h(Label, { text: 'Name', focused: where === 'name' }), where === 'name' && adding === null && picking === null ? null : h(Text, null, ' '), h(TextInput, { value: form.name, onChange: (v) => dispatch({ type: 'name', value: v }), focus: where === 'name' && adding === null && picking === null, placeholder: 'tether-relay-build' }), nameTaken ? h(Text, { color: theme.red }, ' already taken') : null), h(Text, null, ' '),
348
+ // Triggers
349
+ h(Box, null, h(Label, { text: 'Starts when', focused: where === 'triggers' }), h(Box, { flexDirection: 'column', flexGrow: 1 }, form.triggers.length === 0 ? h(Text, { color: theme.dim }, ' no trigger yet') : null, ...form.triggers.map((t, i) => {
350
+ const on = where === 'triggers' && i === tCursor && adding === null;
351
+ return h(Text, { key: `${t.team}:${t.label}`, backgroundColor: on ? theme.selected : undefined, bold: on, color: on ? theme.bold : undefined }, ` ${triggerText(t)} `);
352
+ }), adding !== null
353
+ ? h(Box, { flexDirection: 'column' }, h(Box, null, h(Text, { color: theme.dim }, ' team '), h(TextInput, { value: newTeam, onChange: setNewTeam, focus: adding === 'team', placeholder: 'ENG', onSubmit: () => setAdding('label') }), h(Text, { color: theme.dim }, ' label '), h(TextInput, { value: newLabel, onChange: setNewLabel, focus: adding === 'label', placeholder: 'vs-plan', onSubmit: commitTrigger })), addError ? h(Text, { color: theme.red }, ` ${addError}`) : null)
354
+ : null), h(Box, { flexShrink: 0, marginLeft: 2 }, h(Text, { color: theme.dim }, where === 'triggers' ? 'a add trigger · x remove' : ''))), h(Text, null, ' '),
355
+ // Steps
356
+ h(Box, null, h(Label, { text: 'Steps', focused: where === 'steps' }), h(Box, { flexDirection: 'column', flexGrow: 1 }, form.steps.length === 0 ? h(Text, { color: theme.dim }, ' no steps yet') : h(Text, { wrap: 'truncate-end' }, ' ', ...stepNodes), picking !== null
357
+ ? h(Box, { flexDirection: 'column' }, ...avail.map((s, i) => h(Text, { key: s.id, wrap: 'truncate-end', backgroundColor: i === picking ? theme.selected : undefined, color: i === picking ? theme.bold : theme.text }, ` ${i === picking ? '▶ ' : ' '}${s.label}`, s.hint ? h(Text, { color: theme.dim, wrap: 'truncate-end' }, ` ${s.hint}`) : null)))
358
+ : avail.length
359
+ ? h(Text, { color: theme.dim, wrap: 'truncate-end' }, ` available: ${avail.map((s) => s.label).join(', ')}`)
360
+ : null), h(Box, { flexShrink: 0, marginLeft: 2 }, h(Text, { color: theme.dim }, where === 'steps' && picking === null ? '+ add · J/K reorder · x remove' : ''))), h(Text, null, ' '),
361
+ // Project
362
+ h(Box, null, h(Label, { text: 'Project', focused: where === 'project' }), projectOptions.length
363
+ ? h(Select, { options: projectOptions, value: form.project, onChange: (v) => dispatch({ type: 'project', value: v }), focus: where === 'project' && adding === null && picking === null })
364
+ : h(Text, { color: theme.red }, 'No projects yet. Add one on the Projects tab.')), h(Text, null, ' '),
365
+ // Rounds
366
+ h(Box, null, h(Label, { text: 'Rounds', focused: where === 'rounds' }), producer
367
+ ? h(Text, null, h(Text, { bold: where === 'rounds', color: where === 'rounds' ? theme.accent : undefined }, ` ${form.rounds} `), h(Text, { color: theme.dim }, ' tries at Plan or Dev before the card parks'))
368
+ : h(Text, { color: theme.dim }, '1 fixed: no step here can be sent back')), h(Text, null, ' '), h(Text, { color: theme.accent, wrap: 'truncate-end' }, summaryLine(form, catalog)), ...warnings.map((w, i) => h(Text, { key: `w${i}`, color: theme.yellow, wrap: 'truncate-end' }, `! ${w}`)), attempted && problems.length ? h(Box, { flexDirection: 'column', marginTop: 1 }, ...problems.map((p, i) => h(Text, { key: i, color: theme.red }, `! ${p}`))) : null, error ? h(Box, { marginTop: 1 }, h(Text, { color: theme.red }, error)) : null, busy ? h(Text, { color: theme.dim }, 'Saving...') : null);
369
+ }
370
+ export const workflowFormScreen = {
371
+ id: 'agents.workflow.form',
372
+ title: (p) => ['Workflows', typeof p.name === 'string' ? `Edit ${p.name}` : 'New workflow'],
373
+ component: (props) => h(WorkflowForm, { params: props.params }),
374
+ hints: () => KEY_HINTS,
375
+ };
@@ -0,0 +1,41 @@
1
+ import { type StepsCatalog, type WorkflowItem } from '../data.ts';
2
+ import { type Column } from '../widgets.ts';
3
+ import type { Screen } from '../screens.ts';
4
+ export declare function stepLabel(catalog: StepsCatalog | null, id: string): string;
5
+ export declare function stepName(catalog: StepsCatalog | null, id: string): string;
6
+ export interface TriggerLike {
7
+ team: string;
8
+ label: string;
9
+ }
10
+ export declare const triggerText: (t: TriggerLike) => string;
11
+ export declare const triggersText: (triggers: TriggerLike[]) => string;
12
+ export declare const sameTriggerLike: (a: TriggerLike, b: TriggerLike) => boolean;
13
+ export declare const stepsText: (catalog: StepsCatalog | null, steps: string[]) => string;
14
+ export interface MatchWarning {
15
+ loser: string;
16
+ winner: string;
17
+ triggers: TriggerLike[];
18
+ }
19
+ export declare function matchWarnings(workflows: {
20
+ name: string;
21
+ triggers: TriggerLike[];
22
+ }[]): MatchWarning[];
23
+ export declare const warningText: (w: MatchWarning) => string;
24
+ export declare function moveArgs(workflows: {
25
+ name: string;
26
+ }[], index: number, dir: 1 | -1): string[] | null;
27
+ export declare function workflowColumns(columns: number, workflows?: WorkflowItem[], catalog?: StepsCatalog | null): Column[];
28
+ export declare function workflowRows(workflows: WorkflowItem[], catalog: StepsCatalog | null): (string | {
29
+ text: string;
30
+ colour: "#6b7280";
31
+ bold?: undefined;
32
+ } | {
33
+ text: string;
34
+ bold: boolean;
35
+ colour?: undefined;
36
+ } | {
37
+ text: string;
38
+ colour: "#7aa2f7";
39
+ bold?: undefined;
40
+ })[][];
41
+ export declare const workflowsScreen: Screen;
@@ -0,0 +1,174 @@
1
+ // Agents, Workflows: the workflows in match order. Every change is a real
2
+ // `vsf agents workflow ...` command run as a child process.
3
+ import React, { useState } from 'react';
4
+ import { Box, Text } from 'ink';
5
+ import { useData } from "../data.js";
6
+ import { useEquivalent, useHints, useKeys, useScreen } from "../context.js";
7
+ import { theme } from "../theme.js";
8
+ import { Panel, Table, TextInput } from "../widgets.js";
9
+ const h = React.createElement;
10
+ // ---- pure helpers (shared with the workflow form) --------------------------
11
+ const FALLBACK_LABELS = {
12
+ 'vs-plan': 'Plan',
13
+ 'vs-plan-review': 'Plan review',
14
+ 'vs-publish': 'Publish',
15
+ 'vs-dev': 'Dev',
16
+ 'vs-qa': 'QA',
17
+ };
18
+ export function stepLabel(catalog, id) {
19
+ const found = catalog?.steps.find((s) => s.id === id);
20
+ if (found)
21
+ return found.label;
22
+ if (FALLBACK_LABELS[id])
23
+ return FALLBACK_LABELS[id];
24
+ const t = id.replace(/^vs-/, '').replace(/-/g, ' ');
25
+ return t.charAt(0).toUpperCase() + t.slice(1);
26
+ }
27
+ // The short name the commands take: "dev", "plan-review".
28
+ export function stepName(catalog, id) {
29
+ return catalog?.steps.find((s) => s.id === id)?.name ?? id.replace(/^vs-/, '');
30
+ }
31
+ export const triggerText = (t) => `${t.team} + ${t.label}`;
32
+ export const triggersText = (triggers) => triggers.map(triggerText).join(', ');
33
+ export const sameTriggerLike = (a, b) => a.team === b.team && a.label === b.label;
34
+ export const stepsText = (catalog, steps) => steps.map((s) => stepLabel(catalog, s)).join(' → ');
35
+ // Two workflows that start on the same TEAM:LABEL: only the first in order
36
+ // starts the card. One entry per (loser, winner) pair.
37
+ export function matchWarnings(workflows) {
38
+ const out = [];
39
+ workflows.forEach((w, j) => {
40
+ const byWinner = new Map();
41
+ for (const t of w.triggers) {
42
+ const winner = workflows.slice(0, j).find((o) => o.triggers.some((x) => sameTriggerLike(x, t)));
43
+ if (!winner)
44
+ continue;
45
+ byWinner.set(winner.name, [...(byWinner.get(winner.name) ?? []), t]);
46
+ }
47
+ for (const [winner, triggers] of byWinner)
48
+ out.push({ loser: w.name, winner, triggers });
49
+ });
50
+ return out;
51
+ }
52
+ export const warningText = (w) => `${w.loser} also starts on ${w.triggers.map(triggerText).join(', ')}, so ${w.winner} wins.`;
53
+ // The move command for J (dir 1, down) or K (dir -1, up); null at the ends.
54
+ export function moveArgs(workflows, index, dir) {
55
+ const me = workflows[index];
56
+ const other = workflows[index + dir];
57
+ if (!me || !other)
58
+ return null;
59
+ return ['agents', 'workflow', 'move', me.name, dir === 1 ? '--after' : '--before', other.name];
60
+ }
61
+ // Column widths that fit the longest cell (plus a gap), within the terminal.
62
+ export function workflowColumns(columns, workflows = [], catalog = null) {
63
+ const fit = (min, texts, max) => Math.min(max, Math.max(min, ...texts.map((t) => t.length + 2)));
64
+ const budget = Math.max(60, columns - 12);
65
+ const name = fit(12, workflows.map((w) => w.name), 28);
66
+ const trig = fit(14, workflows.map((w) => triggersText(w.triggers)), 28);
67
+ const proj = fit(10, workflows.map((w) => w.project), 20);
68
+ const steps = Math.max(12, Math.min(fit(12, workflows.map((w) => stepsText(catalog, w.steps)), 48), budget - 4 - 6 - name - trig - proj));
69
+ return [{ title: '#', width: 4 }, { title: 'Name', width: name }, { title: 'Starts when', width: trig }, { title: 'Steps', width: steps }, { title: 'Project', width: proj }, { title: 'Rounds' }];
70
+ }
71
+ export function workflowRows(workflows, catalog) {
72
+ return workflows.map((w, i) => [
73
+ { text: String(i + 1), colour: theme.dim },
74
+ { text: w.name, bold: true },
75
+ triggersText(w.triggers),
76
+ stepsText(catalog, w.steps),
77
+ { text: w.project, colour: theme.accent },
78
+ String(w.maxRounds),
79
+ ]);
80
+ }
81
+ // ---- screen ----------------------------------------------------------------
82
+ const LIST_HINTS = [
83
+ { key: 'n', label: 'new' },
84
+ { key: 'e', label: 'edit' },
85
+ { key: 'R', label: 'rename' },
86
+ { key: 'J/K', label: 'move down/up' },
87
+ { key: 'd', label: 'delete' },
88
+ { key: 'esc', label: 'back' },
89
+ ];
90
+ function WorkflowsList() {
91
+ const ctx = useScreen();
92
+ const { state } = useData();
93
+ const list = state.workflows;
94
+ const [selName, setSelName] = useState(null);
95
+ const [renaming, setRenaming] = useState(null);
96
+ const [draft, setDraft] = useState('');
97
+ const found = list.findIndex((w) => w.name === selName);
98
+ const index = found >= 0 ? found : Math.min(0, list.length - 1);
99
+ const selected = list[index];
100
+ const warnings = matchWarnings(list);
101
+ useHints(renaming !== null ? [{ key: 'enter', label: 'rename' }, { key: 'esc', label: 'cancel' }] : LIST_HINTS);
102
+ useEquivalent(renaming !== null && draft.trim() && draft.trim() !== renaming ? ['agents', 'workflow', 'rename', renaming, draft.trim()] : null);
103
+ const rename = async () => {
104
+ const next = draft.trim();
105
+ if (renaming === null)
106
+ return;
107
+ if (!next || next === renaming) {
108
+ setRenaming(null);
109
+ return;
110
+ }
111
+ if (list.some((w) => w.name === next)) {
112
+ ctx.toast(`A workflow named ${next} already exists.`, 'error');
113
+ return;
114
+ }
115
+ const r = await ctx.actions.run({ args: ['agents', 'workflow', 'rename', renaming, next], title: 'Rename workflow', noConfirm: true, done: `Renamed to ${next}` });
116
+ if (r && r.code === 0)
117
+ setSelName(next);
118
+ if (r)
119
+ setRenaming(null);
120
+ };
121
+ useKeys((input, key) => {
122
+ if (renaming !== null) {
123
+ if (key.escape)
124
+ setRenaming(null);
125
+ return;
126
+ }
127
+ if (key.upArrow || input === 'k')
128
+ setSelName(list[Math.max(0, index - 1)]?.name ?? null);
129
+ else if (key.downArrow || input === 'j')
130
+ setSelName(list[Math.min(list.length - 1, index + 1)]?.name ?? null);
131
+ else if (input === 'n') {
132
+ if (!state.projects.length)
133
+ ctx.toast('Add a project first, on the Projects tab.', 'info');
134
+ else
135
+ ctx.nav.push('agents.workflow.form', {});
136
+ }
137
+ else if ((input === 'e' || key.return) && selected)
138
+ ctx.nav.push('agents.workflow.form', { name: selected.name });
139
+ else if (input === 'R' && selected) {
140
+ setDraft(selected.name);
141
+ setRenaming(selected.name);
142
+ }
143
+ else if ((input === 'J' || input === 'K') && selected) {
144
+ const args = moveArgs(list, index, input === 'J' ? 1 : -1);
145
+ if (!args)
146
+ ctx.toast(input === 'J' ? 'Already last.' : 'Already first.', 'info');
147
+ else
148
+ void ctx.actions.run({ args, title: 'Move workflow', noConfirm: true, done: `Moved ${selected.name}` });
149
+ }
150
+ else if (input === 'd' && selected) {
151
+ void ctx.actions.run({
152
+ args: ['agents', 'workflow', 'rm', selected.name, '--yes'],
153
+ title: `Delete workflow ${selected.name}?`,
154
+ body: 'Cards will no longer start it. Runs already recorded stay.',
155
+ confirmLabel: 'Delete',
156
+ done: `Deleted ${selected.name}`,
157
+ });
158
+ }
159
+ }, { whileCapturing: true });
160
+ return h(Panel, { title: 'Workflows, in match order', focused: true }, h(Text, { color: theme.dim }, 'The first workflow that matches a card wins.'), h(Text, null, ' '), h(Table, {
161
+ columns: workflowColumns(ctx.columns, list, state.steps),
162
+ rows: workflowRows(list, state.steps),
163
+ selected: index,
164
+ empty: state.loaded ? 'No workflows yet. Press n to make one.' : 'Loading...',
165
+ }), warnings.length ? h(Text, null, ' ') : null, ...warnings.map((w, i) => h(Text, { key: i, color: theme.yellow, wrap: 'truncate-end' }, `! ${warningText(w)}${i === warnings.length - 1 ? ' J/K reorders.' : ''}`)), renaming !== null
166
+ ? h(Box, { marginTop: 1 }, h(Text, { color: theme.dim }, `Rename ${renaming} to `), h(TextInput, { value: draft, onChange: setDraft, onSubmit: () => void rename() }))
167
+ : null, state.errors.workflows ? h(Text, { color: theme.red }, state.errors.workflows) : null);
168
+ }
169
+ export const workflowsScreen = {
170
+ id: 'agents.workflows',
171
+ title: 'Workflows',
172
+ component: WorkflowsList,
173
+ hints: () => LIST_HINTS,
174
+ };
@@ -0,0 +1,30 @@
1
+ import React from 'react';
2
+ import { type Hint } from './context.ts';
3
+ import type { Params } from './nav.ts';
4
+ export interface ScreenProps {
5
+ params: Params;
6
+ }
7
+ export interface Screen {
8
+ id: string;
9
+ title: string | string[] | ((params: Params) => string | string[]);
10
+ component: (props: ScreenProps) => React.ReactElement | null;
11
+ hints: (props: {
12
+ params: Params;
13
+ }) => Hint[];
14
+ }
15
+ export interface TabDef {
16
+ id: string;
17
+ label: string;
18
+ params?: Params;
19
+ }
20
+ export declare const AGENT_TABS: TabDef[];
21
+ export declare const activeTab: (params: Params) => TabDef;
22
+ export declare function ScreenHost(props: {
23
+ id: string;
24
+ params: Params;
25
+ extraHints?: Hint[];
26
+ }): React.ReactElement;
27
+ export declare const screens: Record<string, Screen>;
28
+ export declare function titleOf(screen: Screen, params: Params): string[];
29
+ export declare function hintsOf(id: string, params: Params): Hint[];
30
+ export declare function crumbsOf(id: string, params: Params): string[];