@compilr-dev/cli 0.7.4 → 0.7.5
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/agent.d.ts +7 -2
- package/dist/agent.js +55 -39
- package/dist/commands-v2/handlers/project.js +65 -2
- package/dist/commands-v2/handlers/settings.js +18 -26
- package/dist/compilr-diff-companion.vsix +0 -0
- package/dist/db/schema.d.ts +1 -1
- package/dist/episodes/index.d.ts +13 -13
- package/dist/episodes/index.js +13 -14
- package/dist/guide/cli-guide-entries.js +54 -2
- package/dist/handlers/delegation-handlers.js +228 -50
- package/dist/handlers/interactive-flow-handlers.d.ts +26 -0
- package/dist/handlers/interactive-flow-handlers.js +61 -0
- package/dist/handlers/propose-alternatives-handlers.d.ts +36 -0
- package/dist/handlers/propose-alternatives-handlers.js +65 -0
- package/dist/index.js +13 -2
- package/dist/repl-v2.d.ts +66 -0
- package/dist/repl-v2.js +382 -53
- package/dist/shared-handlers.d.ts +57 -5
- package/dist/shared-handlers.js +50 -0
- package/dist/tools/consult.d.ts +14 -0
- package/dist/tools/consult.js +73 -0
- package/dist/tools/delegate.d.ts +12 -6
- package/dist/tools/delegate.js +35 -19
- package/dist/tools/interactive-flow.d.ts +13 -0
- package/dist/tools/interactive-flow.js +19 -0
- package/dist/tools/platform-adapter.d.ts +14 -2
- package/dist/tools/platform-adapter.js +16 -4
- package/dist/tools/propose-alternatives.d.ts +13 -0
- package/dist/tools/propose-alternatives.js +19 -0
- package/dist/tools.d.ts +22 -98
- package/dist/tools.js +27 -382
- package/dist/ui/markdown-renderer.js +26 -7
- package/dist/ui/overlay/data/tutorials/projects/new-project.js +56 -20
- package/dist/ui/overlay/impl/interactive-flow-overlay-v2.d.ts +79 -0
- package/dist/ui/overlay/impl/interactive-flow-overlay-v2.js +580 -0
- package/dist/ui/overlay/impl/new-overlay-v2.d.ts +32 -0
- package/dist/ui/overlay/impl/new-overlay-v2.js +305 -66
- package/dist/ui/overlay/impl/propose-alternatives-overlay-v2.d.ts +53 -0
- package/dist/ui/overlay/impl/propose-alternatives-overlay-v2.js +326 -0
- package/dist/ui/overlay/index.d.ts +2 -0
- package/dist/ui/overlay/index.js +2 -0
- package/dist/ui/tool-formatters.js +61 -3
- package/package.json +2 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Flow Overlay V2
|
|
3
|
+
*
|
|
4
|
+
* Multi-screen wizard for the `build_interactive_flow` DSL. Walks the
|
|
5
|
+
* user through a tree of nodes (question / info / branch / summary)
|
|
6
|
+
* defined by the agent. Branches are pure routing — never rendered.
|
|
7
|
+
*
|
|
8
|
+
* The state-machine logic (next-resolution, branch evaluation,
|
|
9
|
+
* backtrack) lives in `@compilr-dev/sdk/flow-runner` as pure helpers
|
|
10
|
+
* shared with Desktop. This overlay only handles per-node rendering +
|
|
11
|
+
* keyboard input + the renderer-specific transient state (cursor
|
|
12
|
+
* position, multi-selections, text buffer).
|
|
13
|
+
*
|
|
14
|
+
* Renderer collapses: the SDK has many `render` variants (tile-row,
|
|
15
|
+
* card-grid, segmented, …) that target GUIs. The terminal collapses
|
|
16
|
+
* them all to one rendering per input mode — `render` is ignored, as
|
|
17
|
+
* are `tone`, `density`, `icon`.
|
|
18
|
+
*
|
|
19
|
+
* Phase coverage (per spec):
|
|
20
|
+
* - Phase A: single + text(single-line) + info + summary + branch ✓
|
|
21
|
+
* - Phase B: multi + text(multiline) ✓
|
|
22
|
+
* - Phase C: proposal mode — rendered inline as a numbered list with
|
|
23
|
+
* pros + cons under each option. Same selection UX as 'single' mode
|
|
24
|
+
* (arrows / 1-9 / Enter). Result is the chosen Proposal's id, which
|
|
25
|
+
* `lookupLabel` resolves to the option's label for the summary table.
|
|
26
|
+
*
|
|
27
|
+
* Spec: project-docs/00-requirements/compilr-dev-cli/interactive-flow-cli-spec.md
|
|
28
|
+
*/
|
|
29
|
+
import { BaseOverlayV2 } from '../../base/index.js';
|
|
30
|
+
import type { RenderContext, OverlayAction, KeyEvent } from '../types.js';
|
|
31
|
+
import type { Flow, AnswerValue, InteractiveFlowResult, NodeId } from '../../../shared-handlers.js';
|
|
32
|
+
interface FlowState {
|
|
33
|
+
/** Stack of every node visited, in order. Last element is current. */
|
|
34
|
+
path: NodeId[];
|
|
35
|
+
/** Final answers per question node */
|
|
36
|
+
answers: Record<NodeId, AnswerValue>;
|
|
37
|
+
/** Human-readable labels for the answers */
|
|
38
|
+
answerLabels: Record<NodeId, AnswerValue>;
|
|
39
|
+
/** Wall-clock start for durationMs */
|
|
40
|
+
startedAt: number;
|
|
41
|
+
/** Cursor index for single/multi questions */
|
|
42
|
+
selectedIndex: number;
|
|
43
|
+
/** Selections for multi mode (zero-based choice indices) */
|
|
44
|
+
multiSelections: Set<number>;
|
|
45
|
+
/** Text buffer for text-input questions */
|
|
46
|
+
inputBuffer: string;
|
|
47
|
+
/** Last warning message (e.g. multi-select min/max not met) */
|
|
48
|
+
warning: string;
|
|
49
|
+
}
|
|
50
|
+
export interface InteractiveFlowOptionsV2 {
|
|
51
|
+
flow: Flow;
|
|
52
|
+
}
|
|
53
|
+
export type InteractiveFlowResultV2 = InteractiveFlowResult;
|
|
54
|
+
export declare class InteractiveFlowOverlayV2 extends BaseOverlayV2<FlowState, InteractiveFlowResultV2> {
|
|
55
|
+
readonly type: "inline";
|
|
56
|
+
readonly id = "interactive-flow-overlay-v2";
|
|
57
|
+
private readonly flow;
|
|
58
|
+
constructor(options: InteractiveFlowOptionsV2);
|
|
59
|
+
protected renderContent(context: RenderContext): string[];
|
|
60
|
+
handleKey(key: KeyEvent): OverlayAction<InteractiveFlowResultV2>;
|
|
61
|
+
getCloseSummary(result: InteractiveFlowResultV2): string | null;
|
|
62
|
+
private currentNodeId;
|
|
63
|
+
private currentNode;
|
|
64
|
+
private renderQuestion;
|
|
65
|
+
private handleQuestionKey;
|
|
66
|
+
private renderInfo;
|
|
67
|
+
private handleInfoKey;
|
|
68
|
+
private renderSummary;
|
|
69
|
+
private handleSummaryKey;
|
|
70
|
+
private advance;
|
|
71
|
+
private advanceInfo;
|
|
72
|
+
private handleBackOrCancel;
|
|
73
|
+
private resetTransientState;
|
|
74
|
+
private makeCompleted;
|
|
75
|
+
private makeAborted;
|
|
76
|
+
private footerHints;
|
|
77
|
+
private tinted;
|
|
78
|
+
}
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive Flow Overlay V2
|
|
3
|
+
*
|
|
4
|
+
* Multi-screen wizard for the `build_interactive_flow` DSL. Walks the
|
|
5
|
+
* user through a tree of nodes (question / info / branch / summary)
|
|
6
|
+
* defined by the agent. Branches are pure routing — never rendered.
|
|
7
|
+
*
|
|
8
|
+
* The state-machine logic (next-resolution, branch evaluation,
|
|
9
|
+
* backtrack) lives in `@compilr-dev/sdk/flow-runner` as pure helpers
|
|
10
|
+
* shared with Desktop. This overlay only handles per-node rendering +
|
|
11
|
+
* keyboard input + the renderer-specific transient state (cursor
|
|
12
|
+
* position, multi-selections, text buffer).
|
|
13
|
+
*
|
|
14
|
+
* Renderer collapses: the SDK has many `render` variants (tile-row,
|
|
15
|
+
* card-grid, segmented, …) that target GUIs. The terminal collapses
|
|
16
|
+
* them all to one rendering per input mode — `render` is ignored, as
|
|
17
|
+
* are `tone`, `density`, `icon`.
|
|
18
|
+
*
|
|
19
|
+
* Phase coverage (per spec):
|
|
20
|
+
* - Phase A: single + text(single-line) + info + summary + branch ✓
|
|
21
|
+
* - Phase B: multi + text(multiline) ✓
|
|
22
|
+
* - Phase C: proposal mode — rendered inline as a numbered list with
|
|
23
|
+
* pros + cons under each option. Same selection UX as 'single' mode
|
|
24
|
+
* (arrows / 1-9 / Enter). Result is the chosen Proposal's id, which
|
|
25
|
+
* `lookupLabel` resolves to the option's label for the summary table.
|
|
26
|
+
*
|
|
27
|
+
* Spec: project-docs/00-requirements/compilr-dev-cli/interactive-flow-cli-spec.md
|
|
28
|
+
*/
|
|
29
|
+
import { resolveNext, walkPastBranches, applyBacktrack, lookupLabel, } from '@compilr-dev/sdk';
|
|
30
|
+
import { BaseOverlayV2, renderBorder, wrapText } from '../../base/index.js';
|
|
31
|
+
import { renderMarkdown } from '../../conversation.js';
|
|
32
|
+
// =============================================================================
|
|
33
|
+
// Overlay
|
|
34
|
+
// =============================================================================
|
|
35
|
+
export class InteractiveFlowOverlayV2 extends BaseOverlayV2 {
|
|
36
|
+
type = 'inline';
|
|
37
|
+
id = 'interactive-flow-overlay-v2';
|
|
38
|
+
flow;
|
|
39
|
+
constructor(options) {
|
|
40
|
+
const initialPath = walkPastBranches(options.flow, [options.flow.startNode], {});
|
|
41
|
+
super({
|
|
42
|
+
path: initialPath,
|
|
43
|
+
answers: {},
|
|
44
|
+
answerLabels: {},
|
|
45
|
+
startedAt: Date.now(),
|
|
46
|
+
selectedIndex: 0,
|
|
47
|
+
multiSelections: new Set(),
|
|
48
|
+
inputBuffer: '',
|
|
49
|
+
warning: '',
|
|
50
|
+
});
|
|
51
|
+
this.flow = options.flow;
|
|
52
|
+
this.minHeight = 22;
|
|
53
|
+
}
|
|
54
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Public API
|
|
56
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
57
|
+
renderContent(context) {
|
|
58
|
+
const s = context.styles;
|
|
59
|
+
const cols = context.width;
|
|
60
|
+
const border = renderBorder(cols, s);
|
|
61
|
+
const lines = [];
|
|
62
|
+
const node = this.currentNode();
|
|
63
|
+
// Header
|
|
64
|
+
lines.push(border);
|
|
65
|
+
for (const line of wrapText(this.flow.title, cols - 2)) {
|
|
66
|
+
lines.push(' ' + s.primaryBold(line));
|
|
67
|
+
}
|
|
68
|
+
if (this.flow.description) {
|
|
69
|
+
for (const line of wrapText(this.flow.description, cols - 4)) {
|
|
70
|
+
lines.push(' ' + s.muted(line));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
lines.push('');
|
|
74
|
+
// Body — dispatch on node type
|
|
75
|
+
if (node === undefined) {
|
|
76
|
+
lines.push(' ' + s.error('Malformed flow: current node is missing'));
|
|
77
|
+
}
|
|
78
|
+
else if (node.type === 'question') {
|
|
79
|
+
lines.push(...this.renderQuestion(node, s, cols));
|
|
80
|
+
}
|
|
81
|
+
else if (node.type === 'info') {
|
|
82
|
+
lines.push(...this.renderInfo(node, s, cols));
|
|
83
|
+
}
|
|
84
|
+
else if (node.type === 'summary') {
|
|
85
|
+
lines.push(...this.renderSummary(node, s, cols));
|
|
86
|
+
}
|
|
87
|
+
// Footer / warnings
|
|
88
|
+
lines.push('');
|
|
89
|
+
if (this.state.warning) {
|
|
90
|
+
lines.push(' ' + s.warning(`! ${this.state.warning}`));
|
|
91
|
+
lines.push('');
|
|
92
|
+
}
|
|
93
|
+
lines.push(' ' + s.muted(this.footerHints(node)));
|
|
94
|
+
lines.push(border);
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
97
|
+
handleKey(key) {
|
|
98
|
+
// Ctrl+C — hard cancel
|
|
99
|
+
if (key.ctrl && key.name === 'c') {
|
|
100
|
+
return this.close(this.makeAborted('escape'));
|
|
101
|
+
}
|
|
102
|
+
const node = this.currentNode();
|
|
103
|
+
if (node === undefined) {
|
|
104
|
+
// Malformed flow — close as aborted
|
|
105
|
+
return this.close(this.makeAborted('unknown'));
|
|
106
|
+
}
|
|
107
|
+
// 'q' — soft cancel from any node
|
|
108
|
+
if (key.char === 'q' && !key.ctrl && !key.meta) {
|
|
109
|
+
return this.close(this.makeAborted('escape'));
|
|
110
|
+
}
|
|
111
|
+
// Esc — back one user-visible step, or cancel if no prior visible node
|
|
112
|
+
if (key.name === 'escape') {
|
|
113
|
+
return this.handleBackOrCancel();
|
|
114
|
+
}
|
|
115
|
+
if (node.type === 'question') {
|
|
116
|
+
return this.handleQuestionKey(node, key);
|
|
117
|
+
}
|
|
118
|
+
if (node.type === 'info') {
|
|
119
|
+
return this.handleInfoKey(node, key);
|
|
120
|
+
}
|
|
121
|
+
if (node.type === 'summary') {
|
|
122
|
+
return this.handleSummaryKey(key);
|
|
123
|
+
}
|
|
124
|
+
return this.noAction();
|
|
125
|
+
}
|
|
126
|
+
getCloseSummary(result) {
|
|
127
|
+
const s = this.getStyles();
|
|
128
|
+
if (!result.completed) {
|
|
129
|
+
const stepCount = result.path.length;
|
|
130
|
+
return s.muted('Flow: ') + s.warning(`cancelled at step ${String(stepCount)}`);
|
|
131
|
+
}
|
|
132
|
+
const stepCount = result.path.length;
|
|
133
|
+
return s.muted('Flow: ') + s.success(`completed — ${String(stepCount)} steps`);
|
|
134
|
+
}
|
|
135
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Current node helpers
|
|
137
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
138
|
+
currentNodeId() {
|
|
139
|
+
return this.state.path[this.state.path.length - 1];
|
|
140
|
+
}
|
|
141
|
+
currentNode() {
|
|
142
|
+
const id = this.currentNodeId();
|
|
143
|
+
if (id === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
return this.flow.nodes[id];
|
|
146
|
+
}
|
|
147
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// QuestionNode rendering & input
|
|
149
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
150
|
+
renderQuestion(node, s, cols) {
|
|
151
|
+
const out = [];
|
|
152
|
+
for (const line of wrapText(node.prompt, cols - 2)) {
|
|
153
|
+
out.push(' ' + s.primaryBold(line));
|
|
154
|
+
}
|
|
155
|
+
if (node.description) {
|
|
156
|
+
for (const line of wrapText(node.description, cols - 4)) {
|
|
157
|
+
out.push(' ' + s.muted(line));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
out.push('');
|
|
161
|
+
const mode = node.input.mode;
|
|
162
|
+
if (mode === 'single') {
|
|
163
|
+
for (let i = 0; i < node.input.choices.length; i++) {
|
|
164
|
+
const choice = node.input.choices[i];
|
|
165
|
+
const isCursor = i === this.state.selectedIndex;
|
|
166
|
+
const cursor = isCursor ? ' ▶' : ' ';
|
|
167
|
+
const num = i + 1 <= 9 ? ` ${String(i + 1)} ` : ' ';
|
|
168
|
+
const label = this.tinted(choice.label, choice.tint, s);
|
|
169
|
+
out.push(`${cursor}${num}${label}`);
|
|
170
|
+
if (choice.description) {
|
|
171
|
+
for (const line of wrapText(choice.description, cols - 8)) {
|
|
172
|
+
out.push(' ' + s.muted(line));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
if (mode === 'multi') {
|
|
179
|
+
for (let i = 0; i < node.input.choices.length; i++) {
|
|
180
|
+
const choice = node.input.choices[i];
|
|
181
|
+
const isCursor = i === this.state.selectedIndex;
|
|
182
|
+
const isSelected = this.state.multiSelections.has(i);
|
|
183
|
+
const cursor = isCursor ? ' ▶' : ' ';
|
|
184
|
+
const box = isSelected ? '[x]' : '[ ]';
|
|
185
|
+
const num = i + 1 <= 9 ? ` ${String(i + 1)} ` : ' ';
|
|
186
|
+
const label = this.tinted(choice.label, choice.tint, s);
|
|
187
|
+
out.push(`${cursor} ${box}${num}${label}`);
|
|
188
|
+
if (choice.description) {
|
|
189
|
+
for (const line of wrapText(choice.description, cols - 10)) {
|
|
190
|
+
out.push(' ' + s.muted(line));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
out.push('');
|
|
195
|
+
const min = node.input.min;
|
|
196
|
+
const max = node.input.max;
|
|
197
|
+
const constraintParts = [];
|
|
198
|
+
if (min !== undefined)
|
|
199
|
+
constraintParts.push(`min ${String(min)}`);
|
|
200
|
+
if (max !== undefined)
|
|
201
|
+
constraintParts.push(`max ${String(max)}`);
|
|
202
|
+
const constraints = constraintParts.length > 0 ? ` (${constraintParts.join(', ')})` : '';
|
|
203
|
+
out.push(' ' + s.muted(`Selected: ${String(this.state.multiSelections.size)}${constraints}`));
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
if (mode === 'text') {
|
|
207
|
+
const isMultiline = node.input.multiline === true;
|
|
208
|
+
const placeholder = node.input.placeholder ?? '';
|
|
209
|
+
if (isMultiline) {
|
|
210
|
+
// Multi-line: show buffer in a box. Up to 8 lines visible.
|
|
211
|
+
const bufLines = this.state.inputBuffer.split('\n');
|
|
212
|
+
const display = bufLines.length === 1 && bufLines[0].length === 0
|
|
213
|
+
? [placeholder ? s.muted(placeholder) : '']
|
|
214
|
+
: bufLines;
|
|
215
|
+
const visible = display.slice(-8);
|
|
216
|
+
for (const line of visible) {
|
|
217
|
+
out.push(' ▶ ' + line);
|
|
218
|
+
}
|
|
219
|
+
out.push(' ' + s.muted(' (Ctrl+D submits, Enter adds a line)'));
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
const display = this.state.inputBuffer.length === 0 && placeholder
|
|
223
|
+
? s.muted(placeholder)
|
|
224
|
+
: this.state.inputBuffer;
|
|
225
|
+
out.push(' ▶ ' + display + '_');
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
// mode === 'proposal' — numbered list with pros/cons inline.
|
|
229
|
+
// Same selection UX as 'single' mode (arrows / 1-9 / Enter) but each
|
|
230
|
+
// option also surfaces its pros + cons below the label so the user can
|
|
231
|
+
// compare without leaving the flow modal.
|
|
232
|
+
for (let i = 0; i < node.input.options.length; i++) {
|
|
233
|
+
const opt = node.input.options[i];
|
|
234
|
+
const isCursor = i === this.state.selectedIndex;
|
|
235
|
+
const cursor = isCursor ? ' ▶' : ' ';
|
|
236
|
+
const num = i + 1 <= 9 ? ` ${String(i + 1)} ` : ' ';
|
|
237
|
+
const label = this.tinted(opt.label, opt.tint, s);
|
|
238
|
+
out.push(`${cursor}${num}${label}`);
|
|
239
|
+
if (opt.pros && opt.pros.length > 0) {
|
|
240
|
+
for (const pro of opt.pros) {
|
|
241
|
+
for (const w of wrapText(pro, cols - 10)) {
|
|
242
|
+
out.push(' ' + s.success('+ ' + w));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (opt.cons && opt.cons.length > 0) {
|
|
247
|
+
for (const con of opt.cons) {
|
|
248
|
+
for (const w of wrapText(con, cols - 10)) {
|
|
249
|
+
out.push(' ' + s.warning('− ' + w));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Spacer between options (skip after last to keep footer tight)
|
|
254
|
+
if (i < node.input.options.length - 1)
|
|
255
|
+
out.push('');
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
handleQuestionKey(node, key) {
|
|
260
|
+
const mode = node.input.mode;
|
|
261
|
+
if (mode === 'proposal') {
|
|
262
|
+
// node.input is the proposal variant (TS narrows via the mode check)
|
|
263
|
+
const input = node.input;
|
|
264
|
+
const count = input.options.length;
|
|
265
|
+
if (key.name === 'up') {
|
|
266
|
+
this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
|
|
267
|
+
return this.rerender();
|
|
268
|
+
}
|
|
269
|
+
if (key.name === 'down') {
|
|
270
|
+
this.state.selectedIndex = Math.min(count - 1, this.state.selectedIndex + 1);
|
|
271
|
+
return this.rerender();
|
|
272
|
+
}
|
|
273
|
+
if (key.char && /^[1-9]$/.test(key.char)) {
|
|
274
|
+
const idx = parseInt(key.char, 10) - 1;
|
|
275
|
+
if (idx < count) {
|
|
276
|
+
this.state.selectedIndex = idx;
|
|
277
|
+
return this.rerender();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
281
|
+
const opt = input.options[this.state.selectedIndex];
|
|
282
|
+
return this.advance(opt.id, lookupLabel(node, opt.id));
|
|
283
|
+
}
|
|
284
|
+
return this.noAction();
|
|
285
|
+
}
|
|
286
|
+
if (mode === 'single') {
|
|
287
|
+
const count = node.input.choices.length;
|
|
288
|
+
if (key.name === 'up') {
|
|
289
|
+
this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
|
|
290
|
+
return this.rerender();
|
|
291
|
+
}
|
|
292
|
+
if (key.name === 'down') {
|
|
293
|
+
this.state.selectedIndex = Math.min(count - 1, this.state.selectedIndex + 1);
|
|
294
|
+
return this.rerender();
|
|
295
|
+
}
|
|
296
|
+
if (key.char && /^[1-9]$/.test(key.char)) {
|
|
297
|
+
const idx = parseInt(key.char, 10) - 1;
|
|
298
|
+
if (idx < count) {
|
|
299
|
+
this.state.selectedIndex = idx;
|
|
300
|
+
return this.rerender();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
304
|
+
const choice = node.input.choices[this.state.selectedIndex];
|
|
305
|
+
return this.advance(choice.id, lookupLabel(node, choice.id));
|
|
306
|
+
}
|
|
307
|
+
return this.noAction();
|
|
308
|
+
}
|
|
309
|
+
if (mode === 'multi') {
|
|
310
|
+
// node.input is the multi variant here (TS narrows via mode check)
|
|
311
|
+
const input = node.input;
|
|
312
|
+
const count = input.choices.length;
|
|
313
|
+
if (key.name === 'up') {
|
|
314
|
+
this.state.selectedIndex = Math.max(0, this.state.selectedIndex - 1);
|
|
315
|
+
return this.rerender();
|
|
316
|
+
}
|
|
317
|
+
if (key.name === 'down') {
|
|
318
|
+
this.state.selectedIndex = Math.min(count - 1, this.state.selectedIndex + 1);
|
|
319
|
+
return this.rerender();
|
|
320
|
+
}
|
|
321
|
+
if (key.char === ' ' || key.name === 'space') {
|
|
322
|
+
if (this.state.multiSelections.has(this.state.selectedIndex)) {
|
|
323
|
+
this.state.multiSelections.delete(this.state.selectedIndex);
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
this.state.multiSelections.add(this.state.selectedIndex);
|
|
327
|
+
}
|
|
328
|
+
return this.rerender();
|
|
329
|
+
}
|
|
330
|
+
if (key.char && /^[1-9]$/.test(key.char)) {
|
|
331
|
+
const idx = parseInt(key.char, 10) - 1;
|
|
332
|
+
if (idx < count) {
|
|
333
|
+
this.state.selectedIndex = idx;
|
|
334
|
+
return this.rerender();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
338
|
+
const selected = [...this.state.multiSelections].sort((a, b) => a - b);
|
|
339
|
+
const min = input.min ?? 0;
|
|
340
|
+
const max = input.max ?? Infinity;
|
|
341
|
+
if (selected.length < min) {
|
|
342
|
+
this.state.warning = `Pick at least ${String(min)}`;
|
|
343
|
+
return this.rerender();
|
|
344
|
+
}
|
|
345
|
+
if (selected.length > max) {
|
|
346
|
+
this.state.warning = `Pick at most ${String(max)}`;
|
|
347
|
+
return this.rerender();
|
|
348
|
+
}
|
|
349
|
+
const answerIds = selected.map((i) => input.choices[i].id);
|
|
350
|
+
return this.advance(answerIds, lookupLabel(node, answerIds));
|
|
351
|
+
}
|
|
352
|
+
return this.noAction();
|
|
353
|
+
}
|
|
354
|
+
// mode === 'text' (after eliminating single/multi/proposal above).
|
|
355
|
+
// node.input is the text variant; access `multiline` directly.
|
|
356
|
+
const isMultiline = 'multiline' in node.input && node.input.multiline === true;
|
|
357
|
+
if (key.name === 'backspace') {
|
|
358
|
+
this.state.inputBuffer = this.state.inputBuffer.slice(0, -1);
|
|
359
|
+
return this.rerender();
|
|
360
|
+
}
|
|
361
|
+
if (isMultiline) {
|
|
362
|
+
// Ctrl+D submits, Enter adds a newline
|
|
363
|
+
if (key.ctrl && (key.name === 'd' || key.char === 'd')) {
|
|
364
|
+
return this.advance(this.state.inputBuffer, lookupLabel(node, this.state.inputBuffer));
|
|
365
|
+
}
|
|
366
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
367
|
+
this.state.inputBuffer += '\n';
|
|
368
|
+
return this.rerender();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
// Single-line: Enter submits
|
|
373
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
374
|
+
return this.advance(this.state.inputBuffer, lookupLabel(node, this.state.inputBuffer));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (key.char && key.char.length === 1 && !key.ctrl && !key.meta) {
|
|
378
|
+
this.state.inputBuffer += key.char;
|
|
379
|
+
return this.rerender();
|
|
380
|
+
}
|
|
381
|
+
return this.noAction();
|
|
382
|
+
}
|
|
383
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
384
|
+
// InfoNode
|
|
385
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
386
|
+
renderInfo(node, s, cols) {
|
|
387
|
+
const out = [];
|
|
388
|
+
out.push(' ' + s.primaryBold(node.title));
|
|
389
|
+
out.push('');
|
|
390
|
+
let body;
|
|
391
|
+
try {
|
|
392
|
+
body = renderMarkdown(node.body);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
body = node.body;
|
|
396
|
+
}
|
|
397
|
+
for (const line of body.split('\n')) {
|
|
398
|
+
// Wrap each rendered line within terminal width
|
|
399
|
+
for (const w of wrapText(line.trimEnd(), cols - 4)) {
|
|
400
|
+
out.push(' ' + w);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return out;
|
|
404
|
+
}
|
|
405
|
+
handleInfoKey(node, key) {
|
|
406
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
407
|
+
return this.advanceInfo(node);
|
|
408
|
+
}
|
|
409
|
+
return this.noAction();
|
|
410
|
+
}
|
|
411
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
412
|
+
// SummaryNode
|
|
413
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
414
|
+
renderSummary(node, s, cols) {
|
|
415
|
+
const out = [];
|
|
416
|
+
out.push(' ' + s.primaryBold(node.title));
|
|
417
|
+
if (node.recap) {
|
|
418
|
+
out.push('');
|
|
419
|
+
let recap;
|
|
420
|
+
try {
|
|
421
|
+
recap = renderMarkdown(node.recap);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
recap = node.recap;
|
|
425
|
+
}
|
|
426
|
+
for (const line of recap.split('\n')) {
|
|
427
|
+
for (const w of wrapText(line.trimEnd(), cols - 4)) {
|
|
428
|
+
out.push(' ' + w);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
out.push('');
|
|
433
|
+
// Answer table — iterate path entries that are question nodes
|
|
434
|
+
const rows = [];
|
|
435
|
+
for (const id of this.state.path) {
|
|
436
|
+
const n = this.flow.nodes[id];
|
|
437
|
+
// Defensive against missing-node references in path (shouldn't happen
|
|
438
|
+
// but path entries come from runtime, not the type system)
|
|
439
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
440
|
+
if (n && n.type === 'question' && id in this.state.answerLabels) {
|
|
441
|
+
const label = n.prompt.slice(0, 30);
|
|
442
|
+
const raw = this.state.answerLabels[id];
|
|
443
|
+
const value = Array.isArray(raw) ? raw.join(', ') : raw;
|
|
444
|
+
rows.push({ label, value });
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (rows.length === 0) {
|
|
448
|
+
out.push(' ' + s.muted('(no answers collected)'));
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
const maxLabelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
452
|
+
for (const row of rows) {
|
|
453
|
+
const padded = row.label.padEnd(maxLabelWidth);
|
|
454
|
+
out.push(` ${s.muted(padded)} ${row.value}`);
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
handleSummaryKey(key) {
|
|
459
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
460
|
+
return this.close(this.makeCompleted());
|
|
461
|
+
}
|
|
462
|
+
return this.noAction();
|
|
463
|
+
}
|
|
464
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
465
|
+
// Transitions — delegate to SDK helpers
|
|
466
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
467
|
+
advance(answer, label) {
|
|
468
|
+
const node = this.currentNode();
|
|
469
|
+
if (node === undefined || node.type !== 'question')
|
|
470
|
+
return this.noAction();
|
|
471
|
+
const updatedAnswers = { ...this.state.answers, [node.id]: answer };
|
|
472
|
+
const updatedLabels = { ...this.state.answerLabels, [node.id]: label };
|
|
473
|
+
const nextId = resolveNext(node.next, answer);
|
|
474
|
+
if (!nextId || !(nextId in this.flow.nodes)) {
|
|
475
|
+
// Malformed — abort with warning
|
|
476
|
+
return this.close(this.makeAborted('unknown'));
|
|
477
|
+
}
|
|
478
|
+
const newPath = walkPastBranches(this.flow, [...this.state.path, nextId], updatedAnswers);
|
|
479
|
+
this.state.answers = updatedAnswers;
|
|
480
|
+
this.state.answerLabels = updatedLabels;
|
|
481
|
+
this.state.path = newPath;
|
|
482
|
+
this.resetTransientState();
|
|
483
|
+
return this.rerender();
|
|
484
|
+
}
|
|
485
|
+
advanceInfo(node) {
|
|
486
|
+
const nextId = resolveNext(node.next);
|
|
487
|
+
if (!nextId || !(nextId in this.flow.nodes)) {
|
|
488
|
+
return this.close(this.makeAborted('unknown'));
|
|
489
|
+
}
|
|
490
|
+
const newPath = walkPastBranches(this.flow, [...this.state.path, nextId], this.state.answers);
|
|
491
|
+
this.state.path = newPath;
|
|
492
|
+
this.resetTransientState();
|
|
493
|
+
return this.rerender();
|
|
494
|
+
}
|
|
495
|
+
handleBackOrCancel() {
|
|
496
|
+
const result = applyBacktrack(this.flow, this.state.path, this.state.answers, this.state.answerLabels);
|
|
497
|
+
if (result === null) {
|
|
498
|
+
// No prior visible node — treat Esc as cancel
|
|
499
|
+
return this.close(this.makeAborted('escape'));
|
|
500
|
+
}
|
|
501
|
+
this.state.path = result.path;
|
|
502
|
+
this.state.answers = result.answers;
|
|
503
|
+
this.state.answerLabels = result.answerLabels;
|
|
504
|
+
this.resetTransientState();
|
|
505
|
+
return this.rerender();
|
|
506
|
+
}
|
|
507
|
+
resetTransientState() {
|
|
508
|
+
this.state.selectedIndex = 0;
|
|
509
|
+
this.state.multiSelections = new Set();
|
|
510
|
+
this.state.inputBuffer = '';
|
|
511
|
+
this.state.warning = '';
|
|
512
|
+
}
|
|
513
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
514
|
+
// Result builders
|
|
515
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
516
|
+
makeCompleted() {
|
|
517
|
+
return {
|
|
518
|
+
completed: true,
|
|
519
|
+
path: [...this.state.path],
|
|
520
|
+
answers: { ...this.state.answers },
|
|
521
|
+
answerLabels: { ...this.state.answerLabels },
|
|
522
|
+
durationMs: Date.now() - this.state.startedAt,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
makeAborted(reason) {
|
|
526
|
+
return {
|
|
527
|
+
completed: false,
|
|
528
|
+
path: [...this.state.path],
|
|
529
|
+
answers: { ...this.state.answers },
|
|
530
|
+
answerLabels: { ...this.state.answerLabels },
|
|
531
|
+
abortedAt: this.currentNodeId(),
|
|
532
|
+
abortReason: reason,
|
|
533
|
+
durationMs: Date.now() - this.state.startedAt,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
537
|
+
// Footer + tints
|
|
538
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
539
|
+
footerHints(node) {
|
|
540
|
+
if (node === undefined)
|
|
541
|
+
return '[q] cancel';
|
|
542
|
+
if (node.type === 'question') {
|
|
543
|
+
const mode = node.input.mode;
|
|
544
|
+
if (mode === 'single') {
|
|
545
|
+
return '[↑↓/1-9] navigate · [Enter] confirm · [Esc] back · [q] cancel';
|
|
546
|
+
}
|
|
547
|
+
if (mode === 'multi') {
|
|
548
|
+
return '[↑↓/1-9] navigate · [Space] toggle · [Enter] confirm · [Esc] back · [q] cancel';
|
|
549
|
+
}
|
|
550
|
+
if (mode === 'text') {
|
|
551
|
+
const isMultiline = 'multiline' in node.input && node.input.multiline === true;
|
|
552
|
+
return isMultiline
|
|
553
|
+
? '[Ctrl+D] submit · [Enter] new line · [Esc] back · [q] cancel'
|
|
554
|
+
: '[Enter] submit · [Esc] back · [q] cancel';
|
|
555
|
+
}
|
|
556
|
+
// mode === 'proposal'
|
|
557
|
+
return '[↑↓/1-9] navigate · [Enter] pick · [Esc] back · [q] cancel';
|
|
558
|
+
}
|
|
559
|
+
if (node.type === 'info') {
|
|
560
|
+
return '[Enter] continue · [Esc] back · [q] cancel';
|
|
561
|
+
}
|
|
562
|
+
if (node.type === 'summary') {
|
|
563
|
+
return '[Enter] confirm · [Esc] back · [q] cancel';
|
|
564
|
+
}
|
|
565
|
+
return '[q] cancel';
|
|
566
|
+
}
|
|
567
|
+
tinted(label, tint, s) {
|
|
568
|
+
if (tint === 'success')
|
|
569
|
+
return s.success(label);
|
|
570
|
+
if (tint === 'warning')
|
|
571
|
+
return s.warning(label);
|
|
572
|
+
if (tint === 'danger')
|
|
573
|
+
return s.error(label);
|
|
574
|
+
if (tint === 'accent')
|
|
575
|
+
return s.primary(label);
|
|
576
|
+
if (tint === 'muted')
|
|
577
|
+
return s.muted(label);
|
|
578
|
+
return label;
|
|
579
|
+
}
|
|
580
|
+
}
|