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

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.
@@ -22,7 +22,7 @@ function groupAgentsByProvider(agents) {
22
22
  program
23
23
  .name('singleton')
24
24
  .description('Singleton Pipeline Builder — scan agents and build pipelines')
25
- .version('0.4.0-beta.1');
25
+ .version('0.4.0-beta.12');
26
26
 
27
27
  program
28
28
  .command('scan')
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import spawn from 'cross-spawn';
2
2
 
3
3
  const DEFAULT_TIMEOUT_MS = Number(process.env.SINGLETON_RUNNER_TIMEOUT_MS) || 10 * 60 * 1000;
4
4
  const ALLOWED_PERMISSION_MODES = new Set(['bypassPermissions']);
@@ -97,8 +97,11 @@ export const claudeRunner = {
97
97
  }
98
98
  });
99
99
 
100
- child.stdin.write(userPrompt);
101
- child.stdin.end();
100
+ child.stdin.on('error', () => { /* surfaced via close handler */ });
101
+ try {
102
+ child.stdin.write(userPrompt);
103
+ child.stdin.end();
104
+ } catch { /* same */ }
102
105
  });
103
106
 
104
107
  return {
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { spawn } from 'node:child_process';
4
+ import spawn from 'cross-spawn';
5
5
  import { discoverCodexProjectInstructions } from './codex-instructions.js';
6
6
  import { findUsage, safeJsonParse } from './_shared.js';
7
7
 
@@ -128,8 +128,11 @@ export const codexRunner = {
128
128
  resolve({ events, stderr: stderrText });
129
129
  });
130
130
 
131
- child.stdin.write(prompt);
132
- child.stdin.end();
131
+ child.stdin.on('error', () => { /* surfaced via close handler */ });
132
+ try {
133
+ child.stdin.write(prompt);
134
+ child.stdin.end();
135
+ } catch { /* same */ }
133
136
  });
134
137
 
135
138
  let text = '';
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import spawn from 'cross-spawn';
2
2
  import path from 'node:path';
3
3
  import { extractText, safeJsonParse } from './_shared.js';
4
4
 
@@ -56,10 +56,15 @@ export function buildCopilotPermissionArgs(securityPolicy = {}) {
56
56
  args.push('--allow-tool=write');
57
57
  }
58
58
 
59
+ // Copilot CLI runs in deny-by-default mode as soon as any --allow-tool is
60
+ // present. Agents need shell access to list/grep the codebase even when their
61
+ // write surface is restricted — otherwise the scout can't discover anything.
62
+ // read-only stays shell-less; dangerous is already covered by --allow-all-tools.
59
63
  if (profile === 'read-only') {
60
64
  args.push('--deny-tool=write');
61
65
  args.push('--deny-tool=shell');
62
66
  } else {
67
+ if (profile !== 'dangerous') args.push('--allow-tool=shell');
63
68
  args.push('--deny-tool=shell(git push)');
64
69
  }
65
70
 
@@ -77,9 +82,14 @@ export function buildCopilotPermissionArgs(securityPolicy = {}) {
77
82
  }
78
83
 
