singleton-pipeline 0.4.0-beta.13 → 0.4.0-beta.14

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 (77) hide show
  1. package/dist/packages/cli/src/assets/singleton-logo.txt +10 -0
  2. package/dist/packages/cli/src/commands/new.js +763 -0
  3. package/dist/packages/cli/src/commands/repl.js +557 -0
  4. package/dist/packages/cli/src/commands/usage.js +49 -0
  5. package/dist/packages/cli/src/executor/debug-loop.js +525 -0
  6. package/dist/packages/cli/src/executor/inputs.js +226 -0
  7. package/dist/packages/cli/src/executor/outputs.js +134 -0
  8. package/dist/packages/cli/src/executor/preflight.js +605 -0
  9. package/dist/packages/cli/src/executor/replay-loop.js +120 -0
  10. package/dist/packages/cli/src/executor/run-report.js +209 -0
  11. package/dist/packages/cli/src/executor/run-setup.js +114 -0
  12. package/dist/packages/cli/src/executor/security-review.js +97 -0
  13. package/dist/packages/cli/src/executor/snapshot-manager.js +349 -0
  14. package/dist/packages/cli/src/executor/step-runner.js +241 -0
  15. package/dist/packages/cli/src/executor.js +584 -0
  16. package/dist/packages/cli/src/index.js +107 -0
  17. package/dist/packages/cli/src/parser.js +89 -0
  18. package/dist/packages/cli/src/runners/_shared.js +96 -0
  19. package/dist/packages/cli/src/runners/claude.js +103 -0
  20. package/dist/packages/cli/src/runners/codex-instructions.js +69 -0
  21. package/dist/packages/cli/src/runners/codex.js +141 -0
  22. package/dist/packages/cli/src/runners/copilot.js +209 -0
  23. package/dist/packages/cli/src/runners/index.js +18 -0
  24. package/dist/packages/cli/src/runners/opencode.js +240 -0
  25. package/dist/packages/cli/src/scanner.js +43 -0
  26. package/dist/packages/cli/src/security/policy.js +115 -0
  27. package/dist/packages/cli/src/sentinels.js +1 -0
  28. package/dist/packages/cli/src/shell.js +753 -0
  29. package/dist/packages/cli/src/theme.js +39 -0
  30. package/dist/packages/cli/src/timeline.js +238 -0
  31. package/dist/packages/cli/src/types.js +1 -0
  32. package/dist/packages/cli/src/usage/aggregator.js +44 -0
  33. package/dist/packages/cli/src/usage/reader.js +30 -0
  34. package/dist/packages/cli/src/usage/types.js +1 -0
  35. package/dist/packages/server/src/index.js +36 -0
  36. package/dist/packages/server/src/routes/agents.js +31 -0
  37. package/dist/packages/server/src/routes/files.js +45 -0
  38. package/dist/packages/server/src/routes/pipelines.js +74 -0
  39. package/docs/reference.md +28 -0
  40. package/package.json +15 -14
  41. package/packages/web/dist/assets/{index-CnKytBly.js → index-9S0goZlQ.js} +1 -1
  42. package/packages/web/dist/assets/{index-CCFWfCA2.css → index-iV4UtXoN.css} +1 -1
  43. package/packages/web/dist/assets/logo-COSyZmgk.png +0 -0
  44. package/packages/web/dist/index.html +2 -2
  45. package/packages/cli/package.json +0 -18
  46. package/packages/cli/src/commands/new.js +0 -786
  47. package/packages/cli/src/commands/repl.js +0 -548
  48. package/packages/cli/src/executor/debug-loop.js +0 -587
  49. package/packages/cli/src/executor/inputs.js +0 -202
  50. package/packages/cli/src/executor/outputs.js +0 -140
  51. package/packages/cli/src/executor/preflight.js +0 -459
  52. package/packages/cli/src/executor/replay-loop.js +0 -172
  53. package/packages/cli/src/executor/run-report.js +0 -189
  54. package/packages/cli/src/executor/run-setup.js +0 -93
  55. package/packages/cli/src/executor/security-review.js +0 -108
  56. package/packages/cli/src/executor/snapshot-manager.js +0 -335
  57. package/packages/cli/src/executor/step-runner.js +0 -266
  58. package/packages/cli/src/executor.js +0 -652
  59. package/packages/cli/src/index.js +0 -107
  60. package/packages/cli/src/parser.js +0 -78
  61. package/packages/cli/src/runners/_shared.js +0 -83
  62. package/packages/cli/src/runners/claude.js +0 -122
  63. package/packages/cli/src/runners/codex-instructions.js +0 -75
  64. package/packages/cli/src/runners/codex.js +0 -165
  65. package/packages/cli/src/runners/copilot.js +0 -224
  66. package/packages/cli/src/runners/index.js +0 -20
  67. package/packages/cli/src/runners/opencode.js +0 -265
  68. package/packages/cli/src/scanner.js +0 -47
  69. package/packages/cli/src/security/policy.js +0 -126
  70. package/packages/cli/src/shell.js +0 -732
  71. package/packages/cli/src/theme.js +0 -46
  72. package/packages/cli/src/timeline.js +0 -180
  73. package/packages/server/package.json +0 -11
  74. package/packages/server/src/index.js +0 -43
  75. package/packages/server/src/routes/agents.js +0 -32
  76. package/packages/server/src/routes/files.js +0 -42
  77. package/packages/server/src/routes/pipelines.js +0 -74
