pi-soly 1.11.2 → 1.12.1
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/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- package/ask/tests/prompt.test.ts +0 -54
package/ask/picker.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface AskQuestion {
|
|
|
47
47
|
header: string;
|
|
48
48
|
/** The full question. */
|
|
49
49
|
question: string;
|
|
50
|
-
/** 2-4 options. */
|
|
50
|
+
/** 2-4 options. Empty when `freeText` is set (the answer is typed). */
|
|
51
51
|
options: AskOption[];
|
|
52
52
|
/** If true, user can pick multiple options (checkboxes). Default false. */
|
|
53
53
|
multiSelect?: boolean;
|
|
@@ -55,6 +55,16 @@ export interface AskQuestion {
|
|
|
55
55
|
* dialog when picked. The custom string is stored as the answer.
|
|
56
56
|
* Default false. */
|
|
57
57
|
allowOther?: boolean;
|
|
58
|
+
/** Multi-select only: minimum number of options that must be chosen for the
|
|
59
|
+
* question to count as answered. Default 1. */
|
|
60
|
+
minSelect?: number;
|
|
61
|
+
/** Multi-select only: maximum number of options the user may choose.
|
|
62
|
+
* Default = no limit. Toggling beyond this is ignored. */
|
|
63
|
+
maxSelect?: number;
|
|
64
|
+
/** If true, the question has no options — the user types a free-text answer
|
|
65
|
+
* into an inline field. The typed string is the answer; leaving it blank
|
|
66
|
+
* is allowed (the question is optional and omitted from results). */
|
|
67
|
+
freeText?: boolean;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** Single-pick answer: either an option index (0..N-1) or a custom string
|
|
@@ -72,6 +82,9 @@ export interface AskProResult {
|
|
|
72
82
|
* Keyed by question index. Added when the user pressed `n` after
|
|
73
83
|
* picking an option and typed a note. */
|
|
74
84
|
notes?: Record<number, string>;
|
|
85
|
+
/** Indices of questions the user explicitly skipped (via `s`) or left
|
|
86
|
+
* blank (free-text). Absent when nothing was skipped. */
|
|
87
|
+
skipped?: number[];
|
|
75
88
|
}
|
|
76
89
|
|
|
77
90
|
interface AskProComponentDeps {
|
|
@@ -81,6 +94,9 @@ interface AskProComponentDeps {
|
|
|
81
94
|
done: (result: AskProResult) => void;
|
|
82
95
|
/** Optional title shown above the tabs. */
|
|
83
96
|
title?: string;
|
|
97
|
+
/** Optional syntax highlighter (pi's highlightCode). When provided, fenced
|
|
98
|
+
* ```lang code blocks inside option previews are colorized. */
|
|
99
|
+
highlight?: (code: string, lang?: string) => string[];
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
// ---------------------------------------------------------------------------
|
|
@@ -135,6 +151,13 @@ export class AskProComponent extends Container {
|
|
|
135
151
|
/** Note Input used while inputMode.kind === "note". Kept on the instance
|
|
136
152
|
* so the `n`-flow can reuse a single field across open/close cycles. */
|
|
137
153
|
private noteInput: Input | null = null;
|
|
154
|
+
/** Indices the user explicitly skipped (via `s`). */
|
|
155
|
+
private skipped = new Set<number>();
|
|
156
|
+
/** Persistent inline Input per free-text question, keyed by question index.
|
|
157
|
+
* Lazily created so navigating back keeps what was typed. */
|
|
158
|
+
private freeTextInputs = new Map<number, Input>();
|
|
159
|
+
/** Optional syntax highlighter for fenced code in previews. */
|
|
160
|
+
private highlight?: (code: string, lang?: string) => string[];
|
|
138
161
|
|
|
139
162
|
private tabsText!: Text;
|
|
140
163
|
private bodyContainer!: Container;
|
|
@@ -147,6 +170,7 @@ export class AskProComponent extends Container {
|
|
|
147
170
|
this.keybindings = deps.keybindings;
|
|
148
171
|
this.done = deps.done;
|
|
149
172
|
this.title = deps.title ?? "pi-ask";
|
|
173
|
+
this.highlight = deps.highlight;
|
|
150
174
|
|
|
151
175
|
const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
|
|
152
176
|
this.addChild(titleText);
|
|
@@ -163,9 +187,47 @@ export class AskProComponent extends Container {
|
|
|
163
187
|
this.footerText = new Text("", 1, 0);
|
|
164
188
|
this.addChild(this.footerText);
|
|
165
189
|
|
|
190
|
+
this.selectedIndex = this.defaultIndexFor(0);
|
|
166
191
|
this.repaint();
|
|
167
192
|
}
|
|
168
193
|
|
|
194
|
+
/** Cursor lands on the recommended (⭐) option when entering a question. */
|
|
195
|
+
private defaultIndexFor(qIdx: number): number {
|
|
196
|
+
const opts = this.questions[qIdx]?.options ?? [];
|
|
197
|
+
const rec = opts.findIndex((o) => o.recommended);
|
|
198
|
+
return rec >= 0 ? rec : 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A question with no options whose answer is typed free-text. */
|
|
202
|
+
private isFreeText(qIdx: number): boolean {
|
|
203
|
+
return this.questions[qIdx]?.freeText === true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Min/max number of selections for a multi-select question. min defaults
|
|
207
|
+
* to 1, max to the number of options (effectively unlimited). */
|
|
208
|
+
private multiSelectBounds(q: AskQuestion): { min: number; max: number } {
|
|
209
|
+
const optionCount = q.options.length + (q.allowOther ? 1 : 0);
|
|
210
|
+
const min = Math.max(1, q.minSelect ?? 1);
|
|
211
|
+
const max = Math.min(optionCount, q.maxSelect ?? optionCount);
|
|
212
|
+
return { min, max: Math.max(min, max) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Number of items currently selected for a multi-select question. */
|
|
216
|
+
private multiCount(qIdx: number): number {
|
|
217
|
+
const a = this.answers.get(qIdx);
|
|
218
|
+
return Array.isArray(a) ? a.length : 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Lazily get the persistent free-text Input for a question. */
|
|
222
|
+
private freeTextInputFor(qIdx: number): Input {
|
|
223
|
+
let input = this.freeTextInputs.get(qIdx);
|
|
224
|
+
if (!input) {
|
|
225
|
+
input = new Input();
|
|
226
|
+
this.freeTextInputs.set(qIdx, input);
|
|
227
|
+
}
|
|
228
|
+
return input;
|
|
229
|
+
}
|
|
230
|
+
|
|
169
231
|
// -------------------------------------------------------------------------
|
|
170
232
|
// Public state accessors (used by tests; safe to call from outside)
|
|
171
233
|
// -------------------------------------------------------------------------
|
|
@@ -196,16 +258,29 @@ export class AskProComponent extends Container {
|
|
|
196
258
|
private renderTabs(): string {
|
|
197
259
|
return this.questions
|
|
198
260
|
.map((q, i) => {
|
|
199
|
-
const
|
|
261
|
+
const skipped = this.skipped.has(i);
|
|
262
|
+
const filled = this.hasContent(i);
|
|
200
263
|
const active = i === this.currentIndex;
|
|
201
|
-
const marker = active ? "◉" :
|
|
264
|
+
const marker = active ? "◉" : skipped ? "⊘" : filled ? "✓" : "○";
|
|
202
265
|
const label = q.header.length > 12 ? `${q.header.slice(0, 11)}…` : q.header;
|
|
203
|
-
const color = active ? "accent" :
|
|
266
|
+
const color = active ? "accent" : skipped ? "dim" : filled ? "success" : "dim";
|
|
204
267
|
return this.theme.fg(color, `${marker} ${label}`);
|
|
205
268
|
})
|
|
206
269
|
.join(this.theme.fg("dim", " "));
|
|
207
270
|
}
|
|
208
271
|
|
|
272
|
+
/** Does the question hold a real (non-skipped) answer? Distinct from
|
|
273
|
+
* isAnswered, which treats skipped/free-text as non-blocking. */
|
|
274
|
+
private hasContent(qIdx: number): boolean {
|
|
275
|
+
if (this.skipped.has(qIdx)) return false;
|
|
276
|
+
if (this.isFreeText(qIdx)) {
|
|
277
|
+
return (this.freeTextInputs.get(qIdx)?.getValue().trim() ?? "") !== "";
|
|
278
|
+
}
|
|
279
|
+
const a = this.answers.get(qIdx);
|
|
280
|
+
if (a === undefined) return false;
|
|
281
|
+
return Array.isArray(a) ? a.length > 0 : true;
|
|
282
|
+
}
|
|
283
|
+
|
|
209
284
|
private renderQuestionBody(): void {
|
|
210
285
|
this.bodyContainer.clear();
|
|
211
286
|
const q = this.questions[this.currentIndex];
|
|
@@ -227,6 +302,20 @@ export class AskProComponent extends Container {
|
|
|
227
302
|
);
|
|
228
303
|
this.bodyContainer.addChild(new Spacer(1));
|
|
229
304
|
|
|
305
|
+
// Skipped — show a muted note instead of the options.
|
|
306
|
+
if (this.skipped.has(this.currentIndex)) {
|
|
307
|
+
this.bodyContainer.addChild(
|
|
308
|
+
new Text(this.theme.fg("dim", "⊘ skipped — press s to answer"), 1, 0),
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Free-text — the answer is typed into an always-on inline field.
|
|
314
|
+
if (this.isFreeText(this.currentIndex)) {
|
|
315
|
+
this.renderFreeTextBody();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
230
319
|
const isMulti = q.multiSelect ?? false;
|
|
231
320
|
const allowOther = q.allowOther ?? false;
|
|
232
321
|
const currentAns = this.answers.get(this.currentIndex);
|
|
@@ -349,6 +438,24 @@ export class AskProComponent extends Container {
|
|
|
349
438
|
this.renderInlineField();
|
|
350
439
|
}
|
|
351
440
|
|
|
441
|
+
/** Render the body of a free-text question: a label, the persistent inline
|
|
442
|
+
* Input, and a hint. Keystrokes are routed to the field by handleInput. */
|
|
443
|
+
private renderFreeTextBody(): void {
|
|
444
|
+
this.bodyContainer.addChild(
|
|
445
|
+
new Text(this.theme.fg("dim", "Type your answer:"), 1, 0),
|
|
446
|
+
);
|
|
447
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
448
|
+
this.bodyContainer.addChild(this.freeTextInputFor(this.currentIndex));
|
|
449
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
450
|
+
this.bodyContainer.addChild(
|
|
451
|
+
new Text(
|
|
452
|
+
this.theme.fg("dim", "(blank is allowed — this question is optional)"),
|
|
453
|
+
1,
|
|
454
|
+
0,
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
352
459
|
/** Append the active inline text field to bodyContainer. No-op when no
|
|
353
460
|
* inline mode is active. */
|
|
354
461
|
private renderInlineField(): void {
|
|
@@ -427,6 +534,24 @@ export class AskProComponent extends Container {
|
|
|
427
534
|
return parts.join(" ");
|
|
428
535
|
}
|
|
429
536
|
|
|
537
|
+
// Skipped — only the un-skip + nav affordances apply.
|
|
538
|
+
if (this.skipped.has(this.currentIndex)) {
|
|
539
|
+
parts.push(this.theme.fg("accent", "s answer"));
|
|
540
|
+
if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
|
|
541
|
+
if (!isLast) parts.push(this.theme.fg("dim", "tab/→ next"));
|
|
542
|
+
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
543
|
+
return parts.join(" ");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Free-text — typing field owns most keys; tab/enter navigate/submit.
|
|
547
|
+
if (this.isFreeText(this.currentIndex)) {
|
|
548
|
+
parts.push(this.theme.fg("dim", "type answer"));
|
|
549
|
+
if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab prev"));
|
|
550
|
+
parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
|
|
551
|
+
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
552
|
+
return parts.join(" ");
|
|
553
|
+
}
|
|
554
|
+
|
|
430
555
|
parts.push(this.theme.fg("dim", "↑↓ navigate"));
|
|
431
556
|
parts.push(this.theme.fg("dim", `1-${totalOptions} pick`));
|
|
432
557
|
if (this.currentIndex > 0) parts.push(this.theme.fg("dim", "tab/← prev"));
|
|
@@ -441,6 +566,14 @@ export class AskProComponent extends Container {
|
|
|
441
566
|
} else if (isMulti) {
|
|
442
567
|
// Multi-select: Space toggles, Enter advances/submits
|
|
443
568
|
parts.push(this.theme.fg("dim", "␣ toggle"));
|
|
569
|
+
// Min/max bounds hint with a live count, e.g. "pick 2–3 (1)".
|
|
570
|
+
const { min, max } = this.multiSelectBounds(q);
|
|
571
|
+
const count = this.multiCount(this.currentIndex);
|
|
572
|
+
if (min > 1 || max < totalOptions) {
|
|
573
|
+
const range = min === max ? `${min}` : `${min}–${max}`;
|
|
574
|
+
const ok = count >= min && count <= max;
|
|
575
|
+
parts.push(this.theme.fg(ok ? "success" : "warning", `pick ${range} (${count})`));
|
|
576
|
+
}
|
|
444
577
|
if (isLast) {
|
|
445
578
|
parts.push(
|
|
446
579
|
this.theme.fg(
|
|
@@ -460,14 +593,25 @@ export class AskProComponent extends Container {
|
|
|
460
593
|
const hasNote = this.notes.has(this.currentIndex);
|
|
461
594
|
const noteHint = hasNote ? "n ✓note" : "n note";
|
|
462
595
|
parts.push(this.theme.fg(hasNote ? "success" : "dim", noteHint));
|
|
596
|
+
parts.push(this.theme.fg("dim", "s skip"));
|
|
463
597
|
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
464
598
|
return parts.join(" ");
|
|
465
599
|
}
|
|
466
600
|
|
|
467
601
|
private isAnswered(qIdx: number): boolean {
|
|
602
|
+
// Explicitly skipped questions never block submission.
|
|
603
|
+
if (this.skipped.has(qIdx)) return true;
|
|
604
|
+
// Free-text is optional — typing is encouraged but blank is allowed,
|
|
605
|
+
// so the question never blocks the flow.
|
|
606
|
+
if (this.isFreeText(qIdx)) return true;
|
|
468
607
|
const a = this.answers.get(qIdx);
|
|
469
608
|
if (a === undefined) return false;
|
|
470
|
-
if (Array.isArray(a))
|
|
609
|
+
if (Array.isArray(a)) {
|
|
610
|
+
const q = this.questions[qIdx];
|
|
611
|
+
if (!q) return a.length > 0;
|
|
612
|
+
const { min, max } = this.multiSelectBounds(q);
|
|
613
|
+
return a.length >= min && a.length <= max;
|
|
614
|
+
}
|
|
471
615
|
return true;
|
|
472
616
|
}
|
|
473
617
|
|
|
@@ -500,6 +644,13 @@ export class AskProComponent extends Container {
|
|
|
500
644
|
return;
|
|
501
645
|
}
|
|
502
646
|
|
|
647
|
+
// Free-text question — the inline field owns most keys (printable input,
|
|
648
|
+
// cursor arrows, backspace); only Tab/Shift-Tab/Enter navigate/submit.
|
|
649
|
+
if (this.isFreeText(this.currentIndex) && !this.skipped.has(this.currentIndex)) {
|
|
650
|
+
this.handleFreeTextKey(keyData);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
503
654
|
const q = this.questions[this.currentIndex];
|
|
504
655
|
if (!q) return;
|
|
505
656
|
const isMulti = q.multiSelect ?? false;
|
|
@@ -507,6 +658,19 @@ export class AskProComponent extends Container {
|
|
|
507
658
|
const otherIndex = allowOther ? q.options.length : -1;
|
|
508
659
|
const totalOptions = this.totalOptionsForCurrent();
|
|
509
660
|
|
|
661
|
+
// `s` — toggle skip for the current question (works from any state,
|
|
662
|
+
// including re-answering a previously skipped question).
|
|
663
|
+
if (keyData === "s") {
|
|
664
|
+
this.toggleSkip();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// While skipped, option interaction is locked — only navigation applies.
|
|
669
|
+
if (this.skipped.has(this.currentIndex)) {
|
|
670
|
+
this.handleSkippedNav(keyData);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
510
674
|
// Arrow up / k — move selection up
|
|
511
675
|
if (
|
|
512
676
|
this.keybindings.matches(keyData, "tui.select.up") ||
|
|
@@ -533,7 +697,7 @@ export class AskProComponent extends Container {
|
|
|
533
697
|
if (keyData === KEY_TAB || keyData === KEY_RIGHT) {
|
|
534
698
|
if (this.currentIndex < this.questions.length - 1) {
|
|
535
699
|
this.currentIndex++;
|
|
536
|
-
this.selectedIndex =
|
|
700
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
537
701
|
this.repaint();
|
|
538
702
|
}
|
|
539
703
|
return;
|
|
@@ -543,7 +707,7 @@ export class AskProComponent extends Container {
|
|
|
543
707
|
if (keyData === KEY_SHIFT_TAB || keyData === KEY_LEFT) {
|
|
544
708
|
if (this.currentIndex > 0) {
|
|
545
709
|
this.currentIndex--;
|
|
546
|
-
this.selectedIndex =
|
|
710
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
547
711
|
this.repaint();
|
|
548
712
|
}
|
|
549
713
|
return;
|
|
@@ -576,8 +740,13 @@ export class AskProComponent extends Container {
|
|
|
576
740
|
}
|
|
577
741
|
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
578
742
|
const idx = cur.indexOf(this.selectedIndex);
|
|
579
|
-
if (idx === -1)
|
|
580
|
-
|
|
743
|
+
if (idx === -1) {
|
|
744
|
+
// Respect maxSelect: ignore the toggle once the cap is reached.
|
|
745
|
+
if (cur.length >= this.multiSelectBounds(q).max) return;
|
|
746
|
+
cur.push(this.selectedIndex);
|
|
747
|
+
} else {
|
|
748
|
+
cur.splice(idx, 1);
|
|
749
|
+
}
|
|
581
750
|
this.answers.set(this.currentIndex, cur);
|
|
582
751
|
this.repaint();
|
|
583
752
|
return;
|
|
@@ -613,7 +782,7 @@ export class AskProComponent extends Container {
|
|
|
613
782
|
}
|
|
614
783
|
if (this.currentIndex < this.questions.length - 1) {
|
|
615
784
|
this.currentIndex++;
|
|
616
|
-
this.selectedIndex =
|
|
785
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
617
786
|
}
|
|
618
787
|
this.repaint();
|
|
619
788
|
} else {
|
|
@@ -621,7 +790,7 @@ export class AskProComponent extends Container {
|
|
|
621
790
|
this.answers.set(this.currentIndex, this.selectedIndex);
|
|
622
791
|
if (this.currentIndex < this.questions.length - 1) {
|
|
623
792
|
this.currentIndex++;
|
|
624
|
-
this.selectedIndex =
|
|
793
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
625
794
|
this.repaint();
|
|
626
795
|
} else if (this.allAnswered()) {
|
|
627
796
|
this.submit();
|
|
@@ -656,8 +825,12 @@ export class AskProComponent extends Container {
|
|
|
656
825
|
if (isMulti) {
|
|
657
826
|
const cur = (this.answers.get(this.currentIndex) as AskMultiAnswer | undefined) ?? [];
|
|
658
827
|
const idx = cur.indexOf(optionIdx);
|
|
659
|
-
if (idx === -1)
|
|
660
|
-
|
|
828
|
+
if (idx === -1) {
|
|
829
|
+
if (cur.length >= this.multiSelectBounds(q).max) return;
|
|
830
|
+
cur.push(optionIdx);
|
|
831
|
+
} else {
|
|
832
|
+
cur.splice(idx, 1);
|
|
833
|
+
}
|
|
661
834
|
this.answers.set(this.currentIndex, cur);
|
|
662
835
|
this.repaint();
|
|
663
836
|
} else {
|
|
@@ -666,7 +839,7 @@ export class AskProComponent extends Container {
|
|
|
666
839
|
if (this.currentIndex < this.questions.length - 1) {
|
|
667
840
|
// Advance to next question; don't submit yet
|
|
668
841
|
this.currentIndex++;
|
|
669
|
-
this.selectedIndex =
|
|
842
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
670
843
|
this.repaint();
|
|
671
844
|
} else if (this.allAnswered()) {
|
|
672
845
|
// Last question + all answered → submit
|
|
@@ -677,6 +850,104 @@ export class AskProComponent extends Container {
|
|
|
677
850
|
}
|
|
678
851
|
}
|
|
679
852
|
|
|
853
|
+
/** Toggle the skip state of the current question. Skipping discards any
|
|
854
|
+
* partial answer and advances; un-skipping stays so the user can answer. */
|
|
855
|
+
private toggleSkip(): void {
|
|
856
|
+
const i = this.currentIndex;
|
|
857
|
+
if (this.skipped.has(i)) {
|
|
858
|
+
this.skipped.delete(i);
|
|
859
|
+
this.repaint();
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
this.skipped.add(i);
|
|
863
|
+
this.answers.delete(i);
|
|
864
|
+
this.advanceOrSubmit();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Advance to the next question; on the last one, submit if everything is
|
|
868
|
+
* answered, else just repaint. Shared by skip / free-text / pick flows. */
|
|
869
|
+
private advanceOrSubmit(): void {
|
|
870
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
871
|
+
this.currentIndex++;
|
|
872
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
873
|
+
this.repaint();
|
|
874
|
+
} else if (this.allAnswered()) {
|
|
875
|
+
this.submit();
|
|
876
|
+
} else {
|
|
877
|
+
this.repaint();
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Navigation while a question is skipped (option interaction is locked). */
|
|
882
|
+
private handleSkippedNav(keyData: string): void {
|
|
883
|
+
if (keyData === KEY_TAB || keyData === KEY_RIGHT) {
|
|
884
|
+
if (this.currentIndex < this.questions.length - 1) {
|
|
885
|
+
this.currentIndex++;
|
|
886
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
887
|
+
this.repaint();
|
|
888
|
+
}
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (
|
|
892
|
+
keyData === KEY_SHIFT_TAB ||
|
|
893
|
+
keyData === KEY_LEFT ||
|
|
894
|
+
keyData === KEY_BACKSPACE
|
|
895
|
+
) {
|
|
896
|
+
if (this.currentIndex > 0) {
|
|
897
|
+
this.currentIndex--;
|
|
898
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
899
|
+
this.repaint();
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (
|
|
904
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
905
|
+
keyData === KEY_ENTER ||
|
|
906
|
+
keyData === KEY_ENTER_CR
|
|
907
|
+
) {
|
|
908
|
+
this.advanceOrSubmit();
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/** Key handling for a free-text question: Tab/Shift-Tab navigate, Enter
|
|
913
|
+
* advances/submits (committing the typed value), everything else (printable
|
|
914
|
+
* chars, cursor arrows, backspace) routes to the inline field. */
|
|
915
|
+
private handleFreeTextKey(keyData: string): void {
|
|
916
|
+
if (keyData === KEY_TAB) {
|
|
917
|
+
this.commitFreeText();
|
|
918
|
+
if (this.currentIndex < this.questions.length - 1) this.currentIndex++;
|
|
919
|
+
this.repaint();
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
if (keyData === KEY_SHIFT_TAB) {
|
|
923
|
+
this.commitFreeText();
|
|
924
|
+
if (this.currentIndex > 0) this.currentIndex--;
|
|
925
|
+
this.repaint();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (
|
|
929
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
930
|
+
keyData === KEY_ENTER ||
|
|
931
|
+
keyData === KEY_ENTER_CR
|
|
932
|
+
) {
|
|
933
|
+
this.commitFreeText();
|
|
934
|
+
this.advanceOrSubmit();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
this.freeTextInputFor(this.currentIndex).handleInput(keyData);
|
|
938
|
+
this.commitFreeText();
|
|
939
|
+
this.repaint();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/** Mirror the current free-text field's value into the answers map (trimmed,
|
|
943
|
+
* non-empty), or clear it when blank. Keeps tab markers / submit live. */
|
|
944
|
+
private commitFreeText(): void {
|
|
945
|
+
const i = this.currentIndex;
|
|
946
|
+
const value = this.freeTextInputs.get(i)?.getValue().trim() ?? "";
|
|
947
|
+
if (value === "") this.answers.delete(i);
|
|
948
|
+
else this.answers.set(i, value);
|
|
949
|
+
}
|
|
950
|
+
|
|
680
951
|
/** Create a fresh inline Input pre-filled with `value`, cursor at the
|
|
681
952
|
* end so the user can immediately extend or backspace-edit it.
|
|
682
953
|
* (Input.setValue leaves the cursor at min(prevCursor, len) which is 0
|
|
@@ -785,7 +1056,7 @@ export class AskProComponent extends Container {
|
|
|
785
1056
|
if (commit && !isMulti && trimmed !== "") {
|
|
786
1057
|
if (this.currentIndex < this.questions.length - 1) {
|
|
787
1058
|
this.currentIndex++;
|
|
788
|
-
this.selectedIndex =
|
|
1059
|
+
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
789
1060
|
this.repaint();
|
|
790
1061
|
} else if (this.allAnswered()) {
|
|
791
1062
|
this.submit();
|
|
@@ -796,7 +1067,14 @@ export class AskProComponent extends Container {
|
|
|
796
1067
|
private submit(): void {
|
|
797
1068
|
if (!this.allAnswered()) return;
|
|
798
1069
|
const answers: Record<number, AskAnswer | AskMultiAnswer> = {};
|
|
1070
|
+
const skipped: number[] = [];
|
|
799
1071
|
for (let i = 0; i < this.questions.length; i++) {
|
|
1072
|
+
// A question with no real content (explicitly skipped, or a free-text
|
|
1073
|
+
// field left blank) is reported as skipped, not as an answer.
|
|
1074
|
+
if (!this.hasContent(i)) {
|
|
1075
|
+
skipped.push(i);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
800
1078
|
answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
|
|
801
1079
|
}
|
|
802
1080
|
// Include notes only if at least one was added
|
|
@@ -810,7 +1088,10 @@ export class AskProComponent extends Container {
|
|
|
810
1088
|
}
|
|
811
1089
|
}
|
|
812
1090
|
this.completed = true;
|
|
813
|
-
|
|
1091
|
+
const result: AskProResult = { answers };
|
|
1092
|
+
if (hasNotes) result.notes = notes;
|
|
1093
|
+
if (skipped.length > 0) result.skipped = skipped;
|
|
1094
|
+
this.done(result);
|
|
814
1095
|
}
|
|
815
1096
|
|
|
816
1097
|
// -------------------------------------------------------------------------
|
|
@@ -883,6 +1164,48 @@ export class AskProComponent extends Container {
|
|
|
883
1164
|
return out;
|
|
884
1165
|
}
|
|
885
1166
|
|
|
1167
|
+
/** Final, styled preview lines for the given column width. Prose is
|
|
1168
|
+
* word-wrapped and themed; fenced ```lang code blocks are syntax-highlighted
|
|
1169
|
+
* (via the optional highlighter) and hard-truncated, never word-wrapped. */
|
|
1170
|
+
private previewDisplayLines(maxWidth: number): string[] {
|
|
1171
|
+
const raw = this.currentPreviewLines();
|
|
1172
|
+
const out: string[] = [];
|
|
1173
|
+
let codeBuf: string[] | null = null;
|
|
1174
|
+
let codeLang = "";
|
|
1175
|
+
for (const line of raw) {
|
|
1176
|
+
const fence = line.trimStart().match(/^```(\w*)\s*$/);
|
|
1177
|
+
if (fence) {
|
|
1178
|
+
if (codeBuf === null) {
|
|
1179
|
+
codeBuf = [];
|
|
1180
|
+
codeLang = fence[1] ?? "";
|
|
1181
|
+
} else {
|
|
1182
|
+
out.push(...this.highlightBlock(codeBuf, codeLang, maxWidth));
|
|
1183
|
+
codeBuf = null;
|
|
1184
|
+
codeLang = "";
|
|
1185
|
+
}
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
if (codeBuf !== null) {
|
|
1189
|
+
codeBuf.push(line);
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1192
|
+
for (const w of this.wrapPreviewLine(line, maxWidth)) {
|
|
1193
|
+
out.push(this.theme.fg("text", w));
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
if (codeBuf !== null) out.push(...this.highlightBlock(codeBuf, codeLang, maxWidth));
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Highlight a fenced code block to styled lines capped at `maxWidth`. Falls
|
|
1201
|
+
* back to dimmed plain text when no highlighter was provided. */
|
|
1202
|
+
private highlightBlock(code: string[], lang: string, maxWidth: number): string[] {
|
|
1203
|
+
const highlighted = this.highlight
|
|
1204
|
+
? this.highlight(code.join("\n"), lang || undefined)
|
|
1205
|
+
: code.map((l) => this.theme.fg("dim", l));
|
|
1206
|
+
return highlighted.map((l) => truncateToWidth(l, maxWidth, "", false));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
886
1209
|
/** Override render to produce a side-by-side layout when a preview is
|
|
887
1210
|
* present. Falls back to default Container render otherwise. Every
|
|
888
1211
|
* returned line is guaranteed ≤ `width` visible columns. */
|
|
@@ -903,15 +1226,11 @@ export class AskProComponent extends Container {
|
|
|
903
1226
|
);
|
|
904
1227
|
const previewWidth = width - splitCol - sepLen;
|
|
905
1228
|
if (previewWidth < AskProComponent.MIN_COL) {
|
|
906
|
-
return this.renderPreviewStacked(width, superLines,
|
|
1229
|
+
return this.renderPreviewStacked(width, superLines, border);
|
|
907
1230
|
}
|
|
908
1231
|
|
|
909
|
-
//
|
|
910
|
-
|
|
911
|
-
const wrapped: string[] = [];
|
|
912
|
-
for (const line of previewLines) {
|
|
913
|
-
for (const w of this.wrapPreviewLine(line, previewWidth)) wrapped.push(w);
|
|
914
|
-
}
|
|
1232
|
+
// Final styled preview rows (prose wrapped, code highlighted).
|
|
1233
|
+
const wrapped = this.previewDisplayLines(previewWidth);
|
|
915
1234
|
|
|
916
1235
|
const result: string[] = [];
|
|
917
1236
|
// Header row for the preview column (aligned under it), hard-capped.
|
|
@@ -931,7 +1250,7 @@ export class AskProComponent extends Container {
|
|
|
931
1250
|
// short ones. truncateToWidth preserves ANSI/OSC styling.
|
|
932
1251
|
const left = truncateToWidth(superLine, splitCol, "", true);
|
|
933
1252
|
let row = left + " " + border + " ";
|
|
934
|
-
if (pLine !== undefined) row +=
|
|
1253
|
+
if (pLine !== undefined) row += pLine; // already styled
|
|
935
1254
|
// Hard cap — the guarantee that prevents the terminal-width crash.
|
|
936
1255
|
result.push(truncateToWidth(row, width, "", false));
|
|
937
1256
|
}
|
|
@@ -943,7 +1262,6 @@ export class AskProComponent extends Container {
|
|
|
943
1262
|
private renderPreviewStacked(
|
|
944
1263
|
width: number,
|
|
945
1264
|
superLines: string[],
|
|
946
|
-
previewLines: string[],
|
|
947
1265
|
border: string,
|
|
948
1266
|
): string[] {
|
|
949
1267
|
const result: string[] = [];
|
|
@@ -951,10 +1269,8 @@ export class AskProComponent extends Container {
|
|
|
951
1269
|
result.push(
|
|
952
1270
|
truncateToWidth(this.theme.fg("dim", `${border} — preview —`), width, "", false),
|
|
953
1271
|
);
|
|
954
|
-
for (const
|
|
955
|
-
|
|
956
|
-
result.push(truncateToWidth(this.theme.fg("text", w), width, "", false));
|
|
957
|
-
}
|
|
1272
|
+
for (const l of this.previewDisplayLines(width)) {
|
|
1273
|
+
result.push(truncateToWidth(l, width, "", false));
|
|
958
1274
|
}
|
|
959
1275
|
return result;
|
|
960
1276
|
}
|