79
84
  export function buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy = {} } = {}) {
85
+ // Copilot CLI expects the user prompt as `-p <text>` arg. Passing `-p -` is
86
+ // interpreted as the literal string "-", not as a stdin marker, so we always
87
+ // inline the prompt as an argument here. Callers must keep the prompt under
88
+ // ~32KB on Windows — large blobs (scout output, etc.) should be referenced
89
+ // as files on disk rather than injected inline.
80
90
  const args = [
81
91
  '-p',
82
- prompt,
92
+ prompt ?? '',
83
93
  '--output-format',
84
94
  'json',
85
95
  ...buildCopilotPermissionArgs(securityPolicy),
@@ -90,10 +100,13 @@ export function buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy =
90
100
  }
91
101
 
92
102
  export function summarizeCopilotEvents(events) {
93
- const assistantMessages = events
94
- .filter((event) => event.type === 'assistant.message')
95
- .map((event) => extractText(event.data))
96
- .filter(Boolean);
103
+ // Copilot emits intermediate `assistant.message` events between tool calls
104
+ // (the model's "thinking out loud"). The final deliverable is the LAST
105
+ // assistant.message concatenating them all would prepend narration noise
106
+ // to whatever the agent is supposed to produce as its output.
107
+ const assistantMessages = events.filter((event) => event.type === 'assistant.message');
108
+ const finalMessage = assistantMessages.at(-1);
109
+ const finalText = finalMessage ? extractText(finalMessage.data) : '';
97
110
  const deltaText = events
98
111
  .filter((event) => event.type === 'assistant.message_delta')
99
112
  .map((event) => event.data?.deltaContent || event.data?.delta || '')
@@ -106,8 +119,8 @@ export function summarizeCopilotEvents(events) {
106
119
  }, 0);
107
120
 
108
121
  return {
109
- text: assistantMessages.join('\n').trim() || deltaText.trim(),
110
- turns: events.filter((event) => event.type === 'assistant.message').length || null,
122
+ text: (finalText || deltaText).trim(),
123
+ turns: assistantMessages.length || null,
111
124
  outputTokens: outputTokens || null,
112
125
  premiumRequests: Number(result?.usage?.premiumRequests || 0) || null,
113
126
  result,
@@ -137,7 +150,10 @@ export const copilotRunner = {
137
150
  securityPolicy,
138
151
  timeoutMs = DEFAULT_TIMEOUT_MS,
139
152
  }) {
140
- const prompt = buildPrompt(systemPrompt, userPrompt);
153
+ // When --agent is used, Copilot loads the system prompt from .github/agents/<name>.md.
154
+ // We pass only the user prompt as `-p <text>`. Without --agent we inline the
155
+ // system prompt wrapped in <system>/<user> tags as the user prompt.
156
+ const prompt = runnerAgent ? userPrompt : buildPrompt(systemPrompt, userPrompt);
141
157
  const args = buildCopilotArgs({ prompt, model, runnerAgent, securityPolicy });
142
158
 
143
159
  const { events, stderr } = await new Promise((resolve, reject) => {
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
1
+ import spawn from 'cross-spawn';
2
2
  import path from 'node:path';
3
3
  import { extractText, findCostUsd, findUsage, safeJsonParse } from './_shared.js';
4
4
 
@@ -13,6 +13,30 @@ export const C = {
13
13
  ghost: '#797C81', // gris discret lisible sur fond sombre
14
14
  };
15
15
 
16
+ // ── Semantic tokens — one color, one role ────────────────────────
17
+ // Use these everywhere. Each token carries meaning, not decoration.
18
+ // text — primary readable body text
19
+ // muted — secondary text, metadata (dates, versions, descriptions)
20
+ // subtle — decorative separators (·, ─)
21
+ // accent — brand, interactive elements (slash commands, agent IDs)
22
+ // keyword — technical labels and feature/provider names
23
+ // string — user data (pipeline names, paths, URLs)
24
+ // success — positive markers (✓), confirmations
25
+ // warning — attention markers (!), announcements (New)
26
+ // error — failure markers (✕), blocking errors
27
+ export const S = {
28
+ text: '#FFFFFF',
29
+ muted: '#8E8B9E', // soft cool gray, very subtly violet-tinted — reads as "quiet"
30
+ subtle: '#797C81',
31
+ border: '#4A4060', // structural separators (blessed.line widgets, frames)
32
+ accent: '#C084FC',
33
+ keyword: '#93C5FD',
34
+ string: '#F9A8D4',
35
+ success: '#6EE7B7',
36
+ warning: '#FDBA74',
37
+ error: '#FCA5A5',
38
+ };
39
+
16
40
  export function createShell() {
17
41
  const screen = blessed.screen({ smartCSR: true, title: 'Singleton' });
18
42
  const inputHints = [
@@ -29,10 +53,12 @@ export function createShell() {
29
53
  width: '100%', height: '100%-4',
30
54
  scrollable: true, alwaysScroll: true,
31
55
  tags: true,
32
- padding: { left: 2, top: 1, right: 2 },
56
+ border: { type: 'line' },
57
+ style: { border: { fg: S.border } },
58
+ padding: { left: 1, top: 0, right: 1 },
33
59
  scrollbar: {
34
60
  ch: '│',
35
- style: { fg: C.line }
61
+ style: { fg: S.border }
36
62
  }
37
63
  });
38
64
 
@@ -44,32 +70,36 @@ export function createShell() {
44
70
  hidden: true,
45
71
  scrollable: true,
46
72
  alwaysScroll: true,
47
- padding: { left: 2, top: 1, right: 2 },
73
+ border: { type: 'line' },
74
+ style: { border: { fg: S.border } },
75
+ padding: { left: 1, top: 0, right: 1 },
48
76
  scrollbar: {
49
77
  ch: '│',
50
- style: { fg: C.line }
78
+ style: { fg: S.border }
51
79
  }
52
80
  });
53
81
 
54
- const pipelineSep = blessed.line({
55
- orientation: 'horizontal',
56
- bottom: 8, left: 0, width: '100%',
57
- style: { fg: C.line }
58
- });
59
-
60
82
  const pipelineStatus = blessed.box({
61
- bottom: 4, left: 0,
83
+ bottom: 5, left: 0,
62
84
  width: '100%', height: 4,
63
85
  tags: true,
64
86
  hidden: true,
65
87
  padding: { left: 2, right: 2 }
66
88
  });
67
89
 
90
+ // Label overlay sitting on the top border of pipelineLog (e.g. "Step 2/4" or "input waiting")
91
+ const pipelineLabel = blessed.box({
92
+ top: 0, left: 4,
93
+ width: 'shrink', height: 1,
94
+ tags: true,
95
+ hidden: true
96
+ });
97
+
68
98
  // ── Shell bar (toujours visible) ───────────────────────────────
69
99
  const sep1 = blessed.line({
70
100
  orientation: 'horizontal',
71
101
  bottom: 3, left: 0, width: '100%',
72
- style: { fg: C.line }
102
+ style: { fg: S.border }
73
103
  });
74
104
 
75
105
  const suggestBox = blessed.box({
@@ -90,7 +120,7 @@ export function createShell() {
90
120
  const sep2 = blessed.line({
91
121
  orientation: 'horizontal',
92
122
  bottom: 1, left: 0, width: '100%',
93
- style: { fg: C.line }
123
+ style: { fg: S.border }
94
124
  });
95
125
 
96
126
  const footerLeftBox = blessed.box({
@@ -116,8 +146,8 @@ export function createShell() {
116
146
 
117
147
  screen.append(content);
118
148
  screen.append(pipelineLog);
119
- screen.append(pipelineSep);
120
149
  screen.append(pipelineStatus);
150
+ screen.append(pipelineLabel);
121
151
  screen.append(suggestBox);
122
152
  screen.append(sep1);
123
153
  screen.append(promptBox);
@@ -126,7 +156,6 @@ export function createShell() {
126
156
  screen.append(footerRightBox);
127
157
  screen.append(footerCenterBox);
128
158
 
129
- pipelineSep.hide();
130
159
  pipelineStatus.hide();
131
160
 
132
161
  // ── Input state ─────────────────────────────────────────────────
@@ -146,6 +175,9 @@ export function createShell() {
146
175
  let footerLeft = '';
147
176
  let footerRight = '';
148
177
  let footerCenter = '';
178
+ // Tracks the /run two-step submit: first Enter on `/run <pipeline>` opens flag suggestions
179
+ // passively, second Enter submits. Cleared by any keystroke that breaks the dance.
180
+ let runAwaitingSecondEnter = false;
149
181
  function stripTags(s) {
150
182
  return String(s || '').replace(/\{[^}]+\}/g, '');
151
183
  }
@@ -157,7 +189,7 @@ export function createShell() {
157
189
  }
158
190
 
159
191
  function renderSuggestions() {
160
- if (!suggestions.length || promptMode) {
192
+ if (!suggestions.length) {
161
193
  suggestBox.hide();
162
194
  return;
163
195
  }
@@ -168,13 +200,18 @@ export function createShell() {
168
200
  Math.max(0, suggestions.length - maxItems)
169
201
  );
170
202
  const width = Math.max(40, (screen.width ?? 100) - 6);
203
+ // Semantic styling:
204
+ // active row → accent ›, white bold label, muted description (clearly the one in focus)
205
+ // inactive row → blank marker, muted label, subtle description (recedes)
206
+ // suggestIndex === -1 means "no active selection" (passive listing after a soft Enter on /run).
171
207
  const lines = suggestions.slice(start, start + maxItems).map((item, idx) => {
172
- const active = start + idx === suggestIndex;
173
- const marker = active ? `{${C.violet}-fg}›{/}` : `{${C.ghost}-fg} {/}`;
208
+ const active = suggestIndex >= 0 && start + idx === suggestIndex;
209
+ const marker = active ? `{${S.accent}-fg}{bold}›{/}` : ' ';
174
210
  const label = active
175
- ? `{${C.pink}-fg}${item.label}{/}`
176
- : `{${C.dimV}-fg}${item.label}{/}`;
177
- const desc = item.description ? ` {${C.ghost}-fg}${item.description}{/}` : '';
211
+ ? `{${S.text}-fg}{bold}${item.label}{/}`
212
+ : `{${S.muted}-fg}${item.label}{/}`;
213
+ const descColor = active ? S.muted : S.subtle;
214
+ const desc = item.description ? ` {${descColor}-fg}${item.description}{/}` : '';
178
215
  const visible = stripTags(`${marker} ${item.label}${item.description ? ` ${item.description}` : ''}`);
179
216
  const clippedDesc = visible.length > width ? '' : desc;
180
217
  return `${marker} ${label}${clippedDesc}`;
@@ -184,17 +221,21 @@ export function createShell() {
184
221
  suggestBox.show();
185
222
  }
186
223
 
187
- async function refreshSuggestions({ applySingle = false } = {}) {
188
- if (!completer || promptMode) return false;
224
+ async function refreshSuggestions({ applySingle = false, passive = false } = {}) {
225
+ // Prompt-scoped completer (set via shell.prompt({ completer })) takes precedence,
226
+ // so per-field autocompletes don't leak into the global slash-command completer.
227
+ const activeCompleter = promptMode?.completer || completer;
228
+ if (!activeCompleter) return false;
189
229
 
190
230
  const seq = ++completeSeq;
191
- const result = await completer({ buffer, cursor: buffer.length });
231
+ const result = await activeCompleter({ buffer, cursor: buffer.length });
192
232
  if (seq !== completeSeq) return false;
193
233
 
194
234
  suggestions = Array.isArray(result)
195
235
  ? result.filter((s) => s && typeof s.value === 'string' && typeof s.label === 'string')
196
236
  : [];
197
- suggestIndex = 0;
237
+ // passive=true: shown as a list with no active row; Enter will submit, not apply.
238
+ suggestIndex = passive ? -1 : 0;
198
239
 
199
240
  if (applySingle && suggestions.length === 1) {
200
241
  applySuggestion(suggestions[0]);
@@ -210,6 +251,7 @@ export function createShell() {
210
251
  if (!item) return false;
211
252
  buffer = item.value;
212
253
  hideSuggestions();
254
+ runAwaitingSecondEnter = false;
213
255
  updatePrompt();
214
256
  return true;
215
257
  }
@@ -217,21 +259,29 @@ export function createShell() {
217
259
  function updatePrompt() {
218
260
  if (promptMode) {
219
261
  const message = String(promptMode.message || '');
262
+ // If the caller passed pre-tagged content, respect it. Otherwise the message
263
+ // belongs to the "awaiting input" state → bold + warning to match the ambient frame.
220
264
  const renderedMessage = message.includes('{')
221
265
  ? message
222
- : `{${C.dimV}-fg}${message}{/}`;
266
+ : `{${S.warning}-fg}{bold}${message}{/}`;
223
267
  const marker = message.includes('Debug action')
224
268
  ? ''
225
- : `{${C.pink}-fg}?{/} `;
269
+ : `{${S.warning}-fg}{bold}?{/} `;
270
+ // Ghost-text default: when the buffer is empty and the caller supplied a
271
+ // `default` value, render it in subtle after the cursor so it reads as a
272
+ // suggestion. Pressing Enter on an empty buffer accepts the default.
273
+ const ghost = (!buffer && promptMode.default)
274
+ ? `{${S.subtle}-fg}${promptMode.default}{/}`
275
+ : '';
226
276
  promptBox.setContent(
227
- `${marker}${renderedMessage} {${C.dimV}-fg}›{/} ${buffer}{${C.violet}-fg}▌{/}`
277
+ `${marker}${renderedMessage} {${S.muted}-fg}›{/} ${buffer}{${S.accent}-fg}▌{/}${ghost}`
228
278
  );
229
279
  } else {
230
280
  if (buffer) {
231
- promptBox.setContent(`{${C.dimV}-fg}›{/} ${buffer}{${C.violet}-fg}▌{/}`);
281
+ promptBox.setContent(`{${S.muted}-fg}›{/} ${buffer}{${S.accent}-fg}▌{/}`);
232
282
  } else {
233
283
  const hint = history.length === 0 ? inputHints[0] : inputHints[hintIndex];
234
- promptBox.setContent(`{${C.dimV}-fg}›{/} {${C.violet}-fg}▌{/}{#797C81-fg}${hint}{/}`);
284
+ promptBox.setContent(`{${S.muted}-fg}›{/} {${S.accent}-fg}▌{/}{${S.subtle}-fg}${hint}{/}`);
235
285
  }
236
286
  }
237
287
  screen.render();
@@ -348,18 +398,26 @@ export function createShell() {
348
398
  if (!inputEnabled && !promptMode) return;
349
399
 
350
400
  if (promptMode && key.name === 'escape') {
351
- const { resolve, message } = promptMode;
401
+ // In a prompt with autocomplete open, Esc first closes the suggestions instead
402
+ // of cancelling the prompt itself (matches the global-mode behavior).
403
+ if (suggestions.length) {
404
+ hideSuggestions();
405
+ updatePrompt();
406
+ return;
407
+ }
408
+ const { resolve, message, silent } = promptMode;
352
409
  promptMode = null;
353
410
  buffer = '';
354
- log(`{${C.ghost}-fg}↩ cancelled{/} {${C.dimV}-fg}${message}{/}`);
411
+ if (!silent) log(`{${S.subtle}-fg}↩ cancelled{/} {${S.muted}-fg}${message}{/}`);
355
412
  updatePrompt();
356
413
  resolve('__SINGLETON_ESC__');
357
414
  return;
358
415
  }
359
416
 
360
- if (!promptMode && key.name === 'tab') {
417
+ if (key.name === 'tab' && (promptMode?.completer || (!promptMode && completer))) {
361
418
  if (suggestions.length > 1) {
362
- suggestIndex = (suggestIndex + 1) % suggestions.length;
419
+ // From the passive -1 state, Tab focuses the first item rather than skipping it.
420
+ suggestIndex = suggestIndex < 0 ? 0 : (suggestIndex + 1) % suggestions.length;
363
421
  renderSuggestions();
364
422
  screen.render();
365
423
  return;
@@ -368,9 +426,11 @@ export function createShell() {
368
426
  return;
369
427
  }
370
428
 
371
- if (!promptMode && suggestions.length && (key.name === 'down' || key.name === 'up')) {
429
+ if (suggestions.length && (key.name === 'down' || key.name === 'up')) {
372
430
  const dir = key.name === 'down' ? 1 : -1;
373
- suggestIndex = (suggestIndex + dir + suggestions.length) % suggestions.length;
431
+ // From the passive -1 state, the first arrow lands on item 0 (down) or last (up).
432
+ if (suggestIndex < 0) suggestIndex = dir === 1 ? 0 : suggestions.length - 1;
433
+ else suggestIndex = (suggestIndex + dir + suggestions.length) % suggestions.length;
374
434
  renderSuggestions();
375
435
  screen.render();
376
436
  return;
@@ -382,9 +442,14 @@ export function createShell() {
382
442
  return;
383
443
  }
384
444
 
385
- if (!promptMode && suggestions.length && (key.name === 'right' || key.name === 'enter' || key.name === 'return')) {
386
- applySuggestion();
387
- return;
445
+ if (suggestions.length && (key.name === 'right' || key.name === 'enter' || key.name === 'return')) {
446
+ // Passive listing (no active row) → Enter falls through to the submit handler below.
447
+ if (suggestIndex < 0 && (key.name === 'enter' || key.name === 'return')) {
448
+ // fall through
449
+ } else {
450
+ applySuggestion();
451
+ return;
452
+ }
388
453
  }
389
454
 
390
455
  if (!promptMode && !suggestions.length && (key.full === 'C-p' || key.full === 'C-n')) {
@@ -415,14 +480,34 @@ export function createShell() {
415
480
 
416
481
  if (key.name === 'enter' || key.name === 'return') {
417
482
  const value = buffer.trim();
483
+
484
+ // Two-step submit for /run <pipeline>: first Enter opens flag suggestions passively,
485
+ // second Enter submits. Guarded by runAwaitingSecondEnter so dismissing the suggestions
486
+ // (Esc) and pressing Enter again doesn't re-loop.
487
+ if (
488
+ !promptMode &&
489
+ !runAwaitingSecondEnter &&
490
+ /^\/run\s+\S+\s*$/.test(value) &&
491
+ !value.includes(' --')
492
+ ) {
493
+ if (!buffer.endsWith(' ')) buffer += ' ';
494
+ runAwaitingSecondEnter = true;
495
+ updatePrompt();
496
+ await refreshSuggestions({ passive: true });
497
+ return;
498
+ }
499
+ runAwaitingSecondEnter = false;
500
+
418
501
  buffer = '';
419
502
  hideSuggestions();
420
503
  if (promptMode) {
421
- const { resolve, message } = promptMode;
504
+ const { resolve, message, silent, default: promptDefault } = promptMode;
505
+ // Empty submission with a ghost-text default → resolve with the default.
506
+ const finalValue = (value === '' && promptDefault) ? promptDefault : value;
422
507
  promptMode = null;
423
- log(`{${C.pink}-fg}?{/} {${C.dimV}-fg}${message}{/} ${value}`);
508
+ if (!silent) log(`{${S.warning}-fg}{bold}?{/} {${S.muted}-fg}${message}{/} ${finalValue}`);
424
509
  updatePrompt();
425
- resolve(value);
510
+ resolve(finalValue);
426
511
  } else {
427
512
  updatePrompt();
428
513
  if (value) {
@@ -436,14 +521,28 @@ export function createShell() {
436
521
  }
437
522
  } else if (key.name === 'backspace') {
438
523
  buffer = buffer.slice(0, -1);
439
- hideSuggestions();
440
524
  resetHistoryNav();
441
- updatePrompt();
525
+ runAwaitingSecondEnter = false;
526
+ // In a prompt with a completer, keystrokes re-filter the suggestions instead
527
+ // of dismissing them. In all other modes, typing hides the suggest panel.
528
+ if (promptMode?.completer) {
529
+ updatePrompt();
530
+ await refreshSuggestions({ passive: true });
531
+ } else {
532
+ hideSuggestions();
533
+ updatePrompt();
534
+ }
442
535
  } else if (ch && !key.ctrl && !key.meta) {
443
536
  buffer += ch;
444
- hideSuggestions();
445
537
  resetHistoryNav();
446
- updatePrompt();
538
+ runAwaitingSecondEnter = false;
539
+ if (promptMode?.completer) {
540
+ updatePrompt();
541
+ await refreshSuggestions({ passive: true });
542
+ } else {
543
+ hideSuggestions();
544
+ updatePrompt();
545
+ }
447
546
  }
448
547
  });
449
548
 
@@ -454,14 +553,75 @@ export function createShell() {
454
553
 
455
554
  updatePrompt();
456
555
 
556
+ // Mode → border color mapping. Drives the "ambient state" frame around the log panel.
557
+ // null/'idle' → S.border (faint, structural)
558
+ // 'running' → S.keyword (blue, run in progress)
559
+ // 'awaiting' → S.warning (orange, waiting for human input)
560
+ // 'error' → S.error (red, last run failed)
561
+ // 'debug' → S.warning (orange, debug mode active)
562
+ // Label overlay shown on the top border of the pipeline log frame.
563
+ // Timeline writes the step indicator (step X/N). The executor can override
564
+ // with "input waiting" while a prompt is pending — overrides are sticky
565
+ // until cleared, so the timeline's spinner-tick re-renders don't clobber them.
566
+ let pipelineLabelOverride = null;
567
+ function writePipelineLabel(text) {
568
+ if (!text) {
569
+ pipelineLabel.setContent('');
570
+ pipelineLabel.hide();
571
+ } else {
572
+ pipelineLabel.setContent(` ${text} `);
573
+ pipelineLabel.show();
574
+ }
575
+ screen.render();
576
+ }
577
+ function applyPipelineLabel(text) {
578
+ if (pipelineLabelOverride !== null) return;
579
+ writePipelineLabel(text);
580
+ }
581
+ function setPipelineLabel(text) {
582
+ pipelineLabelOverride = text;
583
+ writePipelineLabel(`{${S.warning}-fg}{bold}${text}{/}`);
584
+ }
585
+ function clearPipelineLabel() {
586
+ pipelineLabelOverride = null;
587
+ writePipelineLabel('');
588
+ }
589
+
590
+ // Mode → border color. Two-layer state:
591
+ // baseMode — ambient mode set by the executor ('running' during a step, etc.)
592
+ // currentMode — what is actually painted; prompts override to 'awaiting' and restore baseMode on resolve.
593
+ // Removed 'debug' as its own mode: a debug pause IS an awaiting state, a running debug step IS running.
594
+ let baseMode = null;
595
+ function applyMode(mode) {
596
+ const map = {
597
+ running: S.keyword,
598
+ awaiting: S.warning,
599
+ error: S.error,
600
+ };
601
+ const color = map[mode] || S.border;
602
+ content.style.border.fg = color;
603
+ pipelineLog.style.border.fg = color;
604
+ sep1.style.fg = color;
605
+ sep2.style.fg = color;
606
+ screen.render();
607
+ }
608
+ function setMode(mode) {
609
+ baseMode = mode;
610
+ applyMode(mode);
611
+ }
612
+
457
613
  return {
458
614
  log,
459
- logMuted(text) { log(`{${C.dimV}-fg}${text}{/}`); },
460
- logAccent(text) { log(`{${C.violet}-fg}${text}{/}`); },
615
+ logMuted(text) { log(`{${S.muted}-fg}${text}{/}`); },
616
+ logAccent(text) { log(`{${S.accent}-fg}${text}{/}`); },
461
617
  setFooter,
462
618
  setFooterCenter,
619
+ setMode,
620
+ setPipelineLabel,
621
+ clearPipelineLabel,
463
622
 
464
623
  clear() { content.setContent(''); screen.render(); },
624
+ setContent(text) { content.setContent(text); screen.render(); },
465
625
  onCommand(fn) { onSubmit = fn; },
466
626
  setCompleter(fn) { completer = fn; },
467
627
 
@@ -499,17 +659,45 @@ export function createShell() {
499
659
  disableInput() { inputEnabled = false; hideSuggestions(); resetHistoryNav(); screen.render(); },
500
660
  enableInput() { inputEnabled = true; buffer = ''; hideSuggestions(); resetHistoryNav(); updatePrompt(); },
501
661
 
502
- prompt(message) {
662
+ prompt(message, { silent = false, completer: promptCompleter = null, default: promptDefault = '' } = {}) {
503
663
  return new Promise((resolve) => {
504
- promptMode = { resolve, message };
664
+ // Override ambient mode to 'awaiting' (orange) for the duration of the prompt,
665
+ // and restore the baseMode (e.g. 'running') once the user has answered.
666
+ const shouldOverride = baseMode === 'running';
667
+ if (shouldOverride) applyMode('awaiting');
668
+ promptMode = {
669
+ message,
670
+ silent,
671
+ completer: promptCompleter,
672
+ default: promptDefault,
673
+ resolve: (value) => {
674
+ if (shouldOverride) applyMode(baseMode);
675
+ resolve(value);
676
+ },
677
+ };
505
678
  buffer = '';
506
679
  hideSuggestions();
507
680
  resetHistoryNav();
508
681
  updatePrompt();
682
+ // With a prompt-scoped completer, show the full suggestion list immediately
683
+ // so the user sees what's available without having to press Tab first.
684
+ // Use passive mode so Enter submits the typed value rather than applying
685
+ // the first row — Tab/arrows are the explicit "pick" path.
686
+ if (promptCompleter) {
687
+ refreshSuggestions({ passive: true }).catch(() => {});
688
+ }
509
689
  });
510
690
  },
511
691
 
512
- pipelineWidgets: { screen, logPanel: pipelineLog, statusBox: pipelineStatus },
692
+ // Mirror sends every timeline.log call into the main `content` widget too, so the full
693
+ // run history survives exitPipelineMode (pipelineLog gets hidden, but content keeps it).
694
+ pipelineWidgets: {
695
+ screen,
696
+ logPanel: pipelineLog,
697
+ statusBox: pipelineStatus,
698
+ setLabel: applyPipelineLabel,
699
+ mirror: (text) => content.log(text),
700
+ },
513
701
 
514
702
  enterPipelineMode() {
515
703
  pipelineMode = true;
@@ -517,17 +705,19 @@ export function createShell() {
517
705
  pipelineLog.setContent('');
518
706
  pipelineStatus.setContent('');
519
707
  pipelineLog.show();
520
- pipelineSep.show();
521
708
  pipelineStatus.show();
522
- promptBox.setContent(`{${C.dimV}-fg}scroll: ↑ ↓ pgup pgdn home end{/}`);
709
+ pipelineLabel.show();
710
+ promptBox.setContent('');
523
711
  screen.render();
524
712
  },
525
713
 
526
714
  exitPipelineMode() {
527
715
  pipelineMode = false;
716
+ pipelineLabelOverride = null;
528
717
  pipelineLog.hide();
529
- pipelineSep.hide();
530
718
  pipelineStatus.hide();
719
+ pipelineLabel.hide();
720
+ pipelineLabel.setContent('');
531
721
  content.show();
532
722
  updatePrompt();
533
723
  screen.render();