@bpmnkit/cli 0.0.9

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/dist/tui.js ADDED
@@ -0,0 +1,2544 @@
1
+ import { spawn } from "node:child_process";
2
+ import { renderBpmnAscii } from "@bpmnkit/ascii";
3
+ import { appendAuditEntry, getAuditLog, getSettings, saveSettings } from "@bpmnkit/profiles";
4
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
5
+ const CSI = "\x1b[";
6
+ const HIDE = `${CSI}?25l`;
7
+ const SHOW = `${CSI}?25h`;
8
+ const ALT_ON = `${CSI}?1049h`;
9
+ const ALT_OFF = `${CSI}?1049l`;
10
+ const CLEAR = `${CSI}2J${CSI}H`;
11
+ const inv = (s) => `${CSI}7m${s}${CSI}m`;
12
+ const bold = (s) => `${CSI}1m${s}${CSI}m`;
13
+ const dim = (s) => `${CSI}2m${s}${CSI}m`;
14
+ const green = (s) => `${CSI}32m${s}${CSI}m`;
15
+ const red = (s) => `${CSI}31m${s}${CSI}m`;
16
+ const cyan = (s) => `${CSI}36m${s}${CSI}m`;
17
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: needed for ANSI stripping
18
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
19
+ function vlen(s) {
20
+ return s.replace(ANSI_RE, "").length;
21
+ }
22
+ function padEnd(s, n) {
23
+ const v = vlen(s);
24
+ return v < n ? s + " ".repeat(n - v) : s;
25
+ }
26
+ function fit(s, n) {
27
+ return s.length > n ? `${s.slice(0, n - 1)}…` : s;
28
+ }
29
+ // ─── Capturing output writer ──────────────────────────────────────────────────
30
+ function makeCapturingWriter() {
31
+ let captured = { type: "messages", lines: [] };
32
+ function extractItems(data) {
33
+ if (typeof data === "object" && data !== null && "items" in data) {
34
+ return data.items;
35
+ }
36
+ if (Array.isArray(data))
37
+ return data;
38
+ return [data];
39
+ }
40
+ function extractTotal(data, items) {
41
+ if (typeof data === "object" && data !== null) {
42
+ const page = data.page;
43
+ if (typeof page === "object" && page !== null) {
44
+ const t = page.totalItems;
45
+ if (typeof t === "number")
46
+ return t;
47
+ }
48
+ }
49
+ return items.length;
50
+ }
51
+ const writer = {
52
+ format: "table",
53
+ isInteractive: true,
54
+ printList(data, columns) {
55
+ const items = extractItems(data);
56
+ captured = { type: "list", items, columns, total: extractTotal(data, items) };
57
+ },
58
+ printItem(data) {
59
+ captured = { type: "item", data };
60
+ },
61
+ print(data) {
62
+ if (captured.type === "messages") {
63
+ const str = typeof data === "string" ? data : JSON.stringify(data, null, 2);
64
+ for (const line of str.split("\n")) {
65
+ captured.lines.push(line);
66
+ }
67
+ }
68
+ },
69
+ ok(msg) {
70
+ if (captured.type === "messages")
71
+ captured.lines.push(`${green("✓")} ${msg}`);
72
+ },
73
+ info(msg) {
74
+ if (captured.type === "messages")
75
+ captured.lines.push(msg);
76
+ },
77
+ };
78
+ return { writer, get: () => captured };
79
+ }
80
+ // ─── Field helpers ────────────────────────────────────────────────────────────
81
+ const SKIP_FLAGS = new Set(["output", "profile", "help", "no-color", "debug"]);
82
+ function buildFields(cmd) {
83
+ const fields = [];
84
+ for (const arg of cmd.args ?? []) {
85
+ fields.push({
86
+ kind: "arg",
87
+ label: arg.name,
88
+ hint: arg.required ? "required" : "optional",
89
+ value: "",
90
+ cursor: 0,
91
+ required: arg.required ?? false,
92
+ argSpec: arg,
93
+ });
94
+ }
95
+ for (const flag of cmd.flags ?? []) {
96
+ if (SKIP_FLAGS.has(flag.name))
97
+ continue;
98
+ const hintParts = [
99
+ flag.type,
100
+ flag.placeholder ? `<${flag.placeholder}>` : "",
101
+ flag.required ? "required" : "optional",
102
+ ];
103
+ fields.push({
104
+ kind: "flag",
105
+ label: `--${flag.name}`,
106
+ hint: hintParts.filter(Boolean).join(" "),
107
+ value: flag.default !== undefined ? String(flag.default) : "",
108
+ cursor: 0,
109
+ required: flag.required ?? false,
110
+ flagSpec: flag,
111
+ });
112
+ }
113
+ fields.push({ kind: "run", label: "[ Run ]", hint: "", value: "", cursor: 0, required: false });
114
+ return fields;
115
+ }
116
+ function buildContext(cmd, fields, writer, getClient, getAdminClient) {
117
+ const positional = [];
118
+ const flags = {};
119
+ for (const f of fields) {
120
+ if (f.kind === "arg" && f.value)
121
+ positional.push(f.value);
122
+ if (f.kind === "flag" && f.value && f.flagSpec) {
123
+ const { name, type } = f.flagSpec;
124
+ if (type === "boolean") {
125
+ flags[name] = f.value === "true" || f.value === "yes" || f.value === "1";
126
+ }
127
+ else if (type === "number") {
128
+ const n = Number(f.value);
129
+ if (!Number.isNaN(n))
130
+ flags[name] = n;
131
+ }
132
+ else {
133
+ flags[name] = f.value;
134
+ }
135
+ }
136
+ }
137
+ return { positional, flags, output: writer, getClient, getAdminClient };
138
+ }
139
+ // ─── Field type helpers ────────────────────────────────────────────────────────
140
+ function getEnum(field) {
141
+ if (field.kind === "flag")
142
+ return field.flagSpec?.enum;
143
+ if (field.kind === "arg")
144
+ return field.argSpec?.enum;
145
+ return undefined;
146
+ }
147
+ function isJsonField(field) {
148
+ if (field.kind === "flag")
149
+ return field.flagSpec?.json === true;
150
+ if (field.kind === "arg")
151
+ return field.argSpec?.json === true;
152
+ return false;
153
+ }
154
+ function getPresets(field) {
155
+ if (field.kind === "flag")
156
+ return field.flagSpec?.presets;
157
+ return undefined;
158
+ }
159
+ function parseJsonToEntries(json) {
160
+ if (!json.trim())
161
+ return [];
162
+ try {
163
+ const obj = JSON.parse(json);
164
+ return Object.entries(obj).map(([key, val]) => ({
165
+ key,
166
+ keyCursor: key.length,
167
+ val: typeof val === "string" ? val : JSON.stringify(val),
168
+ valCursor: 0,
169
+ }));
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ }
175
+ function entriesToJson(entries) {
176
+ const obj = {};
177
+ for (const e of entries) {
178
+ if (!e.key)
179
+ continue;
180
+ try {
181
+ obj[e.key] = JSON.parse(e.val);
182
+ }
183
+ catch {
184
+ obj[e.key] = e.val;
185
+ }
186
+ }
187
+ return Object.keys(obj).length === 0 ? "" : JSON.stringify(obj);
188
+ }
189
+ /**
190
+ * Build initial entries for the JSON editor.
191
+ * If fieldSpecs are provided, pre-populate with all known fields (in spec order),
192
+ * merging in any existing values. Extra keys from existing JSON are appended at the end.
193
+ */
194
+ function buildInitialEntries(existingJson, fieldSpecs) {
195
+ const existing = {};
196
+ if (existingJson.trim()) {
197
+ try {
198
+ const obj = JSON.parse(existingJson);
199
+ for (const [k, v] of Object.entries(obj)) {
200
+ existing[k] = typeof v === "string" ? v : JSON.stringify(v);
201
+ }
202
+ }
203
+ catch {
204
+ // ignore malformed JSON
205
+ }
206
+ }
207
+ if (!fieldSpecs || fieldSpecs.length === 0)
208
+ return parseJsonToEntries(existingJson);
209
+ const seenKeys = new Set();
210
+ const entries = [];
211
+ for (const spec of fieldSpecs) {
212
+ seenKeys.add(spec.name);
213
+ const val = existing[spec.name] ?? "";
214
+ entries.push({ key: spec.name, keyCursor: spec.name.length, val, valCursor: val.length });
215
+ }
216
+ // Append extra keys not in the spec
217
+ for (const [k, v] of Object.entries(existing)) {
218
+ if (!seenKeys.has(k)) {
219
+ entries.push({ key: k, keyCursor: k.length, val: v, valCursor: v.length });
220
+ }
221
+ }
222
+ return entries;
223
+ }
224
+ /** Look up a JsonFieldSpec by key name. */
225
+ function getFieldSpec(key, fieldSpecs) {
226
+ return fieldSpecs?.find((s) => s.name === key);
227
+ }
228
+ // ─── Table helpers ────────────────────────────────────────────────────────────
229
+ function getCellStr(item, col) {
230
+ let val = item;
231
+ for (const part of col.key.split(".")) {
232
+ val = val?.[part];
233
+ }
234
+ if (col.transform)
235
+ return col.transform(val);
236
+ if (val === null || val === undefined)
237
+ return "—";
238
+ if (typeof val === "object")
239
+ return JSON.stringify(val);
240
+ return String(val);
241
+ }
242
+ function calcColWidths(items, columns, available) {
243
+ const natural = columns.map((col) => {
244
+ let max = col.header.length;
245
+ for (const item of items) {
246
+ const len = Math.min(getCellStr(item, col).length, col.maxWidth ?? 999);
247
+ if (len > max)
248
+ max = len;
249
+ }
250
+ return max;
251
+ });
252
+ const gap = (columns.length - 1) * 2;
253
+ const total = natural.reduce((s, w) => s + w, 0) + gap;
254
+ if (total <= available)
255
+ return natural;
256
+ const scale = (available - gap) / natural.reduce((s, w) => s + w, 0);
257
+ return natural.map((w) => Math.max(5, Math.round(w * scale)));
258
+ }
259
+ /** Sentinel for arrays of objects — displayed as "[N items]", Space to drill down. */
260
+ class ArrayValue {
261
+ items;
262
+ constructor(items) {
263
+ this.items = items;
264
+ }
265
+ toString() {
266
+ return `[${this.items.length} item${this.items.length !== 1 ? "s" : ""}]`;
267
+ }
268
+ }
269
+ function flattenObj(obj, prefix = "") {
270
+ // Top-level array (from drill-down): expand each element with [i] prefix
271
+ if (Array.isArray(obj)) {
272
+ const result = {};
273
+ for (let i = 0; i < obj.length; i++) {
274
+ const elem = obj[i];
275
+ if (typeof elem === "object" && elem !== null) {
276
+ Object.assign(result, flattenObj(elem, `[${i}]`));
277
+ }
278
+ else {
279
+ result[`[${i}]`] = elem;
280
+ }
281
+ }
282
+ return result;
283
+ }
284
+ if (typeof obj !== "object" || obj === null)
285
+ return { value: obj };
286
+ const result = {};
287
+ for (const [k, v] of Object.entries(obj)) {
288
+ const key = prefix ? `${prefix}.${k}` : k;
289
+ if (Array.isArray(v)) {
290
+ if (v.length === 0) {
291
+ result[key] = "[]";
292
+ }
293
+ else if (v.every((item) => typeof item !== "object" || item === null)) {
294
+ result[key] = v.join(", ");
295
+ }
296
+ else {
297
+ // Arrays of objects: show as collapsed summary; Space to expand
298
+ result[key] = new ArrayValue(v);
299
+ }
300
+ }
301
+ else if (typeof v === "object" && v !== null) {
302
+ Object.assign(result, flattenObj(v, key));
303
+ }
304
+ else {
305
+ result[key] = v;
306
+ }
307
+ }
308
+ return result;
309
+ }
310
+ // ─── Render helpers ───────────────────────────────────────────────────────────
311
+ function termSize() {
312
+ return { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 };
313
+ }
314
+ function renderHeader(crumbs, cols, profile) {
315
+ const title = crumbs.map((c, i) => (i === 0 ? bold(c) : cyan(c))).join(dim(" › "));
316
+ const profileStr = profile ? dim(` profile: ${profile}`) : "";
317
+ const titleLen = crumbs.join(" › ").length + 2;
318
+ const profileLen = profile ? ` profile: ${profile}`.length : 0;
319
+ const pad = Math.max(0, cols - 4 - titleLen - profileLen);
320
+ const header = profileStr ? ` ${title}${" ".repeat(pad)}${profileStr}` : ` ${title}`;
321
+ return `\n${header}\n ${dim("─".repeat(cols - 4))}`;
322
+ }
323
+ function renderText(value, cursorPos, width, isEditing) {
324
+ if (!isEditing)
325
+ return value ? fit(value, width) : dim("─");
326
+ const v = value;
327
+ const c = cursorPos;
328
+ const start = c >= width ? c - width + 1 : 0;
329
+ const segment = v.slice(start, start + width + 1);
330
+ const rel = c - start;
331
+ const before = segment.slice(0, rel);
332
+ const ch = segment[rel];
333
+ const after = segment.slice(rel + (ch ? 1 : 0), rel + width);
334
+ return `${before}${ch ? inv(ch) : inv(" ")}${after}`;
335
+ }
336
+ function renderFieldValue(field, width, isEditing) {
337
+ return renderText(field.value, field.cursor, width, isEditing);
338
+ }
339
+ /** Pop the stack all the way back to the main menu (first screen). */
340
+ function popToMain(state) {
341
+ if (state.stack.length > 1)
342
+ state.stack.splice(1);
343
+ }
344
+ // ─── Screen renderers ─────────────────────────────────────────────────────────
345
+ function filterGroups(groups, search) {
346
+ if (!search)
347
+ return groups;
348
+ const q = search.toLowerCase();
349
+ return groups.filter((g) => g.name.toLowerCase().includes(q) || g.description.toLowerCase().includes(q));
350
+ }
351
+ function filterCommands(cmds, search) {
352
+ if (!search)
353
+ return cmds;
354
+ const q = search.toLowerCase();
355
+ return cmds.filter((c) => c.name.toLowerCase().includes(q) || c.description.toLowerCase().includes(q));
356
+ }
357
+ function renderMain(state, screen) {
358
+ const { cols, rows } = termSize();
359
+ const searching = screen.search.length > 0;
360
+ const viewH = Math.max(3, rows - (searching ? 9 : 7));
361
+ const groups = filterGroups(state.groups, screen.search);
362
+ const nameW = state.groups.reduce((m, g) => Math.max(m, g.name.length), 0);
363
+ const lines = [renderHeader(["casen"], cols, state.profile), ""];
364
+ if (searching) {
365
+ lines.push(` ${dim("/")} ${screen.search}█\n`);
366
+ }
367
+ const visible = groups.slice(screen.scroll, screen.scroll + viewH);
368
+ for (let vi = 0; vi < visible.length; vi++) {
369
+ const g = visible[vi];
370
+ if (!g)
371
+ continue;
372
+ const i = screen.scroll + vi;
373
+ const isCursor = i === screen.cursor;
374
+ const name = padEnd(isCursor ? cyan(g.name) : g.name, nameW + 2);
375
+ const desc = dim(fit(g.description, cols - nameW - 8));
376
+ const line = ` ${name} ${desc}`;
377
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
378
+ }
379
+ if (groups.length > viewH) {
380
+ const hi = Math.min(screen.scroll + viewH, groups.length);
381
+ lines.push(`\n ${dim(`${screen.scroll + 1}–${hi} of ${groups.length}`)}`);
382
+ }
383
+ const hint = searching
384
+ ? ` ${dim("↑↓")} navigate ${cyan("enter")} open ${cyan("esc")} clear ${dim("bksp")} delete ${cyan("^C")} quit`
385
+ : ` ${dim("↑↓")} navigate ${cyan("enter")} open ${dim("type")} search ${cyan("^C")} quit`;
386
+ lines.push(`\n${hint}`);
387
+ return lines;
388
+ }
389
+ function renderCommands(state, screen) {
390
+ const { cols, rows } = termSize();
391
+ const searching = screen.search.length > 0;
392
+ const viewH = Math.max(3, rows - (searching ? 11 : 9));
393
+ const cmds = filterCommands(screen.group.commands, screen.search);
394
+ const nameW = screen.group.commands.reduce((m, c) => Math.max(m, c.name.length), 0);
395
+ const hasMain = state.stack[0]?.kind === "main";
396
+ const lines = [
397
+ renderHeader([screen.group.name], cols, state.profile),
398
+ `\n ${dim(screen.group.description)}\n`,
399
+ ];
400
+ if (searching) {
401
+ lines.push(` ${dim("/")} ${screen.search}█\n`);
402
+ }
403
+ for (let i = 0; i < cmds.length; i++) {
404
+ const cmd = cmds[i];
405
+ if (!cmd)
406
+ continue;
407
+ const isCursor = i === screen.cursor;
408
+ const name = padEnd(isCursor ? cyan(cmd.name) : cmd.name, nameW + 2);
409
+ const desc = dim(fit(cmd.description, cols - nameW - 8));
410
+ const line = ` ${name} ${desc}`;
411
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
412
+ }
413
+ if (searching) {
414
+ lines.push(`\n ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} clear ${dim("bksp")} delete`);
415
+ }
416
+ else {
417
+ const mHint = hasMain ? ` ${cyan("m")} main menu` : "";
418
+ lines.push(`\n ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} back${mHint} ${dim("type")} search ${cyan("^C")} quit`);
419
+ }
420
+ return lines;
421
+ }
422
+ function renderInput(state, screen) {
423
+ const { cols, rows } = termSize();
424
+ const viewH = Math.max(3, rows - 9);
425
+ const labelW = screen.fields.reduce((m, f) => (f.kind !== "run" ? Math.max(m, f.label.length) : m), 0);
426
+ const hintW = 26;
427
+ const valueW = Math.max(20, cols - labelW - hintW - 10);
428
+ const lines = [
429
+ renderHeader([screen.group.name, screen.cmd.name], cols, state.profile),
430
+ `\n ${dim(screen.cmd.description)}\n`,
431
+ ];
432
+ const visible = screen.fields.slice(screen.scroll, screen.scroll + viewH);
433
+ for (let vi = 0; vi < visible.length; vi++) {
434
+ const field = visible[vi];
435
+ if (!field)
436
+ continue;
437
+ const fi = screen.scroll + vi;
438
+ const isCursor = fi === screen.cursor;
439
+ if (field.kind === "run") {
440
+ const btn = isCursor ? inv(" Run ") : dim("[ Run ]");
441
+ lines.push(` ${isCursor ? cyan("▶") : " "} ${btn}`);
442
+ continue;
443
+ }
444
+ const marker = isCursor ? cyan("▶") : " ";
445
+ const label = padEnd(isCursor ? cyan(field.label) : dim(field.label), labelW + 2);
446
+ const enumVals = getEnum(field);
447
+ const isJson = isJsonField(field);
448
+ const presets = getPresets(field);
449
+ // In edit mode for enum fields, ↑↓ cycle rather than typing
450
+ const isEnumEditing = screen.editing && isCursor && enumVals !== undefined;
451
+ const value = isEnumEditing
452
+ ? enumVals.includes(field.value)
453
+ ? cyan(padEnd(field.value, valueW))
454
+ : padEnd(field.value, valueW)
455
+ : renderFieldValue(field, valueW, screen.editing && isCursor && !isJson);
456
+ const req = field.required ? red("*") : " ";
457
+ // Show contextual hint for the focused field
458
+ let hintText = field.hint;
459
+ if (isCursor) {
460
+ if (enumVals) {
461
+ const idx = enumVals.indexOf(field.value);
462
+ const pos = idx >= 0 ? `${idx + 1}/${enumVals.length}` : `?/${enumVals.length}`;
463
+ hintText = `↑↓ pick ${pos}`;
464
+ }
465
+ else if (isJson) {
466
+ hintText = "→ json editor";
467
+ }
468
+ else if (presets) {
469
+ hintText = "↑↓ preset";
470
+ }
471
+ }
472
+ const hint = isCursor ? cyan(fit(hintText, hintW)) : dim(fit(hintText, hintW));
473
+ lines.push(` ${marker} ${label} ${padEnd(value, valueW)} ${req} ${hint}`);
474
+ }
475
+ lines.push("");
476
+ if (screen.error) {
477
+ lines.push(` ${red("error:")} ${screen.error}`);
478
+ }
479
+ else if (screen.running) {
480
+ lines.push(` ${dim("running…")}`);
481
+ }
482
+ else if (screen.editing) {
483
+ const curField = screen.fields[screen.cursor];
484
+ const curEnum = curField ? getEnum(curField) : undefined;
485
+ const curPresets = curField ? getPresets(curField) : undefined;
486
+ if (curEnum) {
487
+ lines.push(` ${dim("↑↓")} cycle ${cyan("enter")} confirm ${cyan("esc")} cancel ${dim(curEnum.join(" | "))}`);
488
+ }
489
+ else if (curPresets) {
490
+ lines.push(` ${dim("←→")} cursor ${cyan("↑↓")} presets ${cyan("enter")} confirm ${cyan("esc")} cancel`);
491
+ }
492
+ else {
493
+ lines.push(` ${dim("←→")} cursor ${cyan("ctrl+a/e")} home/end ${cyan("ctrl+k/u")} clear ${cyan("enter")} confirm ${cyan("esc")} cancel`);
494
+ }
495
+ }
496
+ else {
497
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} edit/run ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
498
+ }
499
+ return lines;
500
+ }
501
+ function renderRawView(screen, cols) {
502
+ const raw = screen.raw;
503
+ if (!raw)
504
+ return [` ${dim("(no raw response captured)")}`];
505
+ const lines = [];
506
+ const statusCol = raw.status >= 200 && raw.status < 300 ? green : red;
507
+ lines.push(` ${bold("Status:")} ${statusCol(`HTTP ${raw.status}`)}`);
508
+ lines.push("");
509
+ lines.push(` ${bold("Headers:")}`);
510
+ for (const [k, v] of Object.entries(raw.headers)) {
511
+ const kStr = padEnd(cyan(k), 36);
512
+ lines.push(` ${kStr} ${fit(v, cols - 42)}`);
513
+ }
514
+ lines.push("");
515
+ lines.push(` ${bold("Body:")}`);
516
+ let bodyLines;
517
+ try {
518
+ const parsed = JSON.parse(raw.body);
519
+ bodyLines = JSON.stringify(parsed, null, 2).split("\n");
520
+ }
521
+ catch {
522
+ bodyLines = raw.body.split("\n");
523
+ }
524
+ for (const l of bodyLines) {
525
+ lines.push(` ${fit(l, cols - 4)}`);
526
+ }
527
+ return lines;
528
+ }
529
+ // ─── Curl view ────────────────────────────────────────────────────────────────
530
+ const CURL_TOKEN_VAR = "CAMUNDA_TOKEN";
531
+ function buildCurlCmd(raw) {
532
+ const parts = [`curl -X ${raw.method}`];
533
+ let token = null;
534
+ for (const [k, v] of Object.entries(raw.requestHeaders)) {
535
+ let val = v;
536
+ if (k.toLowerCase() === "authorization") {
537
+ const m = /^Bearer (.+)$/.exec(v);
538
+ if (m) {
539
+ token = m[1] ?? null;
540
+ val = `Bearer $${CURL_TOKEN_VAR}`;
541
+ }
542
+ }
543
+ parts.push(` -H '${k}: ${val}'`);
544
+ }
545
+ if (raw.requestBody) {
546
+ parts.push(` -d '${raw.requestBody.replace(/'/g, "'\\''")}'`);
547
+ }
548
+ parts.push(` '${raw.url}'`);
549
+ return { cmd: parts.join(" \\\n"), token };
550
+ }
551
+ function copyToClipboard(text) {
552
+ const cmds = process.platform === "darwin"
553
+ ? [["pbcopy"]]
554
+ : process.platform === "win32"
555
+ ? [["clip"]]
556
+ : [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
557
+ const tryNext = (i) => {
558
+ const entry = cmds[i];
559
+ if (!entry)
560
+ return;
561
+ const [bin, ...args] = entry;
562
+ if (!bin) {
563
+ tryNext(i + 1);
564
+ return;
565
+ }
566
+ try {
567
+ const proc = spawn(bin, args, { stdio: ["pipe", "ignore", "ignore"] });
568
+ proc.on("error", () => tryNext(i + 1));
569
+ proc.stdin.write(text);
570
+ proc.stdin.end();
571
+ proc.unref();
572
+ }
573
+ catch {
574
+ tryNext(i + 1);
575
+ }
576
+ };
577
+ tryNext(0);
578
+ }
579
+ function renderCurlView(screen, cols) {
580
+ const raw = screen.raw;
581
+ if (!raw)
582
+ return [` ${dim("(no raw response captured)")}`];
583
+ const { cmd, token } = buildCurlCmd(raw);
584
+ const lines = [];
585
+ lines.push(` ${bold("curl command:")}`);
586
+ lines.push("");
587
+ for (const l of cmd.split("\n")) {
588
+ lines.push(` ${fit(l, cols - 4)}`);
589
+ }
590
+ if (token) {
591
+ lines.push("");
592
+ lines.push(` ${dim(`Token obfuscated as $${CURL_TOKEN_VAR}. Press ${bold("e")} to copy export statement.`)}`);
593
+ }
594
+ lines.push("");
595
+ lines.push(` ${dim(`Press ${bold("y")} to copy curl command to clipboard.`)}`);
596
+ return lines;
597
+ }
598
+ function renderResults(state, screen) {
599
+ const { cols, rows } = termSize();
600
+ const viewH = Math.max(3, rows - 10);
601
+ const out = screen.output;
602
+ const lines = [];
603
+ const statusBadge = screen.raw
604
+ ? (() => {
605
+ const fn = screen.raw.status >= 200 && screen.raw.status < 300 ? green : red;
606
+ return ` ${dim(fn(`HTTP ${screen.raw.status}`))}`;
607
+ })()
608
+ : "";
609
+ const rawToggle = ` ${screen.rawView ? cyan("r") : dim("r")} ${dim("raw")}`;
610
+ const curlToggle = screen.raw ? ` ${screen.curlView ? cyan("u") : dim("u")} ${dim("curl")}` : "";
611
+ if (screen.curlView) {
612
+ lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
613
+ lines.push("");
614
+ const curlLines = renderCurlView(screen, cols);
615
+ const visible = curlLines.slice(screen.scroll, screen.scroll + rows - 8);
616
+ for (const l of visible)
617
+ lines.push(l);
618
+ lines.push("");
619
+ if (curlLines.length > rows - 8) {
620
+ const hi = Math.min(screen.scroll + rows - 8, curlLines.length);
621
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${curlLines.length}`)}`);
622
+ }
623
+ lines.push(` ${dim("↑↓")} scroll${rawToggle}${curlToggle} ${cyan("y")} copy curl ${cyan("e")} copy export ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
624
+ return lines;
625
+ }
626
+ if (screen.rawView) {
627
+ lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
628
+ lines.push("");
629
+ const rawLines = renderRawView(screen, cols);
630
+ const visible = rawLines.slice(screen.scroll, screen.scroll + rows - 8);
631
+ for (const l of visible)
632
+ lines.push(l);
633
+ lines.push("");
634
+ if (rawLines.length > rows - 8) {
635
+ const hi = Math.min(screen.scroll + rows - 8, rawLines.length);
636
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${rawLines.length}`)}`);
637
+ }
638
+ lines.push(` ${dim("↑↓")} scroll${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
639
+ return lines;
640
+ }
641
+ if (out.type === "list") {
642
+ lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
643
+ lines.push(`\n ${dim(`${out.total} item${out.total !== 1 ? "s" : ""}`)}${statusBadge}\n`);
644
+ const available = cols - 4;
645
+ const widths = calcColWidths(out.items, out.columns, available);
646
+ const header = out.columns
647
+ .map((col, i) => padEnd(dim(col.header), widths[i] ?? col.header.length))
648
+ .join(" ");
649
+ lines.push(` ${header}`);
650
+ lines.push(` ${dim("─".repeat(available))}`);
651
+ const visible = out.items.slice(screen.scroll, screen.scroll + viewH);
652
+ for (let vi = 0; vi < visible.length; vi++) {
653
+ const item = visible[vi];
654
+ if (!item)
655
+ continue;
656
+ const isCursor = screen.scroll + vi === screen.cursor;
657
+ const cells = out.columns.map((col, ci) => {
658
+ const w = widths[ci] ?? 10;
659
+ return padEnd(fit(getCellStr(item, col), w), w);
660
+ });
661
+ const line = ` ${cells.join(" ")}`;
662
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
663
+ }
664
+ lines.push("");
665
+ if (out.total > viewH) {
666
+ const hi = Math.min(screen.scroll + viewH, out.total);
667
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${out.total}`)}`);
668
+ }
669
+ const followupHint = screen.cmd.relations ? ` ${cyan("f")} follow-up` : "";
670
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} detail${followupHint} ${dim("pgup/pgdn")} page${rawToggle}${curlToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
671
+ }
672
+ else if (out.type === "item") {
673
+ lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
674
+ lines.push(statusBadge ? `\n${statusBadge}` : "");
675
+ const flat = flattenObj(out.data);
676
+ const entries = Object.entries(flat);
677
+ const kw = entries.reduce((m, [k]) => Math.max(m, k.length), 0);
678
+ for (let i = 0; i < entries.length; i++) {
679
+ const entry = entries[i];
680
+ if (!entry)
681
+ continue;
682
+ const [k, v] = entry;
683
+ const isCursor = i === screen.cursor;
684
+ const isArr = v instanceof ArrayValue;
685
+ const valueStr = v === null || v === undefined ? dim("—") : String(v);
686
+ const hint = isArr && isCursor ? ` ${dim("[space] expand")}` : "";
687
+ const line = ` ${padEnd(cyan(k), kw + 2)} ${valueStr}${hint}`;
688
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
689
+ }
690
+ const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
691
+ ? ` ${cyan("space")} expand array`
692
+ : "";
693
+ lines.push(`\n${rawToggle}${curlToggle}${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
694
+ }
695
+ else {
696
+ lines.push(renderHeader([screen.group.name, screen.cmd.name], cols, state.profile));
697
+ lines.push(statusBadge ? `\n${statusBadge}` : "");
698
+ const displayLines = screen.altView && out.altLines ? out.altLines : out.lines;
699
+ const altToggle = out.altLines
700
+ ? ` ${screen.altView ? cyan("x") : dim("x")} ${dim("ascii")}`
701
+ : "";
702
+ if (displayLines.length === 0) {
703
+ lines.push(` ${dim("(no output)")}`);
704
+ }
705
+ else {
706
+ const hOff = screen.altView ? 0 : screen.cursor;
707
+ const visible = displayLines.slice(screen.scroll, screen.scroll + viewH);
708
+ for (const line of visible) {
709
+ lines.push(` ${fit(line.slice(hOff), cols - 4)}`);
710
+ }
711
+ if (displayLines.length > viewH || hOff > 0) {
712
+ const hi = Math.min(screen.scroll + viewH, displayLines.length);
713
+ const panHint = hOff > 0 ? ` col +${hOff}` : "";
714
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${displayLines.length}${panHint}`)}`);
715
+ }
716
+ }
717
+ lines.push(`\n ${dim("↑↓←→")} scroll/pan${rawToggle}${curlToggle}${altToggle} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
718
+ }
719
+ return lines;
720
+ }
721
+ function renderDetail(state, screen) {
722
+ const { cols, rows } = termSize();
723
+ const viewH = Math.max(3, rows - 7);
724
+ const flat = flattenObj(screen.item);
725
+ const entries = Object.entries(flat);
726
+ const kw = entries.reduce((m, [k]) => Math.max(m, k.length), 0);
727
+ const lines = [renderHeader([screen.group.name, screen.label], cols, state.profile), ""];
728
+ const visible = entries.slice(screen.scroll, screen.scroll + viewH);
729
+ for (let vi = 0; vi < visible.length; vi++) {
730
+ const entry = visible[vi];
731
+ if (!entry)
732
+ continue;
733
+ const [k, v] = entry;
734
+ const isCursor = screen.scroll + vi === screen.cursor;
735
+ const isArr = v instanceof ArrayValue;
736
+ const valueStr = v === null || v === undefined ? dim("—") : String(v);
737
+ const hint = isArr && isCursor ? ` ${dim("[space] expand")}` : "";
738
+ const line = ` ${padEnd(cyan(k), kw + 2)} ${valueStr}${hint}`;
739
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
740
+ }
741
+ lines.push("");
742
+ if (entries.length > viewH) {
743
+ const hi = Math.min(screen.scroll + viewH, entries.length);
744
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${entries.length} fields`)}`);
745
+ }
746
+ const spaceHint = entries.some(([, v]) => v instanceof ArrayValue)
747
+ ? ` ${cyan("space/→")} expand ${cyan("←")} back`
748
+ : ` ${cyan("←")} back`;
749
+ lines.push(` ${dim("↑↓")} navigate${spaceHint} ${cyan("m")} main menu ${cyan("esc")} back ${cyan("q")} quit`);
750
+ return lines;
751
+ }
752
+ function renderFollowup(state, screen) {
753
+ const { cols, rows } = termSize();
754
+ const viewH = Math.max(3, rows - 8);
755
+ const lines = [
756
+ renderHeader([screen.sourceGroup.name, "follow-up actions"], cols, state.profile),
757
+ "",
758
+ ];
759
+ // Show the fields that will be pre-filled
760
+ const usedFields = new Set();
761
+ for (const rel of screen.relations) {
762
+ for (const p of rel.params)
763
+ usedFields.add(p.field);
764
+ }
765
+ const summary = [...usedFields]
766
+ .map((f) => `${dim(f)}: ${String(screen.item[f] ?? "")}`)
767
+ .join(" ");
768
+ if (summary) {
769
+ lines.push(` ${summary}`);
770
+ lines.push("");
771
+ }
772
+ const visible = screen.relations.slice(screen.scroll, screen.scroll + viewH);
773
+ for (let vi = 0; vi < visible.length; vi++) {
774
+ const rel = visible[vi];
775
+ if (!rel)
776
+ continue;
777
+ const isCursor = screen.scroll + vi === screen.cursor;
778
+ const gName = padEnd(cyan(rel.group.name), 22);
779
+ const cName = rel.cmd.name.padEnd(26);
780
+ const line = ` ${gName} ${cName} ${dim(fit(rel.cmd.description, cols - 56))}`;
781
+ lines.push(isCursor ? inv(line.padEnd(cols - 1)) : line);
782
+ }
783
+ lines.push("");
784
+ if (screen.relations.length > viewH) {
785
+ const hi = Math.min(screen.scroll + viewH, screen.relations.length);
786
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${screen.relations.length}`)}`);
787
+ }
788
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} run pre-filled ${cyan("esc")} back ${cyan("q")} quit`);
789
+ return lines;
790
+ }
791
+ function renderProfile(state, screen) {
792
+ const { cols, rows } = termSize();
793
+ const viewH = Math.max(3, rows - 7);
794
+ const kw = state.profileInfo.reduce((m, { key }) => Math.max(m, key.length), 0);
795
+ const lines = [renderHeader(["profile"], cols, state.profile), ""];
796
+ const visible = state.profileInfo.slice(screen.scroll, screen.scroll + viewH);
797
+ for (const { key, value } of visible) {
798
+ lines.push(` ${padEnd(cyan(key), kw + 2)} ${value}`);
799
+ }
800
+ lines.push("");
801
+ if (state.profileInfo.length > viewH) {
802
+ const hi = Math.min(screen.scroll + viewH, state.profileInfo.length);
803
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${state.profileInfo.length}`)}`);
804
+ }
805
+ lines.push(` ${dim("↑↓")} scroll ${cyan("esc/p")} close ${cyan("q")} quit`);
806
+ return lines;
807
+ }
808
+ const SETTINGS_ROWS = [
809
+ {
810
+ kind: "number",
811
+ key: "auditLogSize",
812
+ label: "Audit Log Size",
813
+ description: "Number of actions to keep per profile (0 = disabled)",
814
+ min: 0,
815
+ max: 1000,
816
+ },
817
+ {
818
+ kind: "action",
819
+ id: "show-audit-log",
820
+ label: "Show Audit Log",
821
+ description: "View recent commands executed for the active profile",
822
+ },
823
+ ];
824
+ function renderSettings(state, screen) {
825
+ const { cols } = termSize();
826
+ const settings = getSettings();
827
+ const labelW = SETTINGS_ROWS.reduce((m, r) => Math.max(m, r.label.length), 0) + 2;
828
+ const lines = [renderHeader(["settings"], cols, state.profile), ""];
829
+ for (let i = 0; i < SETTINGS_ROWS.length; i++) {
830
+ const row = SETTINGS_ROWS[i];
831
+ if (!row)
832
+ continue;
833
+ const isCursor = i === screen.cursor;
834
+ let valueStr;
835
+ if (row.kind === "number") {
836
+ valueStr =
837
+ screen.editing && isCursor
838
+ ? cyan(`[${screen.editValue}_]`)
839
+ : bold(String(settings[row.key]));
840
+ }
841
+ else {
842
+ valueStr = dim("→");
843
+ }
844
+ const labelPart = padEnd(isCursor ? cyan(row.label) : row.label, labelW);
845
+ const content = ` ${labelPart} ${valueStr}`;
846
+ lines.push(isCursor ? inv(content.padEnd(cols - 1)) : content);
847
+ }
848
+ const row = SETTINGS_ROWS[screen.cursor];
849
+ if (row) {
850
+ lines.push("");
851
+ lines.push(` ${dim(row.description)}`);
852
+ }
853
+ lines.push("");
854
+ if (screen.message) {
855
+ lines.push(` ${screen.message}`);
856
+ }
857
+ else if (screen.editing) {
858
+ lines.push(` ${dim("type")} new value ${cyan("enter")} save ${cyan("esc")} cancel ${dim("bksp")} delete`);
859
+ }
860
+ else {
861
+ lines.push(` ${dim("↑↓")} navigate ${cyan("enter")} select ${cyan("esc")} back ${cyan("q")} quit`);
862
+ }
863
+ return lines;
864
+ }
865
+ // ─── Audit log screen ─────────────────────────────────────────────────────────
866
+ function renderAuditLog(state, screen) {
867
+ const { cols, rows } = termSize();
868
+ const viewH = Math.max(3, rows - 8);
869
+ const entries = getAuditLog(state.profile || undefined);
870
+ const lines = [renderHeader(["settings", "audit log"], cols, state.profile), ""];
871
+ if (entries.length === 0) {
872
+ lines.push(` ${dim(`No audit log entries for profile "${state.profile || "default"}".`)}`);
873
+ }
874
+ else {
875
+ const tsW = 19;
876
+ const stW = 5;
877
+ const cmdW = Math.max(20, cols - tsW - stW - 8);
878
+ lines.push(` ${dim(padEnd("TIME", tsW + 2))} ${dim(padEnd("COMMAND", cmdW))} ${dim("STATUS")}`);
879
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
880
+ const visible = entries.slice(screen.scroll, screen.scroll + viewH);
881
+ for (const e of visible) {
882
+ const ts = dim(e.timestamp.replace("T", " ").slice(0, tsW));
883
+ const cmd = fit(`${e.group} ${e.command}${e.positional.length ? ` ${e.positional.join(" ")}` : ""}`, cmdW);
884
+ const status = e.status === "ok" ? green("ok") : red("err");
885
+ lines.push(` ${ts} ${padEnd(cmd, cmdW)} ${status}`);
886
+ }
887
+ if (entries.length > viewH) {
888
+ const hi = Math.min(screen.scroll + viewH, entries.length);
889
+ lines.push(`\n ${dim(`${screen.scroll + 1}–${hi} of ${entries.length}`)}`);
890
+ }
891
+ }
892
+ lines.push(`\n ${dim("↑↓")} scroll ${cyan("esc")} back ${cyan("q")} quit`);
893
+ return lines;
894
+ }
895
+ function renderWorker(state, screen) {
896
+ const { cols, rows } = termSize();
897
+ const viewH = Math.max(3, rows - 13);
898
+ const statusStr = screen.status === "running"
899
+ ? green("● RUNNING")
900
+ : screen.status === "starting"
901
+ ? dim("◌ STARTING")
902
+ : screen.status === "stopping"
903
+ ? cyan("◌ STOPPING")
904
+ : dim("■ STOPPED");
905
+ const lines = [renderHeader(["job", "worker"], cols, state.profile), ""];
906
+ lines.push(` ${padEnd(dim("type"), 8)} ${cyan(screen.jobType)} ${dim("status")} ${statusStr}`);
907
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
908
+ const { activated, completed, failed } = screen.stats;
909
+ lines.push(` ${dim("activated")} ${bold(String(activated))} ${dim("completed")} ${completed > 0 ? green(String(completed)) : dim("0")} ${dim("failed")} ${failed > 0 ? red(String(failed)) : dim("0")}`);
910
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
911
+ lines.push("");
912
+ if (screen.log.length === 0) {
913
+ lines.push(` ${dim("Waiting for jobs…")}`);
914
+ }
915
+ else {
916
+ const logLines = screen.log.map((e) => {
917
+ const ts = dim(e.ts);
918
+ const msg = e.level === "ok"
919
+ ? `${green("✓")} ${e.text}`
920
+ : e.level === "err"
921
+ ? `${red("✗")} ${e.text}`
922
+ : ` ${dim(e.text)}`;
923
+ return ` ${ts} ${msg}`;
924
+ });
925
+ const visible = logLines.slice(screen.scroll, screen.scroll + viewH);
926
+ for (const l of visible)
927
+ lines.push(fit(l, cols - 2));
928
+ if (screen.log.length > viewH) {
929
+ const hi = Math.min(screen.scroll + viewH, screen.log.length);
930
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${screen.log.length}`)}`);
931
+ }
932
+ }
933
+ const isRunning = screen.status === "running" || screen.status === "starting";
934
+ const autoHint = ` ${screen.autoScroll ? cyan("a") : dim("a")} ${dim("auto")}`;
935
+ const navHint = isRunning
936
+ ? ` ${cyan("s")} ${dim("stop")}`
937
+ : ` ${cyan("esc")} back ${cyan("m")} main menu`;
938
+ lines.push(`\n ${dim("↑↓")} scroll${autoHint}${navHint} ${cyan("q")} quit`);
939
+ return lines;
940
+ }
941
+ function renderJsonEditor(state, screen) {
942
+ const { cols, rows } = termSize();
943
+ // Reserve extra row for field hint line
944
+ const viewH = Math.max(3, rows - 10);
945
+ const keyW = Math.max(14, Math.min(28, Math.floor((cols - 12) * 0.38)));
946
+ const valW = Math.max(16, cols - keyW - 12);
947
+ const lines = [
948
+ renderHeader([screen.group.name, screen.cmd.name, `--${screen.fieldLabel}`], cols, state.profile),
949
+ "",
950
+ ];
951
+ lines.push(` ${padEnd(dim("KEY"), keyW + 4)}${dim("VALUE")}`);
952
+ lines.push(` ${dim("─".repeat(cols - 4))}`);
953
+ // entries + add-row
954
+ const rowCount = screen.entries.length + 1;
955
+ const visible = Math.min(viewH, rowCount);
956
+ for (let vi = 0; vi < visible; vi++) {
957
+ const absIdx = screen.scroll + vi;
958
+ const isCursor = absIdx === screen.cursor;
959
+ const marker = isCursor ? cyan("▶") : " ";
960
+ if (absIdx === screen.entries.length) {
961
+ // "add" row
962
+ const label = `${isCursor ? cyan("+ add field") : dim("+ add field")}`;
963
+ lines.push(isCursor ? inv(` ${marker} ${label}`.padEnd(cols - 1)) : ` ${marker} ${label}`);
964
+ continue;
965
+ }
966
+ const entry = screen.entries[absIdx];
967
+ if (!entry)
968
+ continue;
969
+ const spec = getFieldSpec(entry.key, screen.fieldSpecs);
970
+ const isKnown = spec !== undefined;
971
+ const hasEnum = spec?.enum && spec.enum.length > 0;
972
+ const editKey = isCursor && screen.col === "key" && screen.editing;
973
+ const editVal = isCursor && screen.col === "val" && screen.editing;
974
+ const keyStr = renderText(entry.key, entry.keyCursor, keyW - (isKnown ? 0 : 0), editKey);
975
+ // For known enum fields in nav mode, show cycling hint instead of raw value
976
+ const valDisplay = !editVal && hasEnum && entry.val
977
+ ? `${entry.val} ${dim("↑↓")}`
978
+ : !editVal && hasEnum && !entry.val
979
+ ? dim("<pick ↑↓>")
980
+ : renderText(entry.val, entry.valCursor, valW - 4, editVal);
981
+ const valStr = valDisplay;
982
+ const keyPart = isCursor && screen.col === "key" && !editKey
983
+ ? cyan(padEnd(keyStr, keyW))
984
+ : isKnown
985
+ ? padEnd(keyStr, keyW)
986
+ : dim(padEnd(keyStr, keyW));
987
+ const valPart = isCursor && screen.col === "val" && !editVal
988
+ ? cyan(padEnd(valStr, valW))
989
+ : padEnd(valStr, valW);
990
+ const line = ` ${marker} ${keyPart} ${valPart}`;
991
+ lines.push(isCursor && !screen.editing ? inv(line.padEnd(cols - 1)) : line);
992
+ }
993
+ lines.push("");
994
+ // Field hint line: show description/type for the row under the cursor
995
+ const cursorEntry = screen.entries[screen.cursor];
996
+ const cursorSpec = cursorEntry ? getFieldSpec(cursorEntry.key, screen.fieldSpecs) : undefined;
997
+ if (cursorSpec) {
998
+ const req = cursorSpec.required ? red("required") : dim("optional");
999
+ const typeStr = cursorSpec.enum
1000
+ ? `enum(${cursorSpec.enum.slice(0, 3).join("|")}${cursorSpec.enum.length > 3 ? "…" : ""})`
1001
+ : cursorSpec.type;
1002
+ const desc = cursorSpec.description ? ` — ${fit(cursorSpec.description, 40)}` : "";
1003
+ lines.push(` ${dim(typeStr)} ${req}${desc}`);
1004
+ }
1005
+ else {
1006
+ lines.push("");
1007
+ }
1008
+ if (rowCount > viewH) {
1009
+ const hi = Math.min(screen.scroll + viewH, rowCount);
1010
+ lines.push(` ${dim(`${screen.scroll + 1}–${hi} of ${rowCount}`)}`);
1011
+ }
1012
+ if (screen.error) {
1013
+ lines.push(` ${red("error:")} ${screen.error}`);
1014
+ }
1015
+ else if (screen.editing) {
1016
+ const cursorEntryEditing = screen.entries[screen.cursor];
1017
+ const editSpec = screen.col === "val" && cursorEntryEditing
1018
+ ? getFieldSpec(cursorEntryEditing.key, screen.fieldSpecs)
1019
+ : undefined;
1020
+ const editHint = editSpec?.enum
1021
+ ? `${dim("↑↓")} pick value ${cyan("enter")} confirm ${cyan("esc")} cancel`
1022
+ : `${dim("←→")} cursor ${cyan("tab")} switch col ${cyan("enter")} confirm ${cyan("esc")} cancel`;
1023
+ lines.push(` ${editHint}`);
1024
+ }
1025
+ else {
1026
+ lines.push(` ${dim("↑↓")} navigate ${cyan("tab")} switch col ${cyan("enter")} edit ${cyan("a")} add ${cyan("d")} del ${cyan("esc")} save ${cyan("q")} quit`);
1027
+ }
1028
+ return lines;
1029
+ }
1030
+ // ─── Worker view helpers ──────────────────────────────────────────────────────
1031
+ function workerLogH() {
1032
+ return Math.max(3, (process.stdout.rows ?? 24) - 13);
1033
+ }
1034
+ function addWorkerLog(ws, level, text) {
1035
+ const now = new Date();
1036
+ const ts = [now.getHours(), now.getMinutes(), now.getSeconds()]
1037
+ .map((n) => String(n).padStart(2, "0"))
1038
+ .join(":");
1039
+ ws.log.push({ ts, level, text });
1040
+ if (ws.log.length > 500)
1041
+ ws.log.shift();
1042
+ if (ws.autoScroll)
1043
+ ws.scroll = Math.max(0, ws.log.length - workerLogH());
1044
+ }
1045
+ function stopWorkerScreen(ws) {
1046
+ if (ws._timer !== null) {
1047
+ clearInterval(ws._timer);
1048
+ ws._timer = null;
1049
+ }
1050
+ if (ws._stop) {
1051
+ ws._stop();
1052
+ ws._stop = null;
1053
+ }
1054
+ }
1055
+ async function runWorkerLoop(ws, state, variables, jobTimeout, maxJobs) {
1056
+ let client;
1057
+ try {
1058
+ client = await state.getClient();
1059
+ }
1060
+ catch (err) {
1061
+ addWorkerLog(ws, "err", `Client error: ${err instanceof Error ? err.message : String(err)}`);
1062
+ ws.status = "stopped";
1063
+ stopWorkerScreen(ws);
1064
+ return;
1065
+ }
1066
+ let running = true;
1067
+ ws._stop = () => {
1068
+ running = false;
1069
+ ws.status = "stopping";
1070
+ ws._stop = null;
1071
+ };
1072
+ ws.status = "running";
1073
+ addWorkerLog(ws, "info", `Worker started for type "${ws.jobType}"`);
1074
+ addWorkerLog(ws, "info", `Returning: ${JSON.stringify(variables)}`);
1075
+ while (running) {
1076
+ let result;
1077
+ try {
1078
+ result = (await client.job.activateJobs({
1079
+ type: ws.jobType,
1080
+ worker: "casen-worker",
1081
+ timeout: jobTimeout,
1082
+ maxJobsToActivate: maxJobs,
1083
+ requestTimeout: 20000,
1084
+ }));
1085
+ }
1086
+ catch (err) {
1087
+ if (!running)
1088
+ break;
1089
+ addWorkerLog(ws, "err", `Poll error: ${err instanceof Error ? err.message : String(err)} — retry in 5s`);
1090
+ await new Promise((r) => setTimeout(r, 5000));
1091
+ continue;
1092
+ }
1093
+ const jobs = result?.jobs ?? [];
1094
+ for (const job of jobs) {
1095
+ if (!running)
1096
+ break;
1097
+ ws.stats.activated++;
1098
+ addWorkerLog(ws, "info", `Activated ${job.jobKey} process=${job.processDefinitionId} element=${job.elementId}`);
1099
+ try {
1100
+ await client.job.completeJob(job.jobKey, { variables });
1101
+ ws.stats.completed++;
1102
+ addWorkerLog(ws, "ok", `Completed ${job.jobKey}`);
1103
+ }
1104
+ catch (err) {
1105
+ ws.stats.failed++;
1106
+ addWorkerLog(ws, "err", `Failed to complete ${job.jobKey}: ${err instanceof Error ? err.message : String(err)}`);
1107
+ }
1108
+ }
1109
+ if (jobs.length > 0)
1110
+ await new Promise((r) => setTimeout(r, 100));
1111
+ }
1112
+ ws.status = "stopped";
1113
+ addWorkerLog(ws, "info", `Stopped. Activated: ${ws.stats.activated} Completed: ${ws.stats.completed} Failed: ${ws.stats.failed}`);
1114
+ stopWorkerScreen(ws);
1115
+ }
1116
+ function launchWorkerView(inputScreen, state) {
1117
+ const typeArg = inputScreen.fields.find((f) => f.kind === "arg");
1118
+ const jobType = typeArg?.value?.trim() ?? "";
1119
+ if (!jobType) {
1120
+ inputScreen.error = '"type" is required';
1121
+ return;
1122
+ }
1123
+ let variables = { result: "sample-value" };
1124
+ const varField = inputScreen.fields.find((f) => f.label === "--variables");
1125
+ if (varField?.value) {
1126
+ try {
1127
+ const parsed = JSON.parse(varField.value);
1128
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1129
+ variables = parsed;
1130
+ }
1131
+ }
1132
+ catch {
1133
+ // use default
1134
+ }
1135
+ }
1136
+ const timeoutField = inputScreen.fields.find((f) => f.label === "--timeout");
1137
+ const workerTimeout = timeoutField?.value ? Number(timeoutField.value) : 30000;
1138
+ const maxJobsField = inputScreen.fields.find((f) => f.label === "--max-jobs");
1139
+ const maxJobs = maxJobsField?.value ? Number(maxJobsField.value) : 32;
1140
+ const ws = {
1141
+ kind: "worker",
1142
+ group: inputScreen.group,
1143
+ cmd: inputScreen.cmd,
1144
+ jobType,
1145
+ status: "starting",
1146
+ stats: { activated: 0, completed: 0, failed: 0 },
1147
+ log: [],
1148
+ scroll: 0,
1149
+ autoScroll: true,
1150
+ _stop: null,
1151
+ _timer: null,
1152
+ };
1153
+ state.stack.push(ws);
1154
+ render(state);
1155
+ // Re-render at 200 ms so live updates appear without waiting for key presses
1156
+ ws._timer = setInterval(() => {
1157
+ if (!state.quitting && state.stack.includes(ws)) {
1158
+ render(state);
1159
+ }
1160
+ else {
1161
+ if (ws._timer !== null) {
1162
+ clearInterval(ws._timer);
1163
+ ws._timer = null;
1164
+ }
1165
+ }
1166
+ }, 200);
1167
+ // Fire-and-forget: the loop writes to ws state; the timer re-renders
1168
+ runWorkerLoop(ws, state, variables, workerTimeout, maxJobs).catch((err) => {
1169
+ addWorkerLog(ws, "err", `Unexpected: ${err instanceof Error ? err.message : String(err)}`);
1170
+ ws.status = "stopped";
1171
+ stopWorkerScreen(ws);
1172
+ });
1173
+ }
1174
+ function render(state) {
1175
+ if (state.quitting)
1176
+ return;
1177
+ const screen = state.stack[state.stack.length - 1];
1178
+ if (!screen)
1179
+ return;
1180
+ let lines;
1181
+ switch (screen.kind) {
1182
+ case "main":
1183
+ lines = renderMain(state, screen);
1184
+ break;
1185
+ case "commands":
1186
+ lines = renderCommands(state, screen);
1187
+ break;
1188
+ case "input":
1189
+ lines = renderInput(state, screen);
1190
+ break;
1191
+ case "results":
1192
+ lines = renderResults(state, screen);
1193
+ break;
1194
+ case "detail":
1195
+ lines = renderDetail(state, screen);
1196
+ break;
1197
+ case "followup":
1198
+ lines = renderFollowup(state, screen);
1199
+ break;
1200
+ case "profile":
1201
+ lines = renderProfile(state, screen);
1202
+ break;
1203
+ case "settings":
1204
+ lines = renderSettings(state, screen);
1205
+ break;
1206
+ case "audit-log":
1207
+ lines = renderAuditLog(state, screen);
1208
+ break;
1209
+ case "worker":
1210
+ lines = renderWorker(state, screen);
1211
+ break;
1212
+ case "json-editor":
1213
+ lines = renderJsonEditor(state, screen);
1214
+ break;
1215
+ }
1216
+ process.stdout.write(`${CLEAR}${lines.join("\n")}\n`);
1217
+ }
1218
+ // ─── Key handlers ─────────────────────────────────────────────────────────────
1219
+ function handleMainKey(key, screen, state, done) {
1220
+ const { rows } = termSize();
1221
+ const searching = screen.search.length > 0;
1222
+ const viewH = Math.max(3, rows - (searching ? 9 : 7));
1223
+ const groups = filterGroups(state.groups, screen.search);
1224
+ // Backspace removes last search char
1225
+ if (key === "\x7f" || key === "\x08") {
1226
+ screen.search = screen.search.slice(0, -1);
1227
+ screen.cursor = 0;
1228
+ screen.scroll = 0;
1229
+ render(state);
1230
+ return;
1231
+ }
1232
+ // Ctrl+C quits unconditionally
1233
+ if (key === "\x03") {
1234
+ done();
1235
+ return;
1236
+ }
1237
+ // ESC clears search; quits only when search is already empty
1238
+ if (key === "\x1b") {
1239
+ if (searching) {
1240
+ screen.search = "";
1241
+ screen.cursor = 0;
1242
+ screen.scroll = 0;
1243
+ }
1244
+ else {
1245
+ done();
1246
+ return;
1247
+ }
1248
+ render(state);
1249
+ return;
1250
+ }
1251
+ switch (key) {
1252
+ case "\x1b[A": // up
1253
+ if (screen.cursor > 0) {
1254
+ screen.cursor--;
1255
+ if (screen.cursor < screen.scroll)
1256
+ screen.scroll--;
1257
+ }
1258
+ break;
1259
+ case "\x1b[B": // down
1260
+ if (screen.cursor < groups.length - 1) {
1261
+ screen.cursor++;
1262
+ if (screen.cursor >= screen.scroll + viewH)
1263
+ screen.scroll++;
1264
+ }
1265
+ break;
1266
+ case "\x1b[5~": // page up
1267
+ screen.cursor = Math.max(0, screen.cursor - viewH);
1268
+ screen.scroll = Math.max(0, screen.scroll - viewH);
1269
+ break;
1270
+ case "\x1b[6~": // page down
1271
+ screen.cursor = Math.min(groups.length - 1, screen.cursor + viewH);
1272
+ screen.scroll = Math.min(Math.max(0, groups.length - viewH), screen.scroll + viewH);
1273
+ break;
1274
+ case "\r":
1275
+ case "\n": {
1276
+ const group = groups[screen.cursor];
1277
+ if (group?.name === "settings") {
1278
+ state.stack.push({
1279
+ kind: "settings",
1280
+ cursor: 0,
1281
+ editing: false,
1282
+ editValue: "",
1283
+ message: "",
1284
+ });
1285
+ }
1286
+ else if (group) {
1287
+ state.stack.push({ kind: "commands", group, cursor: 0, search: "" });
1288
+ }
1289
+ break;
1290
+ }
1291
+ default:
1292
+ // Any printable char starts/extends search
1293
+ if (key.length === 1 && key >= " ") {
1294
+ screen.search += key;
1295
+ screen.cursor = 0;
1296
+ screen.scroll = 0;
1297
+ }
1298
+ }
1299
+ render(state);
1300
+ }
1301
+ function handleCommandsKey(key, screen, state, done) {
1302
+ const searching = screen.search.length > 0;
1303
+ const cmds = filterCommands(screen.group.commands, screen.search);
1304
+ // Backspace removes last search char
1305
+ if (key === "\x7f" || key === "\x08") {
1306
+ screen.search = screen.search.slice(0, -1);
1307
+ screen.cursor = 0;
1308
+ render(state);
1309
+ return;
1310
+ }
1311
+ // Ctrl+C quits unconditionally
1312
+ if (key === "\x03") {
1313
+ done();
1314
+ return;
1315
+ }
1316
+ // ESC clears search; goes back when search already empty
1317
+ if (key === "\x1b") {
1318
+ if (searching) {
1319
+ screen.search = "";
1320
+ screen.cursor = 0;
1321
+ }
1322
+ else {
1323
+ state.stack.pop();
1324
+ }
1325
+ render(state);
1326
+ return;
1327
+ }
1328
+ switch (key) {
1329
+ case "\x1b[A":
1330
+ if (screen.cursor > 0)
1331
+ screen.cursor--;
1332
+ break;
1333
+ case "\x1b[B":
1334
+ if (screen.cursor < cmds.length - 1)
1335
+ screen.cursor++;
1336
+ break;
1337
+ case "\r":
1338
+ case "\n": {
1339
+ const cmd = cmds[screen.cursor];
1340
+ if (cmd) {
1341
+ state.stack.push({
1342
+ kind: "input",
1343
+ group: screen.group,
1344
+ cmd,
1345
+ fields: buildFields(cmd),
1346
+ cursor: 0,
1347
+ scroll: 0,
1348
+ editing: false,
1349
+ error: "",
1350
+ running: false,
1351
+ });
1352
+ }
1353
+ break;
1354
+ }
1355
+ default:
1356
+ if (key === "m" || key === "M") {
1357
+ if (!searching) {
1358
+ popToMain(state);
1359
+ break;
1360
+ }
1361
+ }
1362
+ // Any printable char starts/extends search
1363
+ if (key.length === 1 && key >= " ") {
1364
+ screen.search += key;
1365
+ screen.cursor = 0;
1366
+ }
1367
+ }
1368
+ render(state);
1369
+ }
1370
+ async function executeCommand(screen, state) {
1371
+ for (const f of screen.fields) {
1372
+ if (f.required && !f.value) {
1373
+ screen.error = `"${f.label}" is required`;
1374
+ return;
1375
+ }
1376
+ }
1377
+ screen.running = true;
1378
+ screen.error = "";
1379
+ render(state);
1380
+ const { writer, get } = makeCapturingWriter();
1381
+ // Wrap client factories to capture the last raw HTTP response
1382
+ let rawCapture = null;
1383
+ const getClient = async () => {
1384
+ const client = await state.getClient();
1385
+ client.on("rawResponse", (evt) => {
1386
+ rawCapture = evt;
1387
+ });
1388
+ return client;
1389
+ };
1390
+ const getAdminClient = async () => {
1391
+ const client = await state.getAdminClient();
1392
+ client.on("rawResponse", (evt) => {
1393
+ rawCapture = evt;
1394
+ });
1395
+ return client;
1396
+ };
1397
+ const ctx = buildContext(screen.cmd, screen.fields, writer, getClient, getAdminClient);
1398
+ const SECRET_FLAG_RE = /secret|password|token/i;
1399
+ const auditFlags = {};
1400
+ for (const [k, v] of Object.entries(ctx.flags)) {
1401
+ auditFlags[k] = SECRET_FLAG_RE.test(k) ? "***" : v;
1402
+ }
1403
+ try {
1404
+ await screen.cmd.run(ctx);
1405
+ appendAuditEntry(state.profile || "default", {
1406
+ group: screen.group.name,
1407
+ command: screen.cmd.name,
1408
+ positional: ctx.positional,
1409
+ flags: auditFlags,
1410
+ status: "ok",
1411
+ });
1412
+ const output = get();
1413
+ // Try to generate ASCII art for BPMN XML output
1414
+ if (output.type === "messages" && output.lines.length > 0) {
1415
+ const xml = output.lines.join("\n");
1416
+ if (xml.includes("<?xml") && (xml.includes("bpmn:") || xml.includes("definitions"))) {
1417
+ try {
1418
+ const ascii = renderBpmnAscii(xml);
1419
+ output.altLines = ascii.split("\n");
1420
+ }
1421
+ catch {
1422
+ // ASCII rendering failed — no alt view
1423
+ }
1424
+ }
1425
+ }
1426
+ state.stack.push({
1427
+ kind: "results",
1428
+ group: screen.group,
1429
+ cmd: screen.cmd,
1430
+ output,
1431
+ raw: rawCapture,
1432
+ rawView: false,
1433
+ curlView: false,
1434
+ altView: false,
1435
+ cursor: 0,
1436
+ scroll: 0,
1437
+ });
1438
+ }
1439
+ catch (err) {
1440
+ const msg = err instanceof Error ? err.message : String(err);
1441
+ appendAuditEntry(state.profile || "default", {
1442
+ group: screen.group.name,
1443
+ command: screen.cmd.name,
1444
+ positional: ctx.positional,
1445
+ flags: auditFlags,
1446
+ status: "error",
1447
+ error: msg,
1448
+ });
1449
+ screen.error = msg;
1450
+ }
1451
+ finally {
1452
+ screen.running = false;
1453
+ }
1454
+ }
1455
+ async function handleInputKey(key, screen, state, done) {
1456
+ const { rows } = termSize();
1457
+ const viewH = Math.max(3, rows - 9);
1458
+ const field = screen.fields[screen.cursor];
1459
+ // ── Edit mode ────────────────────────────────────────────────────────────
1460
+ if (screen.editing && field && field.kind !== "run") {
1461
+ const enumVals = getEnum(field);
1462
+ const presets = getPresets(field);
1463
+ // ── Enum field: only cycle, no free text ──────────────────────────────
1464
+ if (enumVals) {
1465
+ const idx = enumVals.indexOf(field.value);
1466
+ switch (key) {
1467
+ case "\x1b[A": // up — cycle backward
1468
+ field.value = enumVals[idx <= 0 ? enumVals.length - 1 : idx - 1] ?? field.value;
1469
+ break;
1470
+ case "\x1b[B": // down — cycle forward
1471
+ field.value = enumVals[idx < 0 || idx >= enumVals.length - 1 ? 0 : idx + 1] ?? field.value;
1472
+ break;
1473
+ case "\r":
1474
+ case "\n":
1475
+ screen.editing = false;
1476
+ if (screen.cursor < screen.fields.length - 1) {
1477
+ screen.cursor++;
1478
+ if (screen.cursor >= screen.scroll + viewH)
1479
+ screen.scroll++;
1480
+ }
1481
+ break;
1482
+ case "\x1b":
1483
+ screen.editing = false;
1484
+ break;
1485
+ }
1486
+ render(state);
1487
+ return;
1488
+ }
1489
+ // ── Preset number field: ↑↓ cycle presets, free text still works ─────
1490
+ if (presets) {
1491
+ if (key === "\x1b[A") {
1492
+ // up — previous preset (smaller)
1493
+ const cur = Number(field.value);
1494
+ const prev = [...presets].reverse().find((p) => p < cur) ?? presets[presets.length - 1];
1495
+ if (prev !== undefined) {
1496
+ field.value = String(prev);
1497
+ field.cursor = field.value.length;
1498
+ }
1499
+ render(state);
1500
+ return;
1501
+ }
1502
+ if (key === "\x1b[B") {
1503
+ // down — next preset (larger)
1504
+ const cur = Number(field.value);
1505
+ const next = presets.find((p) => p > cur) ?? presets[0];
1506
+ if (next !== undefined) {
1507
+ field.value = String(next);
1508
+ field.cursor = field.value.length;
1509
+ }
1510
+ render(state);
1511
+ return;
1512
+ }
1513
+ // Fall through to normal text editing for all other keys
1514
+ }
1515
+ // ── Normal text editing ───────────────────────────────────────────────
1516
+ switch (key) {
1517
+ case "\r":
1518
+ case "\n":
1519
+ screen.editing = false;
1520
+ if (screen.cursor < screen.fields.length - 1) {
1521
+ screen.cursor++;
1522
+ if (screen.cursor >= screen.scroll + viewH)
1523
+ screen.scroll++;
1524
+ }
1525
+ break;
1526
+ case "\x1b":
1527
+ screen.editing = false;
1528
+ break;
1529
+ case "\x1b[D": // left
1530
+ if (field.cursor > 0)
1531
+ field.cursor--;
1532
+ break;
1533
+ case "\x1b[C": // right
1534
+ if (field.cursor < field.value.length)
1535
+ field.cursor++;
1536
+ break;
1537
+ case "\x7f": // backspace
1538
+ if (field.cursor > 0) {
1539
+ field.value = field.value.slice(0, field.cursor - 1) + field.value.slice(field.cursor);
1540
+ field.cursor--;
1541
+ }
1542
+ break;
1543
+ case "\x1b[3~": // delete
1544
+ field.value = field.value.slice(0, field.cursor) + field.value.slice(field.cursor + 1);
1545
+ break;
1546
+ case "\x01": // Ctrl+A / Home
1547
+ case "\x1b[H":
1548
+ field.cursor = 0;
1549
+ break;
1550
+ case "\x05": // Ctrl+E / End
1551
+ case "\x1b[F":
1552
+ field.cursor = field.value.length;
1553
+ break;
1554
+ case "\x0b": // Ctrl+K — clear to end
1555
+ field.value = field.value.slice(0, field.cursor);
1556
+ break;
1557
+ case "\x15": // Ctrl+U — clear line
1558
+ field.value = "";
1559
+ field.cursor = 0;
1560
+ break;
1561
+ default: {
1562
+ // Filter printable chars to support both single keypresses and pasted text
1563
+ const printable = [...key].filter((ch) => ch >= " ").join("");
1564
+ if (printable) {
1565
+ field.value =
1566
+ field.value.slice(0, field.cursor) + printable + field.value.slice(field.cursor);
1567
+ field.cursor += printable.length;
1568
+ }
1569
+ }
1570
+ }
1571
+ render(state);
1572
+ return;
1573
+ }
1574
+ // ── Navigation mode ───────────────────────────────────────────────────────
1575
+ switch (key) {
1576
+ case "\x1b[A": // up
1577
+ if (screen.cursor > 0) {
1578
+ screen.cursor--;
1579
+ if (screen.cursor < screen.scroll)
1580
+ screen.scroll--;
1581
+ }
1582
+ break;
1583
+ case "\x1b[B": // down
1584
+ if (screen.cursor < screen.fields.length - 1) {
1585
+ screen.cursor++;
1586
+ if (screen.cursor >= screen.scroll + viewH)
1587
+ screen.scroll++;
1588
+ }
1589
+ break;
1590
+ case "\r":
1591
+ case "\n":
1592
+ if (!field)
1593
+ break;
1594
+ if (field.kind === "run") {
1595
+ if (screen.cmd.name === "worker") {
1596
+ launchWorkerView(screen, state);
1597
+ }
1598
+ else {
1599
+ await executeCommand(screen, state);
1600
+ }
1601
+ }
1602
+ else if (isJsonField(field)) {
1603
+ // Open the key-value JSON editor
1604
+ const jsonFieldSpecs = field.kind === "flag" ? field.flagSpec?.fields : undefined;
1605
+ state.stack.push({
1606
+ kind: "json-editor",
1607
+ group: screen.group,
1608
+ cmd: screen.cmd,
1609
+ fieldIndex: screen.cursor,
1610
+ fieldLabel: field.label,
1611
+ entries: buildInitialEntries(field.value, jsonFieldSpecs),
1612
+ fieldSpecs: jsonFieldSpecs,
1613
+ cursor: 0,
1614
+ col: "val",
1615
+ editing: false,
1616
+ scroll: 0,
1617
+ error: "",
1618
+ });
1619
+ }
1620
+ else {
1621
+ screen.editing = true;
1622
+ // For enum fields, set to first value if currently empty
1623
+ const enumVals = getEnum(field);
1624
+ if (enumVals && !field.value)
1625
+ field.value = enumVals[0] ?? "";
1626
+ field.cursor = field.value.length;
1627
+ }
1628
+ break;
1629
+ case "\x1b[C": // right arrow also opens JSON editor
1630
+ if (field && isJsonField(field)) {
1631
+ const jsonFieldSpecs2 = field.kind === "flag" ? field.flagSpec?.fields : undefined;
1632
+ state.stack.push({
1633
+ kind: "json-editor",
1634
+ group: screen.group,
1635
+ cmd: screen.cmd,
1636
+ fieldIndex: screen.cursor,
1637
+ fieldLabel: field.label,
1638
+ entries: buildInitialEntries(field.value, jsonFieldSpecs2),
1639
+ fieldSpecs: jsonFieldSpecs2,
1640
+ cursor: 0,
1641
+ col: "val",
1642
+ editing: false,
1643
+ scroll: 0,
1644
+ error: "",
1645
+ });
1646
+ }
1647
+ break;
1648
+ case "m":
1649
+ case "M":
1650
+ popToMain(state);
1651
+ break;
1652
+ case "\x1b":
1653
+ state.stack.pop();
1654
+ break;
1655
+ case "q":
1656
+ case "Q":
1657
+ done();
1658
+ return;
1659
+ }
1660
+ render(state);
1661
+ }
1662
+ function handleResultsKey(key, screen, state, done) {
1663
+ const { rows } = termSize();
1664
+ const viewH = Math.max(3, rows - 10);
1665
+ // r/R toggles raw view; u/U toggles curl view — mutually exclusive
1666
+ if (key === "r" || key === "R") {
1667
+ screen.rawView = !screen.rawView;
1668
+ if (screen.rawView)
1669
+ screen.curlView = false;
1670
+ screen.scroll = 0;
1671
+ render(state);
1672
+ return;
1673
+ }
1674
+ if (key === "u" || key === "U") {
1675
+ screen.curlView = !screen.curlView;
1676
+ if (screen.curlView)
1677
+ screen.rawView = false;
1678
+ screen.scroll = 0;
1679
+ render(state);
1680
+ return;
1681
+ }
1682
+ if (screen.curlView) {
1683
+ if (key === "y" || key === "Y") {
1684
+ if (screen.raw) {
1685
+ const { cmd } = buildCurlCmd(screen.raw);
1686
+ copyToClipboard(cmd);
1687
+ }
1688
+ }
1689
+ else if (key === "e" || key === "E") {
1690
+ if (screen.raw) {
1691
+ const { token } = buildCurlCmd(screen.raw);
1692
+ if (token)
1693
+ copyToClipboard(`export ${CURL_TOKEN_VAR}="${token}"`);
1694
+ }
1695
+ }
1696
+ else if (key === "\x1b[A") {
1697
+ if (screen.scroll > 0)
1698
+ screen.scroll--;
1699
+ }
1700
+ else if (key === "\x1b[B") {
1701
+ screen.scroll++;
1702
+ }
1703
+ else if (key === "m" || key === "M") {
1704
+ popToMain(state);
1705
+ }
1706
+ else if (key === "\x1b") {
1707
+ state.stack.pop();
1708
+ }
1709
+ else if (key === "q" || key === "Q") {
1710
+ done();
1711
+ return;
1712
+ }
1713
+ render(state);
1714
+ return;
1715
+ }
1716
+ if (screen.rawView) {
1717
+ switch (key) {
1718
+ case "\x1b[A":
1719
+ if (screen.scroll > 0)
1720
+ screen.scroll--;
1721
+ break;
1722
+ case "\x1b[B":
1723
+ screen.scroll++;
1724
+ break;
1725
+ case "m":
1726
+ case "M":
1727
+ popToMain(state);
1728
+ break;
1729
+ case "\x1b":
1730
+ state.stack.pop();
1731
+ break;
1732
+ case "q":
1733
+ case "Q":
1734
+ done();
1735
+ return;
1736
+ }
1737
+ render(state);
1738
+ return;
1739
+ }
1740
+ if (screen.output.type !== "list") {
1741
+ if (screen.output.type === "item") {
1742
+ const entries = Object.entries(flattenObj(screen.output.data));
1743
+ const expandArray = () => {
1744
+ const entry = entries[screen.cursor];
1745
+ if (entry) {
1746
+ const [fieldKey, v] = entry;
1747
+ if (v instanceof ArrayValue) {
1748
+ state.stack.push({
1749
+ kind: "detail",
1750
+ group: screen.group,
1751
+ item: v.items,
1752
+ label: `${fieldKey} (${v.items.length})`,
1753
+ cursor: 0,
1754
+ scroll: 0,
1755
+ });
1756
+ }
1757
+ }
1758
+ };
1759
+ if (key === "\x1b[A" && screen.cursor > 0)
1760
+ screen.cursor--;
1761
+ else if (key === "\x1b[B" && screen.cursor < entries.length - 1)
1762
+ screen.cursor++;
1763
+ else if (key === " " || key === "\x1b[C")
1764
+ expandArray();
1765
+ else if (key === "\x1b[D")
1766
+ state.stack.pop();
1767
+ else if (key === "\x1b")
1768
+ state.stack.pop();
1769
+ else if (key === "m" || key === "M")
1770
+ popToMain(state);
1771
+ else if (key === "q" || key === "Q") {
1772
+ done();
1773
+ return;
1774
+ }
1775
+ }
1776
+ else {
1777
+ // messages type — scrollable + horizontal pan (screen.cursor = hOff)
1778
+ const msgLines = screen.altView && screen.output.altLines ? screen.output.altLines : screen.output.lines;
1779
+ switch (key) {
1780
+ case "x":
1781
+ case "X":
1782
+ if (screen.output.altLines) {
1783
+ screen.altView = !screen.altView;
1784
+ screen.scroll = 0;
1785
+ screen.cursor = 0;
1786
+ }
1787
+ break;
1788
+ case "\x1b[A":
1789
+ if (screen.scroll > 0)
1790
+ screen.scroll = Math.max(0, screen.scroll - 3);
1791
+ break;
1792
+ case "\x1b[B":
1793
+ screen.scroll = Math.min(Math.max(0, msgLines.length - viewH), screen.scroll + 3);
1794
+ break;
1795
+ case "\x1b[C":
1796
+ if (!screen.altView)
1797
+ screen.cursor += 3;
1798
+ break;
1799
+ case "\x1b[D":
1800
+ if (!screen.altView)
1801
+ screen.cursor = Math.max(0, screen.cursor - 3);
1802
+ break;
1803
+ case "\x1b[5~":
1804
+ screen.scroll = Math.max(0, screen.scroll - viewH);
1805
+ break;
1806
+ case "\x1b[6~":
1807
+ screen.scroll = Math.min(Math.max(0, msgLines.length - viewH), screen.scroll + viewH);
1808
+ break;
1809
+ default:
1810
+ if (key === "\x1b")
1811
+ state.stack.pop();
1812
+ else if (key === "m" || key === "M")
1813
+ popToMain(state);
1814
+ else if (key === "q" || key === "Q") {
1815
+ done();
1816
+ return;
1817
+ }
1818
+ }
1819
+ }
1820
+ render(state);
1821
+ return;
1822
+ }
1823
+ const len = screen.output.items.length;
1824
+ switch (key) {
1825
+ case "\x1b[A": // up
1826
+ if (screen.cursor > 0) {
1827
+ screen.cursor--;
1828
+ if (screen.cursor < screen.scroll)
1829
+ screen.scroll--;
1830
+ }
1831
+ break;
1832
+ case "\x1b[B": // down
1833
+ if (screen.cursor < len - 1) {
1834
+ screen.cursor++;
1835
+ if (screen.cursor >= screen.scroll + viewH)
1836
+ screen.scroll++;
1837
+ }
1838
+ break;
1839
+ case "\x1b[5~": // page up
1840
+ screen.cursor = Math.max(0, screen.cursor - viewH);
1841
+ screen.scroll = Math.max(0, screen.scroll - viewH);
1842
+ break;
1843
+ case "\x1b[6~": // page down
1844
+ screen.cursor = Math.min(len - 1, screen.cursor + viewH);
1845
+ screen.scroll = Math.min(Math.max(0, len - viewH), screen.scroll + viewH);
1846
+ break;
1847
+ case "\r":
1848
+ case "\n": {
1849
+ const item = screen.output.items[screen.cursor];
1850
+ if (item) {
1851
+ state.stack.push({
1852
+ kind: "detail",
1853
+ group: screen.group,
1854
+ item,
1855
+ label: "detail",
1856
+ cursor: 0,
1857
+ scroll: 0,
1858
+ });
1859
+ }
1860
+ break;
1861
+ }
1862
+ case "f":
1863
+ case "F": {
1864
+ const item = screen.output.items[screen.cursor];
1865
+ if (item && screen.cmd.relations) {
1866
+ const resolved = [];
1867
+ for (const rel of screen.cmd.relations) {
1868
+ const tGroup = state.groups.find((g) => g.name === rel.groupName);
1869
+ if (!tGroup)
1870
+ continue;
1871
+ const tCmd = tGroup.commands.find((c) => c.name === rel.commandName);
1872
+ if (!tCmd)
1873
+ continue;
1874
+ resolved.push({ group: tGroup, cmd: tCmd, params: rel.params });
1875
+ }
1876
+ if (resolved.length > 0) {
1877
+ state.stack.push({
1878
+ kind: "followup",
1879
+ sourceGroup: screen.group,
1880
+ item: item,
1881
+ relations: resolved,
1882
+ cursor: 0,
1883
+ scroll: 0,
1884
+ });
1885
+ }
1886
+ }
1887
+ break;
1888
+ }
1889
+ case "m":
1890
+ case "M":
1891
+ popToMain(state);
1892
+ break;
1893
+ case "\x1b":
1894
+ state.stack.pop();
1895
+ break;
1896
+ case "q":
1897
+ case "Q":
1898
+ done();
1899
+ return;
1900
+ }
1901
+ render(state);
1902
+ }
1903
+ function handleDetailKey(key, screen, state, done) {
1904
+ const { rows } = termSize();
1905
+ const viewH = Math.max(3, rows - 7);
1906
+ const entries = Object.entries(flattenObj(screen.item));
1907
+ const entryCount = entries.length;
1908
+ switch (key) {
1909
+ case "\x1b[A":
1910
+ if (screen.cursor > 0) {
1911
+ screen.cursor--;
1912
+ if (screen.cursor < screen.scroll)
1913
+ screen.scroll--;
1914
+ }
1915
+ break;
1916
+ case "\x1b[B":
1917
+ if (screen.cursor < entryCount - 1) {
1918
+ screen.cursor++;
1919
+ if (screen.cursor >= screen.scroll + viewH)
1920
+ screen.scroll++;
1921
+ }
1922
+ break;
1923
+ case "\x1b[5~":
1924
+ screen.cursor = Math.max(0, screen.cursor - viewH);
1925
+ screen.scroll = Math.max(0, screen.scroll - viewH);
1926
+ break;
1927
+ case "\x1b[6~":
1928
+ screen.cursor = Math.min(entryCount - 1, screen.cursor + viewH);
1929
+ screen.scroll = Math.min(Math.max(0, entryCount - viewH), screen.scroll + viewH);
1930
+ break;
1931
+ case " ":
1932
+ case "\x1b[C": {
1933
+ const entry = entries[screen.cursor];
1934
+ if (entry) {
1935
+ const [fieldKey, v] = entry;
1936
+ if (v instanceof ArrayValue) {
1937
+ state.stack.push({
1938
+ kind: "detail",
1939
+ group: screen.group,
1940
+ item: v.items,
1941
+ label: `${fieldKey} (${v.items.length})`,
1942
+ cursor: 0,
1943
+ scroll: 0,
1944
+ });
1945
+ }
1946
+ }
1947
+ break;
1948
+ }
1949
+ case "\x1b[D":
1950
+ state.stack.pop();
1951
+ break;
1952
+ case "m":
1953
+ case "M":
1954
+ popToMain(state);
1955
+ break;
1956
+ case "\x1b":
1957
+ state.stack.pop();
1958
+ break;
1959
+ case "q":
1960
+ case "Q":
1961
+ done();
1962
+ return;
1963
+ }
1964
+ render(state);
1965
+ }
1966
+ function handleFollowupKey(key, screen, state, done) {
1967
+ const { rows } = termSize();
1968
+ const viewH = Math.max(3, rows - 8);
1969
+ switch (key) {
1970
+ case "\x1b[A":
1971
+ if (screen.cursor > 0) {
1972
+ screen.cursor--;
1973
+ if (screen.cursor < screen.scroll)
1974
+ screen.scroll--;
1975
+ }
1976
+ break;
1977
+ case "\x1b[B":
1978
+ if (screen.cursor < screen.relations.length - 1) {
1979
+ screen.cursor++;
1980
+ if (screen.cursor >= screen.scroll + viewH)
1981
+ screen.scroll++;
1982
+ }
1983
+ break;
1984
+ case "\r":
1985
+ case "\n": {
1986
+ const rel = screen.relations[screen.cursor];
1987
+ if (rel) {
1988
+ const fields = buildFields(rel.cmd);
1989
+ // Pre-fill args from the source item
1990
+ for (const p of rel.params) {
1991
+ const val = String(screen.item[p.field] ?? "");
1992
+ const field = fields.find((f) => f.kind === "arg" && f.label === p.param);
1993
+ if (field) {
1994
+ field.value = val;
1995
+ field.cursor = val.length;
1996
+ }
1997
+ }
1998
+ const allFilled = fields.filter((f) => f.required).every((f) => f.value);
1999
+ const runIdx = fields.findIndex((f) => f.kind === "run");
2000
+ state.stack.push({
2001
+ kind: "input",
2002
+ group: rel.group,
2003
+ cmd: rel.cmd,
2004
+ fields,
2005
+ cursor: allFilled && runIdx >= 0 ? runIdx : 0,
2006
+ scroll: 0,
2007
+ editing: false,
2008
+ error: "",
2009
+ running: false,
2010
+ });
2011
+ }
2012
+ break;
2013
+ }
2014
+ case "m":
2015
+ case "M":
2016
+ popToMain(state);
2017
+ break;
2018
+ case "\x1b":
2019
+ state.stack.pop();
2020
+ break;
2021
+ case "q":
2022
+ case "Q":
2023
+ done();
2024
+ return;
2025
+ }
2026
+ render(state);
2027
+ }
2028
+ function saveJsonEditorToField(screen, state) {
2029
+ const inputScreen = [...state.stack].reverse().find((s) => s.kind === "input");
2030
+ if (inputScreen?.kind === "input") {
2031
+ const field = inputScreen.fields[screen.fieldIndex];
2032
+ if (field) {
2033
+ field.value = entriesToJson(screen.entries);
2034
+ field.cursor = field.value.length;
2035
+ }
2036
+ }
2037
+ }
2038
+ function handleJsonEditorKey(key, screen, state, done) {
2039
+ const { rows } = termSize();
2040
+ const viewH = Math.max(3, rows - 9);
2041
+ const rowCount = screen.entries.length + 1;
2042
+ const isAddRow = screen.cursor === screen.entries.length;
2043
+ const entry = screen.entries[screen.cursor];
2044
+ // ── Edit mode ────────────────────────────────────────────────────────────
2045
+ if (screen.editing && entry) {
2046
+ const activeIsKey = screen.col === "key";
2047
+ const activeText = activeIsKey ? entry.key : entry.val;
2048
+ const activeCursor = activeIsKey ? entry.keyCursor : entry.valCursor;
2049
+ const setTextAndCursor = (text, cur) => {
2050
+ if (activeIsKey) {
2051
+ entry.key = text;
2052
+ entry.keyCursor = cur;
2053
+ }
2054
+ else {
2055
+ entry.val = text;
2056
+ entry.valCursor = cur;
2057
+ }
2058
+ };
2059
+ // Enum cycling for value column of a known enum field
2060
+ const valSpec = !activeIsKey ? getFieldSpec(entry.key, screen.fieldSpecs) : undefined;
2061
+ const enumVals = valSpec?.enum;
2062
+ if (enumVals && enumVals.length > 0 && !activeIsKey) {
2063
+ const curIdx = enumVals.indexOf(entry.val);
2064
+ switch (key) {
2065
+ case "\x1b[A": // up
2066
+ entry.val = enumVals[(curIdx - 1 + enumVals.length) % enumVals.length] ?? "";
2067
+ entry.valCursor = entry.val.length;
2068
+ render(state);
2069
+ return;
2070
+ case "\x1b[B": // down
2071
+ entry.val = enumVals[(curIdx + 1) % enumVals.length] ?? "";
2072
+ entry.valCursor = entry.val.length;
2073
+ render(state);
2074
+ return;
2075
+ case "\r":
2076
+ case "\n":
2077
+ // If no value selected yet, pick first
2078
+ if (!entry.val && enumVals[0]) {
2079
+ entry.val = enumVals[0];
2080
+ entry.valCursor = entry.val.length;
2081
+ }
2082
+ screen.editing = false;
2083
+ screen.col = "key";
2084
+ if (screen.cursor < rowCount - 1) {
2085
+ screen.cursor++;
2086
+ if (screen.cursor >= screen.scroll + viewH)
2087
+ screen.scroll++;
2088
+ }
2089
+ render(state);
2090
+ return;
2091
+ case "\x1b":
2092
+ screen.editing = false;
2093
+ render(state);
2094
+ return;
2095
+ case "\t":
2096
+ screen.col = "key";
2097
+ render(state);
2098
+ return;
2099
+ }
2100
+ }
2101
+ switch (key) {
2102
+ case "\r":
2103
+ case "\n":
2104
+ screen.editing = false;
2105
+ // Advance: key → val, then val → next row key
2106
+ if (activeIsKey) {
2107
+ screen.col = "val";
2108
+ }
2109
+ else {
2110
+ screen.col = "key";
2111
+ if (screen.cursor < rowCount - 1) {
2112
+ screen.cursor++;
2113
+ if (screen.cursor >= screen.scroll + viewH)
2114
+ screen.scroll++;
2115
+ }
2116
+ }
2117
+ break;
2118
+ case "\t": // Tab: switch key↔val
2119
+ screen.col = activeIsKey ? "val" : "key";
2120
+ break;
2121
+ case "\x1b":
2122
+ screen.editing = false;
2123
+ break;
2124
+ case "\x1b[D":
2125
+ if (activeCursor > 0)
2126
+ setTextAndCursor(activeText, activeCursor - 1);
2127
+ break;
2128
+ case "\x1b[C":
2129
+ if (activeCursor < activeText.length)
2130
+ setTextAndCursor(activeText, activeCursor + 1);
2131
+ break;
2132
+ case "\x7f":
2133
+ if (activeCursor > 0) {
2134
+ setTextAndCursor(activeText.slice(0, activeCursor - 1) + activeText.slice(activeCursor), activeCursor - 1);
2135
+ }
2136
+ break;
2137
+ case "\x01":
2138
+ case "\x1b[H":
2139
+ setTextAndCursor(activeText, 0);
2140
+ break;
2141
+ case "\x05":
2142
+ case "\x1b[F":
2143
+ setTextAndCursor(activeText, activeText.length);
2144
+ break;
2145
+ case "\x15":
2146
+ setTextAndCursor("", 0);
2147
+ break;
2148
+ default: {
2149
+ const printable = [...key].filter((ch) => ch >= " ").join("");
2150
+ if (printable) {
2151
+ setTextAndCursor(activeText.slice(0, activeCursor) + printable + activeText.slice(activeCursor), activeCursor + printable.length);
2152
+ }
2153
+ }
2154
+ }
2155
+ render(state);
2156
+ return;
2157
+ }
2158
+ // ── Navigation mode ───────────────────────────────────────────────────────
2159
+ switch (key) {
2160
+ case "\x1b[A": // up
2161
+ if (screen.cursor > 0) {
2162
+ screen.cursor--;
2163
+ if (screen.cursor < screen.scroll)
2164
+ screen.scroll--;
2165
+ }
2166
+ break;
2167
+ case "\x1b[B": // down
2168
+ if (screen.cursor < rowCount - 1) {
2169
+ screen.cursor++;
2170
+ if (screen.cursor >= screen.scroll + viewH)
2171
+ screen.scroll++;
2172
+ }
2173
+ break;
2174
+ case "\t": // Tab: switch key↔val (not on add-row)
2175
+ if (!isAddRow)
2176
+ screen.col = screen.col === "key" ? "val" : "key";
2177
+ break;
2178
+ case "\r":
2179
+ case "\n":
2180
+ if (isAddRow) {
2181
+ // Add a new entry and start editing its key
2182
+ screen.entries.push({ key: "", keyCursor: 0, val: "", valCursor: 0 });
2183
+ screen.cursor = screen.entries.length - 1;
2184
+ screen.col = "key";
2185
+ screen.editing = true;
2186
+ }
2187
+ else {
2188
+ // For known enum fields in val column, seed with first enum value
2189
+ if (screen.col === "val" && entry) {
2190
+ const spec = getFieldSpec(entry.key, screen.fieldSpecs);
2191
+ if (spec?.enum && spec.enum.length > 0 && !entry.val) {
2192
+ entry.val = spec.enum[0] ?? "";
2193
+ entry.valCursor = entry.val.length;
2194
+ }
2195
+ }
2196
+ screen.editing = true;
2197
+ }
2198
+ break;
2199
+ case "a":
2200
+ case "A":
2201
+ // Insert new entry after cursor and start editing
2202
+ screen.entries.splice(screen.cursor + 1, 0, {
2203
+ key: "",
2204
+ keyCursor: 0,
2205
+ val: "",
2206
+ valCursor: 0,
2207
+ });
2208
+ screen.cursor = Math.min(screen.cursor + 1, screen.entries.length - 1);
2209
+ screen.col = "key";
2210
+ screen.editing = true;
2211
+ break;
2212
+ case "d":
2213
+ case "D":
2214
+ if (!isAddRow && screen.entries.length > 0) {
2215
+ screen.entries.splice(screen.cursor, 1);
2216
+ screen.cursor = Math.min(screen.cursor, screen.entries.length);
2217
+ }
2218
+ break;
2219
+ case "\x1b":
2220
+ saveJsonEditorToField(screen, state);
2221
+ state.stack.pop();
2222
+ break;
2223
+ case "q":
2224
+ case "Q":
2225
+ saveJsonEditorToField(screen, state);
2226
+ done();
2227
+ return;
2228
+ }
2229
+ render(state);
2230
+ }
2231
+ function handleProfileKey(key, screen, state, done) {
2232
+ const { rows } = termSize();
2233
+ const viewH = Math.max(3, rows - 7);
2234
+ switch (key) {
2235
+ case "\x1b[A":
2236
+ if (screen.scroll > 0)
2237
+ screen.scroll--;
2238
+ break;
2239
+ case "\x1b[B":
2240
+ if (screen.scroll < Math.max(0, state.profileInfo.length - viewH))
2241
+ screen.scroll++;
2242
+ break;
2243
+ case "p":
2244
+ case "P":
2245
+ case "\x1b":
2246
+ state.stack.pop();
2247
+ break;
2248
+ case "q":
2249
+ case "Q":
2250
+ done();
2251
+ return;
2252
+ }
2253
+ render(state);
2254
+ }
2255
+ function handleSettingsKey(key, screen, state, done) {
2256
+ screen.message = "";
2257
+ if (screen.editing) {
2258
+ if (key === "\r" || key === "\n") {
2259
+ const row = SETTINGS_ROWS[screen.cursor];
2260
+ if (row?.kind === "number") {
2261
+ const n = Number(screen.editValue);
2262
+ if (!Number.isNaN(n) && n >= row.min && n <= row.max) {
2263
+ saveSettings({ [row.key]: Math.floor(n) });
2264
+ screen.message = green(`✓ Saved: ${row.label} = ${Math.floor(n)}`);
2265
+ }
2266
+ else {
2267
+ screen.message = red(`Invalid — must be a number between ${row.min} and ${row.max}`);
2268
+ }
2269
+ }
2270
+ screen.editing = false;
2271
+ screen.editValue = "";
2272
+ }
2273
+ else if (key === "\x1b") {
2274
+ screen.editing = false;
2275
+ screen.editValue = "";
2276
+ }
2277
+ else if (key === "\x7f" || key === "\x08") {
2278
+ screen.editValue = screen.editValue.slice(0, -1);
2279
+ }
2280
+ else if (key >= "0" && key <= "9") {
2281
+ screen.editValue += key;
2282
+ }
2283
+ render(state);
2284
+ return;
2285
+ }
2286
+ switch (key) {
2287
+ case "\x1b[A":
2288
+ if (screen.cursor > 0)
2289
+ screen.cursor--;
2290
+ break;
2291
+ case "\x1b[B":
2292
+ if (screen.cursor < SETTINGS_ROWS.length - 1)
2293
+ screen.cursor++;
2294
+ break;
2295
+ case "\r":
2296
+ case "\n": {
2297
+ const row = SETTINGS_ROWS[screen.cursor];
2298
+ if (row?.kind === "number") {
2299
+ screen.editing = true;
2300
+ screen.editValue = String(getSettings()[row.key]);
2301
+ }
2302
+ else if (row?.kind === "action" && row.id === "show-audit-log") {
2303
+ state.stack.push({ kind: "audit-log", scroll: 0 });
2304
+ }
2305
+ break;
2306
+ }
2307
+ case "\x1b":
2308
+ state.stack.pop();
2309
+ break;
2310
+ case "q":
2311
+ case "Q":
2312
+ done();
2313
+ return;
2314
+ }
2315
+ render(state);
2316
+ }
2317
+ function handleAuditLogKey(key, screen, state, done) {
2318
+ const { rows } = termSize();
2319
+ const viewH = Math.max(3, rows - 8);
2320
+ const total = getAuditLog(state.profile || undefined).length;
2321
+ switch (key) {
2322
+ case "\x1b[A":
2323
+ if (screen.scroll > 0)
2324
+ screen.scroll--;
2325
+ break;
2326
+ case "\x1b[B":
2327
+ if (screen.scroll < Math.max(0, total - viewH))
2328
+ screen.scroll++;
2329
+ break;
2330
+ case "\x1b":
2331
+ state.stack.pop();
2332
+ break;
2333
+ case "q":
2334
+ case "Q":
2335
+ done();
2336
+ return;
2337
+ }
2338
+ render(state);
2339
+ }
2340
+ function handleWorkerKey(key, screen, state, done) {
2341
+ const { rows } = termSize();
2342
+ const viewH = Math.max(3, rows - 13);
2343
+ const isRunning = screen.status === "running" || screen.status === "starting";
2344
+ switch (key) {
2345
+ case "\x1b[A": // up
2346
+ screen.autoScroll = false;
2347
+ if (screen.scroll > 0)
2348
+ screen.scroll--;
2349
+ break;
2350
+ case "\x1b[B": // down
2351
+ if (screen.scroll < Math.max(0, screen.log.length - viewH)) {
2352
+ screen.scroll++;
2353
+ if (screen.scroll >= screen.log.length - viewH)
2354
+ screen.autoScroll = true;
2355
+ }
2356
+ break;
2357
+ case "\x1b[5~": // page up
2358
+ screen.autoScroll = false;
2359
+ screen.scroll = Math.max(0, screen.scroll - viewH);
2360
+ break;
2361
+ case "\x1b[6~": // page down
2362
+ screen.scroll = Math.min(Math.max(0, screen.log.length - viewH), screen.scroll + viewH);
2363
+ break;
2364
+ case "a":
2365
+ case "A":
2366
+ screen.autoScroll = !screen.autoScroll;
2367
+ if (screen.autoScroll)
2368
+ screen.scroll = Math.max(0, screen.log.length - viewH);
2369
+ break;
2370
+ case "s":
2371
+ case "S":
2372
+ if (isRunning && screen._stop)
2373
+ screen._stop();
2374
+ break;
2375
+ case "\x1b": // esc — only go back when stopped
2376
+ if (!isRunning) {
2377
+ stopWorkerScreen(screen);
2378
+ state.stack.pop();
2379
+ }
2380
+ break;
2381
+ case "m":
2382
+ case "M":
2383
+ if (!isRunning) {
2384
+ stopWorkerScreen(screen);
2385
+ popToMain(state);
2386
+ }
2387
+ break;
2388
+ case "q":
2389
+ case "Q":
2390
+ if (isRunning && screen._stop)
2391
+ screen._stop();
2392
+ stopWorkerScreen(screen);
2393
+ done();
2394
+ return;
2395
+ }
2396
+ render(state);
2397
+ }
2398
+ async function handleKey(key, state, done) {
2399
+ if (state.quitting)
2400
+ return;
2401
+ if (key === "\x03") {
2402
+ done();
2403
+ return;
2404
+ }
2405
+ const screen = state.stack[state.stack.length - 1];
2406
+ if (!screen)
2407
+ return;
2408
+ // Global p/P: open profile view (skip when actively editing text)
2409
+ if (key === "p" || key === "P") {
2410
+ const isEditing = (screen.kind === "input" && screen.editing) ||
2411
+ (screen.kind === "json-editor" && screen.editing) ||
2412
+ (screen.kind === "settings" && screen.editing);
2413
+ const isProfile = screen.kind === "profile";
2414
+ if (!isEditing && !isProfile) {
2415
+ state.stack.push({ kind: "profile", scroll: 0 });
2416
+ render(state);
2417
+ return;
2418
+ }
2419
+ }
2420
+ switch (screen.kind) {
2421
+ case "main":
2422
+ handleMainKey(key, screen, state, done);
2423
+ break;
2424
+ case "commands":
2425
+ handleCommandsKey(key, screen, state, done);
2426
+ break;
2427
+ case "input":
2428
+ await handleInputKey(key, screen, state, done);
2429
+ break;
2430
+ case "results":
2431
+ handleResultsKey(key, screen, state, done);
2432
+ break;
2433
+ case "detail":
2434
+ handleDetailKey(key, screen, state, done);
2435
+ break;
2436
+ case "followup":
2437
+ handleFollowupKey(key, screen, state, done);
2438
+ break;
2439
+ case "profile":
2440
+ handleProfileKey(key, screen, state, done);
2441
+ break;
2442
+ case "settings":
2443
+ handleSettingsKey(key, screen, state, done);
2444
+ break;
2445
+ case "audit-log":
2446
+ handleAuditLogKey(key, screen, state, done);
2447
+ break;
2448
+ case "worker":
2449
+ handleWorkerKey(key, screen, state, done);
2450
+ break;
2451
+ case "json-editor":
2452
+ handleJsonEditorKey(key, screen, state, done);
2453
+ break;
2454
+ }
2455
+ }
2456
+ // ─── Shared TUI runner ────────────────────────────────────────────────────────
2457
+ async function startTui(state) {
2458
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
2459
+ // Non-interactive fallback: print group list or group commands
2460
+ const first = state.stack[0];
2461
+ if (first?.kind === "commands") {
2462
+ process.stdout.write(`${first.group.name}: ${first.group.description}\n`);
2463
+ process.stdout.write(`Commands: ${first.group.commands.map((c) => c.name).join(", ")}\n`);
2464
+ }
2465
+ else {
2466
+ for (const g of state.groups) {
2467
+ process.stdout.write(`${g.name.padEnd(24)} ${g.description}\n`);
2468
+ }
2469
+ }
2470
+ return;
2471
+ }
2472
+ process.stdout.write(`${ALT_ON}${HIDE}`);
2473
+ let cleaned = false;
2474
+ const cleanup = () => {
2475
+ if (cleaned)
2476
+ return;
2477
+ cleaned = true;
2478
+ process.stdout.write(`${ALT_OFF}${SHOW}`);
2479
+ if (process.stdin.isTTY)
2480
+ process.stdin.setRawMode(false);
2481
+ process.stdin.pause();
2482
+ };
2483
+ process.on("exit", cleanup);
2484
+ render(state);
2485
+ await new Promise((resolve) => {
2486
+ const done = () => {
2487
+ state.quitting = true;
2488
+ resolve();
2489
+ };
2490
+ process.stdin.setRawMode(true);
2491
+ process.stdin.resume();
2492
+ process.stdin.setEncoding("utf8");
2493
+ let handling = false;
2494
+ process.stdin.on("data", async (key) => {
2495
+ if (handling || state.quitting)
2496
+ return;
2497
+ handling = true;
2498
+ try {
2499
+ await handleKey(key, state, done);
2500
+ }
2501
+ finally {
2502
+ handling = false;
2503
+ }
2504
+ });
2505
+ });
2506
+ cleanup();
2507
+ process.removeListener("exit", cleanup);
2508
+ }
2509
+ /** Open the TUI at the top-level main menu listing all command groups. */
2510
+ export async function runMainTui(groups, getClient, getAdminClient, opts) {
2511
+ return startTui({
2512
+ groups,
2513
+ stack: [{ kind: "main", cursor: 0, scroll: 0, search: "" }],
2514
+ getClient,
2515
+ getAdminClient: getAdminClient ??
2516
+ opts?.getAdminClient ??
2517
+ (() => Promise.reject(new Error("No admin client"))),
2518
+ quitting: false,
2519
+ profile: opts?.profile ?? "",
2520
+ profileInfo: opts?.profileInfo ?? [],
2521
+ });
2522
+ }
2523
+ /**
2524
+ * Open the TUI directly at a specific group's command list.
2525
+ * The main menu is placed at the bottom of the stack so `m` always works.
2526
+ */
2527
+ export async function runGroupTui(group, groups, getClient, getAdminClient, opts) {
2528
+ const cursor = groups.findIndex((g) => g.name === group.name);
2529
+ return startTui({
2530
+ groups,
2531
+ stack: [
2532
+ { kind: "main", cursor: Math.max(0, cursor), scroll: 0, search: "" },
2533
+ { kind: "commands", group, cursor: 0, search: "" },
2534
+ ],
2535
+ getClient,
2536
+ getAdminClient: getAdminClient ??
2537
+ opts?.getAdminClient ??
2538
+ (() => Promise.reject(new Error("No admin client"))),
2539
+ quitting: false,
2540
+ profile: opts?.profile ?? "",
2541
+ profileInfo: opts?.profileInfo ?? [],
2542
+ });
2543
+ }
2544
+ //# sourceMappingURL=tui.js.map