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.
- package/CHANGELOG.md +49 -0
- package/README.md +170 -129
- package/docs/reference.md +63 -18
- package/package.json +3 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/new.js +455 -109
- package/packages/cli/src/commands/repl.js +86 -89
- package/packages/cli/src/executor/debug-loop.js +587 -0
- package/packages/cli/src/executor/inputs.js +202 -0
- package/packages/cli/src/executor/outputs.js +140 -0
- package/packages/cli/src/executor/preflight.js +459 -0
- package/packages/cli/src/executor/replay-loop.js +172 -0
- package/packages/cli/src/executor/run-report.js +189 -0
- package/packages/cli/src/executor/run-setup.js +93 -0
- package/packages/cli/src/executor/security-review.js +108 -0
- package/packages/cli/src/executor/snapshot-manager.js +335 -0
- package/packages/cli/src/executor/step-runner.js +266 -0
- package/packages/cli/src/executor.js +233 -2228
- package/packages/cli/src/index.js +1 -1
- package/packages/cli/src/runners/claude.js +6 -3
- package/packages/cli/src/runners/codex.js +6 -3
- package/packages/cli/src/runners/copilot.js +25 -9
- package/packages/cli/src/runners/opencode.js +1 -1
- package/packages/cli/src/shell.js +244 -54
- package/packages/cli/src/timeline.js +54 -20
- package/packages/server/package.json +1 -1
- package/packages/web/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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.
|
|
101
|
-
|
|
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
|
|
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.
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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:
|
|
110
|
-
turns:
|
|
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
|
-
|
|
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) => {
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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 ? `{${
|
|
208
|
+
const active = suggestIndex >= 0 && start + idx === suggestIndex;
|
|
209
|
+
const marker = active ? `{${S.accent}-fg}{bold}›{/}` : ' ';
|
|
174
210
|
const label = active
|
|
175
|
-
? `{${
|
|
176
|
-
: `{${
|
|
177
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
: `{${
|
|
266
|
+
: `{${S.warning}-fg}{bold}${message}{/}`;
|
|
223
267
|
const marker = message.includes('Debug action')
|
|
224
268
|
? ''
|
|
225
|
-
: `{${
|
|
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} {${
|
|
277
|
+
`${marker}${renderedMessage} {${S.muted}-fg}›{/} ${buffer}{${S.accent}-fg}▌{/}${ghost}`
|
|
228
278
|
);
|
|
229
279
|
} else {
|
|
230
280
|
if (buffer) {
|
|
231
|
-
promptBox.setContent(`{${
|
|
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(`{${
|
|
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
|
-
|
|
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(`{${
|
|
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 (
|
|
417
|
+
if (key.name === 'tab' && (promptMode?.completer || (!promptMode && completer))) {
|
|
361
418
|
if (suggestions.length > 1) {
|
|
362
|
-
|
|
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 (
|
|
429
|
+
if (suggestions.length && (key.name === 'down' || key.name === 'up')) {
|
|
372
430
|
const dir = key.name === 'down' ? 1 : -1;
|
|
373
|
-
|
|
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 (
|
|
386
|
-
|
|
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(`{${
|
|
508
|
+
if (!silent) log(`{${S.warning}-fg}{bold}?{/} {${S.muted}-fg}${message}{/} ${finalValue}`);
|
|
424
509
|
updatePrompt();
|
|
425
|
-
resolve(
|
|
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
|
-
|
|
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
|
-
|
|
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(`{${
|
|
460
|
-
logAccent(text) { log(`{${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|