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.
Files changed (54) hide show
  1. package/README.md +60 -14
  2. package/artifact/index.ts +241 -0
  3. package/artifact/render.ts +239 -0
  4. package/artifact/server.ts +384 -0
  5. package/artifact/session.ts +368 -0
  6. package/ask/README.md +20 -8
  7. package/ask/index.ts +100 -36
  8. package/ask/picker.ts +345 -29
  9. package/commands.ts +246 -30
  10. package/config.ts +124 -8
  11. package/core.ts +48 -236
  12. package/deck/deck.ts +386 -0
  13. package/deck/index.ts +113 -0
  14. package/index.ts +153 -37
  15. package/iteration.ts +1 -9
  16. package/mcp/commands.ts +12 -0
  17. package/mcp/direct-tools.ts +19 -2
  18. package/mcp/index.ts +144 -2
  19. package/mcp/init.ts +11 -0
  20. package/mcp/lifecycle.ts +9 -2
  21. package/mcp/server-manager.ts +30 -18
  22. package/mcp/state.ts +7 -0
  23. package/mcp/tool-cache.ts +135 -0
  24. package/nudge.ts +20 -3
  25. package/package.json +10 -3
  26. package/skills/soly-framework/SKILL.md +64 -37
  27. package/tool-hints.ts +62 -0
  28. package/tools.ts +28 -100
  29. package/util.ts +250 -0
  30. package/visual/chrome.ts +166 -0
  31. package/visual/colors.ts +24 -0
  32. package/visual/data.ts +62 -0
  33. package/visual/footer.ts +129 -0
  34. package/visual/format.ts +73 -0
  35. package/visual/glyphs.ts +35 -0
  36. package/visual/gradient.ts +122 -0
  37. package/visual/index.ts +18 -0
  38. package/visual/list-panel.ts +207 -0
  39. package/visual/segments.ts +136 -0
  40. package/visual/style.ts +34 -0
  41. package/visual/topbar.ts +55 -0
  42. package/visual/welcome.ts +265 -0
  43. package/visual/working.ts +66 -0
  44. package/workflows/execute.ts +17 -7
  45. package/workflows/index.ts +91 -44
  46. package/workflows/inspect.ts +23 -26
  47. package/workflows/migrate.ts +85 -0
  48. package/workflows/parser.ts +4 -2
  49. package/workflows/planning.ts +9 -8
  50. package/workflows/verify.ts +194 -0
  51. package/workflows-data/discuss-phase.md +10 -6
  52. package/agents-install.ts +0 -106
  53. package/ask/prompt.ts +0 -41
  54. 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 answered = this.isAnswered(i);
261
+ const skipped = this.skipped.has(i);
262
+ const filled = this.hasContent(i);
200
263
  const active = i === this.currentIndex;
201
- const marker = active ? "◉" : answered ? "✓" : "○";
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" : answered ? "success" : "dim";
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)) return a.length > 0;
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 = 0;
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 = 0;
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) cur.push(this.selectedIndex);
580
- else cur.splice(idx, 1);
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 = 0;
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 = 0;
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) cur.push(optionIdx);
660
- else cur.splice(idx, 1);
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 = 0;
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 = 0;
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
- this.done(hasNotes ? { answers, notes } : { answers });
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, previewLines, border);
1229
+ return this.renderPreviewStacked(width, superLines, border);
907
1230
  }
908
1231
 
909
- // Wrap each preview source line to previewWidth (a long line may
910
- // produce several wrapped rows).
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 += this.theme.fg("text", pLine);
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 line of previewLines) {
955
- for (const w of this.wrapPreviewLine(line, width)) {
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
  }