@@ -0,0 +1,753 @@
1
+ import blessed from 'blessed';
2
+ import { ESC_SENTINEL } from './sentinels.js';
3
+ export const ASCII_MODE = process.env.SINGLETON_ASCII === '1'
4
+ || (process.platform === 'win32' && process.env.SINGLETON_UNICODE !== '1');
5
+ // ── Pastel theme ────────────────────────────────────────────────
6
+ export const C = ASCII_MODE ? {
7
+ violet: 'magenta',
8
+ pink: 'magenta',
9
+ blue: 'blue',
10
+ mint: 'green',
11
+ peach: 'yellow',
12
+ salmon: 'red',
13
+ dimV: 'gray',
14
+ line: 'gray',
15
+ ghost: 'gray',
16
+ } : {
17
+ violet: '#C084FC', // accent principal
18
+ pink: '#F9A8D4', // secondaire
19
+ blue: '#93C5FD', // tertiaire
20
+ mint: '#6EE7B7', // success
21
+ peach: '#FDBA74', // warning
22
+ salmon: '#FCA5A5', // erreur
23
+ dimV: '#b58eb8', // violet clair (texte muted)
24
+ line: '#4A4060', // separators
25
+ ghost: '#797C81', // gris discret lisible sur fond sombre
26
+ };
27
+ // ── Semantic tokens — one color, one role ────────────────────────
28
+ // Use these everywhere. Each token carries meaning, not decoration.
29
+ // text — primary readable body text
30
+ // muted — secondary text, metadata (dates, versions, descriptions)
31
+ // subtle — decorative separators (·, ─)
32
+ // accent — brand, interactive elements (slash commands, agent IDs)
33
+ // keyword — technical labels and feature/provider names
34
+ // string — user data (pipeline names, paths, URLs)
35
+ // success — positive markers (✓), confirmations
36
+ // warning — attention markers (!), announcements (New)
37
+ // error — failure markers (✕), blocking errors
38
+ export const S = ASCII_MODE ? {
39
+ text: 'white',
40
+ muted: 'gray',
41
+ subtle: 'gray',
42
+ border: 'gray',
43
+ accent: 'magenta',
44
+ keyword: 'blue',
45
+ string: 'magenta',
46
+ success: 'green',
47
+ warning: 'yellow',
48
+ error: 'red',
49
+ } : {
50
+ text: '#FFFFFF',
51
+ muted: '#8E8B9E', // soft cool gray, very subtly violet-tinted — reads as "quiet"
52
+ subtle: '#797C81',
53
+ border: '#4A4060', // structural separators (blessed.line widgets, frames)
54
+ accent: '#C084FC',
55
+ keyword: '#93C5FD',
56
+ string: '#F9A8D4',
57
+ success: '#6EE7B7',
58
+ warning: '#FDBA74',
59
+ error: '#FCA5A5',
60
+ };
61
+ export const G = ASCII_MODE ? {
62
+ scrollbar: '|',
63
+ pointer: '>',
64
+ cursor: '|',
65
+ cancel: '<-',
66
+ bullet: '.',
67
+ dash: '-',
68
+ hline: '-',
69
+ vline: '|',
70
+ cross: '+',
71
+ success: 'OK',
72
+ error: 'X',
73
+ running: '*',
74
+ pending: 'o',
75
+ skipped: '->',
76
+ } : {
77
+ scrollbar: '│',
78
+ pointer: '›',
79
+ cursor: '▌',
80
+ cancel: '↩',
81
+ bullet: '·',
82
+ dash: '─',
83
+ hline: '─',
84
+ vline: '│',
85
+ cross: '┼',
86
+ success: '✓',
87
+ error: '✕',
88
+ running: '●',
89
+ pending: '○',
90
+ skipped: '↷',
91
+ };
92
+ export function createShell() {
93
+ const screen = blessed.screen({ smartCSR: true, title: 'Singleton' });
94
+ const inputHints = [
95
+ '/help to start',
96
+ '/scan to scan agents',
97
+ '/new to create an agent',
98
+ '/run to execute a pipeline',
99
+ '/commit-last to commit the last run',
100
+ ];
101
+ // ── Normal mode ────────────────────────────────────────────────
102
+ const content = blessed.log({
103
+ top: 0, left: 0,
104
+ width: '100%', height: '100%-4',
105
+ scrollable: true, alwaysScroll: true,
106
+ tags: true,
107
+ border: { type: 'line' },
108
+ style: { border: { fg: S.border } },
109
+ padding: { left: 1, top: 0, right: 1 },
110
+ scrollbar: {
111
+ ch: G.scrollbar,
112
+ style: { fg: S.border }
113
+ }
114
+ });
115
+ // ── Pipeline mode (single column) ──────────────────────────────
116
+ const pipelineLog = blessed.log({
117
+ top: 0, left: 0,
118
+ width: '100%', height: '100%-10',
119
+ tags: true,
120
+ hidden: true,
121
+ scrollable: true,
122
+ alwaysScroll: true,
123
+ border: { type: 'line' },
124
+ style: { border: { fg: S.border } },
125
+ padding: { left: 1, top: 0, right: 1 },
126
+ scrollbar: {
127
+ ch: G.scrollbar,
128
+ style: { fg: S.border }
129
+ }
130
+ });
131
+ const pipelineStatus = blessed.box({
132
+ bottom: 5, left: 0,
133
+ width: '100%', height: 4,
134
+ tags: true,
135
+ hidden: true,
136
+ padding: { left: 2, right: 2 }
137
+ });
138
+ // Label overlay sitting on the top border of pipelineLog (e.g. "Step 2/4" or "input waiting")
139
+ const pipelineLabel = blessed.box({
140
+ top: 0, left: 4,
141
+ width: 'shrink', height: 1,
142
+ tags: true,
143
+ hidden: true
144
+ });
145
+ // ── Shell bar (toujours visible) ───────────────────────────────
146
+ const sep1 = blessed.line({
147
+ orientation: 'horizontal',
148
+ bottom: 3, left: 0, width: '100%',
149
+ style: { fg: S.border }
150
+ });
151
+ const suggestBox = blessed.box({
152
+ bottom: 4, left: 0,
153
+ width: '100%', height: 5,
154
+ tags: true,
155
+ hidden: true,
156
+ padding: { left: 2, right: 2 },
157
+ style: { bg: 'default' }
158
+ });
159
+ const promptBox = blessed.box({
160
+ bottom: 2, left: 0,
161
+ width: '100%', height: 1,
162
+ padding: { left: 2 }, tags: true
163
+ });
164
+ const sep2 = blessed.line({
165
+ orientation: 'horizontal',
166
+ bottom: 1, left: 0, width: '100%',
167
+ style: { fg: S.border }
168
+ });
169
+ const footerLeftBox = blessed.box({
170
+ bottom: 0, left: 0,
171
+ width: '70%', height: 1,
172
+ padding: { left: 2 },
173
+ tags: true
174
+ });
175
+ const footerRightBox = blessed.box({
176
+ bottom: 0, right: 2,
177
+ width: '30%', height: 1,
178
+ align: 'right',
179
+ tags: true
180
+ });
181
+ const footerCenterBox = blessed.box({
182
+ bottom: 0, left: '30%',
183
+ width: '40%', height: 1,
184
+ align: 'center',
185
+ tags: true
186
+ });
187
+ screen.append(content);
188
+ screen.append(pipelineLog);
189
+ screen.append(pipelineStatus);
190
+ screen.append(pipelineLabel);
191
+ screen.append(suggestBox);
192
+ screen.append(sep1);
193
+ screen.append(promptBox);
194
+ screen.append(sep2);
195
+ screen.append(footerLeftBox);
196
+ screen.append(footerRightBox);
197
+ screen.append(footerCenterBox);
198
+ pipelineStatus.hide();
199
+ // ── Input state ─────────────────────────────────────────────────
200
+ let buffer = '';
201
+ let inputEnabled = true;
202
+ let onSubmit = null;
203
+ let promptMode = null;
204
+ let completer = null;
205
+ let suggestions = [];
206
+ let suggestIndex = 0;
207
+ let completeSeq = 0;
208
+ let pipelineMode = false;
209
+ let history = [];
210
+ let historyIndex = -1;
211
+ let draftBuffer = '';
212
+ let hintIndex = 0;
213
+ let footerLeft = '';
214
+ let footerRight = '';
215
+ let footerCenter = '';
216
+ // Tracks the /run two-step submit: first Enter on `/run <pipeline>` opens flag suggestions
217
+ // passively, second Enter submits. Cleared by any keystroke that breaks the dance.
218
+ let runAwaitingSecondEnter = false;
219
+ function stripTags(s) {
220
+ return String(s || '').replace(/\{[^}]+\}/g, '');
221
+ }
222
+ function hideSuggestions() {
223
+ suggestions = [];
224
+ suggestIndex = 0;
225
+ suggestBox.hide();
226
+ }
227
+ function renderSuggestions() {
228
+ if (!suggestions.length) {
229
+ suggestBox.hide();
230
+ return;
231
+ }
232
+ const maxItems = Math.min(5, suggestions.length);
233
+ const start = Math.min(Math.max(0, suggestIndex - maxItems + 1), Math.max(0, suggestions.length - maxItems));
234
+ const width = Math.max(40, (screen.width ?? 100) - 6);
235
+ // Semantic styling:
236
+ // active row → accent ›, white bold label, muted description (clearly the one in focus)
237
+ // inactive row → blank marker, muted label, subtle description (recedes)
238
+ // suggestIndex === -1 means "no active selection" (passive listing after a soft Enter on /run).
239
+ const lines = suggestions.slice(start, start + maxItems).map((item, idx) => {
240
+ const active = suggestIndex >= 0 && start + idx === suggestIndex;
241
+ const marker = active ? `{${S.accent}-fg}{bold}${G.pointer}{/}` : ' ';
242
+ const label = active
243
+ ? `{${S.text}-fg}{bold}${item.label}{/}`
244
+ : `{${S.muted}-fg}${item.label}{/}`;
245
+ const descColor = active ? S.muted : S.subtle;
246
+ const desc = item.description ? ` {${descColor}-fg}${item.description}{/}` : '';
247
+ const visible = stripTags(`${marker} ${item.label}${item.description ? ` ${item.description}` : ''}`);
248
+ const clippedDesc = visible.length > width ? '' : desc;
249
+ return `${marker} ${label}${clippedDesc}`;
250
+ });
251
+ suggestBox.setContent(lines.join('\n'));
252
+ suggestBox.show();
253
+ }
254
+ async function refreshSuggestions({ applySingle = false, passive = false } = {}) {
255
+ // Prompt-scoped completer (set via shell.prompt({ completer })) takes precedence,
256
+ // so per-field autocompletes don't leak into the global slash-command completer.
257
+ const activeCompleter = promptMode?.completer || completer;
258
+ if (!activeCompleter)
259
+ return false;
260
+ const seq = ++completeSeq;
261
+ const result = await activeCompleter({ buffer, cursor: buffer.length });
262
+ if (seq !== completeSeq)
263
+ return false;
264
+ suggestions = Array.isArray(result)
265
+ ? result.filter((s) => s && typeof s.value === 'string' && typeof s.label === 'string')
266
+ : [];
267
+ // passive=true: shown as a list with no active row; Enter will submit, not apply.
268
+ suggestIndex = passive ? -1 : 0;
269
+ if (applySingle && suggestions.length === 1) {
270
+ applySuggestion(suggestions[0]);
271
+ return true;
272
+ }
273
+ renderSuggestions();
274
+ screen.render();
275
+ return suggestions.length > 0;
276
+ }
277
+ function applySuggestion(item = suggestions[suggestIndex]) {
278
+ if (!item)
279
+ return false;
280
+ buffer = item.value;
281
+ hideSuggestions();
282
+ runAwaitingSecondEnter = false;
283
+ updatePrompt();
284
+ return true;
285
+ }
286
+ function updatePrompt() {
287
+ if (promptMode) {
288
+ const message = String(promptMode.message || '');
289
+ // If the caller passed pre-tagged content, respect it. Otherwise the message
290
+ // belongs to the "awaiting input" state → bold + warning to match the ambient frame.
291
+ const renderedMessage = message.includes('{')
292
+ ? message
293
+ : `{${S.warning}-fg}{bold}${message}{/}`;
294
+ const marker = message.includes('Debug action')
295
+ ? ''
296
+ : `{${S.warning}-fg}{bold}?{/} `;
297
+ // Ghost-text default: when the buffer is empty and the caller supplied a
298
+ // `default` value, render it in subtle after the cursor so it reads as a
299
+ // suggestion. Pressing Enter on an empty buffer accepts the default.
300
+ const ghost = (!buffer && promptMode.default)
301
+ ? `{${S.subtle}-fg}${promptMode.default}{/}`
302
+ : '';
303
+ promptBox.setContent(`${marker}${renderedMessage} {${S.muted}-fg}${G.pointer}{/} ${buffer}{${S.accent}-fg}${G.cursor}{/}${ghost}`);
304
+ }
305
+ else {
306
+ if (buffer) {
307
+ promptBox.setContent(`{${S.muted}-fg}${G.pointer}{/} ${buffer}{${S.accent}-fg}${G.cursor}{/}`);
308
+ }
309
+ else {
310
+ const hint = history.length === 0 ? inputHints[0] : inputHints[hintIndex];
311
+ promptBox.setContent(`{${S.muted}-fg}${G.pointer}{/} {${S.accent}-fg}${G.cursor}{/}{${S.subtle}-fg}${hint}{/}`);
312
+ }
313
+ }
314
+ screen.render();
315
+ }
316
+ function renderFooter() {
317
+ footerLeftBox.setContent(footerLeft ? `{${C.dimV}-fg}${String(footerLeft)}{/}` : '');
318
+ footerRightBox.setContent(footerRight ? `{${C.dimV}-fg}${String(footerRight)}{/}` : '');
319
+ footerCenterBox.setContent(footerCenter || '');
320
+ }
321
+ function setFooter(left = '', right = '') {
322
+ footerLeft = left;
323
+ footerRight = right;
324
+ renderFooter();
325
+ screen.render();
326
+ }
327
+ function setFooterCenter(text = '') {
328
+ footerCenter = text;
329
+ renderFooter();
330
+ screen.render();
331
+ }
332
+ screen.on('resize', () => {
333
+ renderSuggestions();
334
+ screen.render();
335
+ });
336
+ function resetHistoryNav() {
337
+ historyIndex = -1;
338
+ draftBuffer = '';
339
+ }
340
+ const hintTicker = setInterval(() => {
341
+ if (promptMode || pipelineMode || buffer || history.length === 0)
342
+ return;
343
+ hintIndex = (hintIndex + 1) % inputHints.length;
344
+ updatePrompt();
345
+ }, 3000);
346
+ screen.on('keypress', async (ch, key) => {
347
+ if (key.full === 'C-c') {
348
+ log(`{${C.dimV}-fg}See you soon.{/}`);
349
+ screen.render();
350
+ setTimeout(() => { screen.destroy(); process.exit(0); }, 200);
351
+ }
352
+ if (pipelineMode) {
353
+ if (key.name === 'up') {
354
+ pipelineLog.scroll(-1);
355
+ screen.render();
356
+ return;
357
+ }
358
+ if (key.name === 'down') {
359
+ pipelineLog.scroll(1);
360
+ screen.render();
361
+ return;
362
+ }
363
+ if (key.name === 'pageup') {
364
+ pipelineLog.scroll(-(pipelineLog.height - 2));
365
+ screen.render();
366
+ return;
367
+ }
368
+ if (key.name === 'pagedown') {
369
+ pipelineLog.scroll(pipelineLog.height - 2);
370
+ screen.render();
371
+ return;
372
+ }
373
+ if (key.name === 'home') {
374
+ pipelineLog.setScroll(0);
375
+ screen.render();
376
+ return;
377
+ }
378
+ if (key.name === 'end') {
379
+ pipelineLog.setScrollPerc(100);
380
+ screen.render();
381
+ return;
382
+ }
383
+ }
384
+ if (!pipelineMode && !promptMode && !suggestions.length) {
385
+ if (buffer === '' && key.name === 'up') {
386
+ content.scroll(-1);
387
+ screen.render();
388
+ return;
389
+ }
390
+ if (buffer === '' && key.name === 'down') {
391
+ content.scroll(1);
392
+ screen.render();
393
+ return;
394
+ }
395
+ if (key.name === 'pageup') {
396
+ content.scroll(-(content.height - 2));
397
+ screen.render();
398
+ return;
399
+ }
400
+ if (key.name === 'pagedown') {
401
+ content.scroll(content.height - 2);
402
+ screen.render();
403
+ return;
404
+ }
405
+ if (key.name === 'home') {
406
+ content.setScroll(0);
407
+ screen.render();
408
+ return;
409
+ }
410
+ if (key.name === 'end') {
411
+ content.setScrollPerc(100);
412
+ screen.render();
413
+ return;
414
+ }
415
+ }
416
+ if (!inputEnabled && !promptMode)
417
+ return;
418
+ if (promptMode && key.name === 'escape') {
419
+ // In a prompt with autocomplete open, Esc first closes the suggestions instead
420
+ // of cancelling the prompt itself (matches the global-mode behavior).
421
+ if (suggestions.length) {
422
+ hideSuggestions();
423
+ updatePrompt();
424
+ return;
425
+ }
426
+ const { resolve, message, silent } = promptMode;
427
+ promptMode = null;
428
+ buffer = '';
429
+ if (!silent)
430
+ log(`{${S.subtle}-fg}${G.cancel} cancelled{/} {${S.muted}-fg}${message}{/}`);
431
+ updatePrompt();
432
+ resolve(ESC_SENTINEL);
433
+ return;
434
+ }
435
+ if (key.name === 'tab' && (promptMode?.completer || (!promptMode && completer))) {
436
+ if (suggestions.length > 1) {
437
+ // From the passive -1 state, Tab focuses the first item rather than skipping it.
438
+ suggestIndex = suggestIndex < 0 ? 0 : (suggestIndex + 1) % suggestions.length;
439
+ renderSuggestions();
440
+ screen.render();
441
+ return;
442
+ }
443
+ await refreshSuggestions({ applySingle: true });
444
+ return;
445
+ }
446
+ if (suggestions.length && (key.name === 'down' || key.name === 'up')) {
447
+ const dir = key.name === 'down' ? 1 : -1;
448
+ // From the passive -1 state, the first arrow lands on item 0 (down) or last (up).
449
+ if (suggestIndex < 0)
450
+ suggestIndex = dir === 1 ? 0 : suggestions.length - 1;
451
+ else
452
+ suggestIndex = (suggestIndex + dir + suggestions.length) % suggestions.length;
453
+ renderSuggestions();
454
+ screen.render();
455
+ return;
456
+ }
457
+ if (!promptMode && suggestions.length && key.name === 'escape') {
458
+ hideSuggestions();
459
+ updatePrompt();
460
+ return;
461
+ }
462
+ if (suggestions.length && (key.name === 'right' || key.name === 'enter' || key.name === 'return')) {
463
+ // Passive listing (no active row) → Enter falls through to the submit handler below.
464
+ if (suggestIndex < 0 && (key.name === 'enter' || key.name === 'return')) {
465
+ // fall through
466
+ }
467
+ else {
468
+ applySuggestion();
469
+ return;
470
+ }
471
+ }
472
+ if (!promptMode && !suggestions.length && (key.full === 'C-p' || key.full === 'C-n')) {
473
+ if (history.length === 0)
474
+ return;
475
+ if (key.full === 'C-p') {
476
+ if (historyIndex === -1) {
477
+ draftBuffer = buffer;
478
+ historyIndex = history.length - 1;
479
+ }
480
+ else if (historyIndex > 0) {
481
+ historyIndex -= 1;
482
+ }
483
+ buffer = history[historyIndex] || '';
484
+ }
485
+ else {
486
+ if (historyIndex === -1)
487
+ return;
488
+ if (historyIndex < history.length - 1) {
489
+ historyIndex += 1;
490
+ buffer = history[historyIndex] || '';
491
+ }
492
+ else {
493
+ historyIndex = -1;
494
+ buffer = draftBuffer;
495
+ }
496
+ }
497
+ updatePrompt();
498
+ return;
499
+ }
500
+ if (key.name === 'enter' || key.name === 'return') {
501
+ const value = buffer.trim();
502
+ // Two-step submit for /run <pipeline>: first Enter opens flag suggestions passively,
503
+ // second Enter submits. Guarded by runAwaitingSecondEnter so dismissing the suggestions
504
+ // (Esc) and pressing Enter again doesn't re-loop.
505
+ if (!promptMode &&
506
+ !runAwaitingSecondEnter &&
507
+ /^\/run\s+\S+\s*$/.test(value) &&
508
+ !value.includes(' --')) {
509
+ if (!buffer.endsWith(' '))
510
+ buffer += ' ';
511
+ runAwaitingSecondEnter = true;
512
+ updatePrompt();
513
+ await refreshSuggestions({ passive: true });
514
+ return;
515
+ }
516
+ runAwaitingSecondEnter = false;
517
+ buffer = '';
518
+ hideSuggestions();
519
+ if (promptMode) {
520
+ const { resolve, message, silent, default: promptDefault } = promptMode;
521
+ // Empty submission with a ghost-text default → resolve with the default.
522
+ const finalValue = (value === '' && promptDefault) ? promptDefault : value;
523
+ promptMode = null;
524
+ if (!silent)
525
+ log(`{${S.warning}-fg}{bold}?{/} {${S.muted}-fg}${message}{/} ${finalValue}`);
526
+ updatePrompt();
527
+ resolve(finalValue);
528
+ }
529
+ else {
530
+ updatePrompt();
531
+ if (value) {
532
+ if (history[history.length - 1] !== value)
533
+ history.push(value);
534
+ if (history.length > 200)
535
+ history = history.slice(-200);
536
+ resetHistoryNav();
537
+ if (onSubmit)
538
+ onSubmit(value);
539
+ }
540
+ else {
541
+ resetHistoryNav();
542
+ }
543
+ }
544
+ }
545
+ else if (key.name === 'backspace') {
546
+ buffer = buffer.slice(0, -1);
547
+ resetHistoryNav();
548
+ runAwaitingSecondEnter = false;
549
+ // In a prompt with a completer, keystrokes re-filter the suggestions instead
550
+ // of dismissing them. In all other modes, typing hides the suggest panel.
551
+ if (promptMode?.completer) {
552
+ updatePrompt();
553
+ await refreshSuggestions({ passive: true });
554
+ }
555
+ else {
556
+ hideSuggestions();
557
+ updatePrompt();
558
+ }
559
+ }
560
+ else if (ch && !key.ctrl && !key.meta) {
561
+ buffer += ch;
562
+ resetHistoryNav();
563
+ runAwaitingSecondEnter = false;
564
+ if (promptMode?.completer) {
565
+ updatePrompt();
566
+ await refreshSuggestions({ passive: true });
567
+ }
568
+ else {
569
+ hideSuggestions();
570
+ updatePrompt();
571
+ }
572
+ }
573
+ });
574
+ function log(text) {
575
+ content.log(text);
576
+ screen.render();
577
+ }
578
+ updatePrompt();
579
+ // Mode → border color mapping. Drives the "ambient state" frame around the log panel.
580
+ // null/'idle' → S.border (faint, structural)
581
+ // 'running' → S.keyword (blue, run in progress)
582
+ // 'awaiting' → S.warning (orange, waiting for human input)
583
+ // 'error' → S.error (red, last run failed)
584
+ // 'debug' → S.warning (orange, debug mode active)
585
+ // Label overlay shown on the top border of the pipeline log frame.
586
+ // Timeline writes the step indicator (step X/N). The executor can override
587
+ // with "input waiting" while a prompt is pending — overrides are sticky
588
+ // until cleared, so the timeline's spinner-tick re-renders don't clobber them.
589
+ let pipelineLabelOverride = null;
590
+ function writePipelineLabel(text) {
591
+ if (!text) {
592
+ pipelineLabel.setContent('');
593
+ pipelineLabel.hide();
594
+ }
595
+ else {
596
+ pipelineLabel.setContent(` ${text} `);
597
+ pipelineLabel.show();
598
+ }
599
+ screen.render();
600
+ }
601
+ function applyPipelineLabel(text) {
602
+ if (pipelineLabelOverride !== null)
603
+ return;
604
+ writePipelineLabel(text);
605
+ }
606
+ function setPipelineLabel(text) {
607
+ pipelineLabelOverride = text;
608
+ writePipelineLabel(`{${S.warning}-fg}{bold}${text}{/}`);
609
+ }
610
+ function clearPipelineLabel() {
611
+ pipelineLabelOverride = null;
612
+ writePipelineLabel('');
613
+ }
614
+ // Mode → border color. Two-layer state:
615
+ // baseMode — ambient mode set by the executor ('running' during a step, etc.)
616
+ // currentMode — what is actually painted; prompts override to 'awaiting' and restore baseMode on resolve.
617
+ // Removed 'debug' as its own mode: a debug pause IS an awaiting state, a running debug step IS running.
618
+ let baseMode = null;
619
+ function applyMode(mode) {
620
+ const map = {
621
+ running: S.keyword,
622
+ awaiting: S.warning,
623
+ error: S.error,
624
+ };
625
+ const color = mode ? map[mode] : S.border;
626
+ content.style.border.fg = color;
627
+ pipelineLog.style.border.fg = color;
628
+ sep1.style.fg = color;
629
+ sep2.style.fg = color;
630
+ screen.render();
631
+ }
632
+ function setMode(mode) {
633
+ baseMode = mode;
634
+ applyMode(mode);
635
+ }
636
+ return {
637
+ log,
638
+ logMuted(text) { log(`{${S.muted}-fg}${text}{/}`); },
639
+ logAccent(text) { log(`{${S.accent}-fg}${text}{/}`); },
640
+ setFooter,
641
+ setFooterCenter,
642
+ setMode,
643
+ setPipelineLabel,
644
+ clearPipelineLabel,
645
+ clear() { content.setContent(''); screen.render(); },
646
+ setContent(text) { content.setContent(text); screen.render(); },
647
+ onCommand(fn) { onSubmit = fn; },
648
+ setCompleter(fn) { completer = fn; },
649
+ // Shimmer animation overlay (e.g. for "Welcome back")
650
+ createShimmer(text, top, left) {
651
+ const box = blessed.box({
652
+ top, left,
653
+ width: text.length,
654
+ height: 1,
655
+ tags: true
656
+ });
657
+ screen.append(box);
658
+ let peak = -4;
659
+ function render() {
660
+ const content = text.split('').map((ch, i) => {
661
+ const dist = Math.abs(i - peak);
662
+ let color;
663
+ if (dist === 0)
664
+ color = '#FFFFFF';
665
+ else if (dist === 1)
666
+ color = '#EDD9FF';
667
+ else if (dist === 2)
668
+ color = '#D4B0FE';
669
+ else
670
+ color = C.violet;
671
+ return `{${color}-fg}{bold}${ch}{/}`;
672
+ }).join('');
673
+ box.setContent(content);
674
+ screen.render();
675
+ peak++;
676
+ if (peak > text.length + 4)
677
+ peak = -4;
678
+ }
679
+ render();
680
+ const iv = setInterval(render, 80);
681
+ return () => { clearInterval(iv); screen.remove(box); screen.render(); };
682
+ },
683
+ disableInput() { inputEnabled = false; hideSuggestions(); resetHistoryNav(); screen.render(); },
684
+ enableInput() { inputEnabled = true; buffer = ''; hideSuggestions(); resetHistoryNav(); updatePrompt(); },
685
+ prompt(message, { silent = false, completer: promptCompleter = null, default: promptDefault = '' } = {}) {
686
+ return new Promise((resolve) => {
687
+ // Override ambient mode to 'awaiting' (orange) for the duration of the prompt,
688
+ // and restore the baseMode (e.g. 'running') once the user has answered.
689
+ const shouldOverride = baseMode === 'running';
690
+ if (shouldOverride)
691
+ applyMode('awaiting');
692
+ promptMode = {
693
+ message,
694
+ silent,
695
+ completer: promptCompleter,
696
+ default: promptDefault,
697
+ resolve: (value) => {
698
+ if (shouldOverride)
699
+ applyMode(baseMode);
700
+ resolve(value);
701
+ },
702
+ };
703
+ buffer = '';
704
+ hideSuggestions();
705
+ resetHistoryNav();
706
+ updatePrompt();
707
+ // With a prompt-scoped completer, show the full suggestion list immediately
708
+ // so the user sees what's available without having to press Tab first.
709
+ // Use passive mode so Enter submits the typed value rather than applying
710
+ // the first row — Tab/arrows are the explicit "pick" path.
711
+ if (promptCompleter) {
712
+ refreshSuggestions({ passive: true }).catch(() => { });
713
+ }
714
+ });
715
+ },
716
+ // Mirror sends every timeline.log call into the main `content` widget too, so the full
717
+ // run history survives exitPipelineMode (pipelineLog gets hidden, but content keeps it).
718
+ pipelineWidgets: {
719
+ screen,
720
+ logPanel: pipelineLog,
721
+ statusBox: pipelineStatus,
722
+ setLabel: applyPipelineLabel,
723
+ mirror: (text) => content.log(text),
724
+ },
725
+ enterPipelineMode() {
726
+ pipelineMode = true;
727
+ content.hide();
728
+ pipelineLog.setContent('');
729
+ pipelineStatus.setContent('');
730
+ pipelineLog.show();
731
+ pipelineStatus.show();
732
+ pipelineLabel.show();
733
+ promptBox.setContent('');
734
+ screen.render();
735
+ },
736
+ exitPipelineMode() {
737
+ pipelineMode = false;
738
+ pipelineLabelOverride = null;
739
+ pipelineLog.hide();
740
+ pipelineStatus.hide();
741
+ pipelineLabel.hide();
742
+ pipelineLabel.setContent('');
743
+ content.show();
744
+ updatePrompt();
745
+ screen.render();
746
+ },
747
+ screen,
748
+ destroy() {
749
+ clearInterval(hintTicker);
750
+ screen.destroy();
751
+ }
752
+ };
753
+ }