pi-soly 2.1.2 → 2.1.3

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/ask/picker.ts CHANGED
@@ -141,6 +141,11 @@ export class AskProComponent extends Container {
141
141
  private selectedIndex = 0;
142
142
  /** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
143
143
  private answers = new Map<number, AskAnswer | AskMultiAnswer>();
144
+ /** "questions" = tabbed flow; "summary" = read-only recap before submit.
145
+ * After the last question is answered, the picker transitions to "summary"
146
+ * so the user can review all answers before committing. Enter submits,
147
+ * Esc cancels. */
148
+ private viewMode: "questions" | "summary" = "questions";
144
149
  /** notes[questionIdx] = free-text note added by user (via `n` key). */
145
150
  private notes = new Map<number, string>();
146
151
  /** Set true once `done` is called — further input is ignored. */
@@ -251,8 +256,21 @@ export class AskProComponent extends Container {
251
256
 
252
257
  private repaint(): void {
253
258
  this.tabsText.setText(this.renderTabs());
254
- this.renderQuestionBody();
255
- this.footerText.setText(this.renderFooter());
259
+ if (this.viewMode === "summary") {
260
+ this.renderSummaryBody();
261
+ this.footerText.setText(this.renderSummaryFooter());
262
+ } else {
263
+ this.renderQuestionBody();
264
+ this.footerText.setText(this.renderFooter());
265
+ }
266
+ }
267
+
268
+ /** Switch to the read-only recap view. Triggered when the user completes
269
+ * the last question and all answers are present. The actual submit() only
270
+ * fires when Enter is pressed in summary mode. */
271
+ private showSummary(): void {
272
+ this.viewMode = "summary";
273
+ this.repaint();
256
274
  }
257
275
 
258
276
  private renderTabs(): string {
@@ -602,6 +620,85 @@ export class AskProComponent extends Container {
602
620
  return parts.join(" ");
603
621
  }
604
622
 
623
+ // -------------------------------------------------------------------------
624
+ // Summary view rendering — read-only recap shown after the last question.
625
+ // One line per question: "<header>: <answer>". Skipped questions show a
626
+ // strike-through-like marker. Notes are shown indented below.
627
+ // -------------------------------------------------------------------------
628
+
629
+ /** Render the recap body into bodyContainer. Replaces renderQuestionBody
630
+ * while in summary mode. */
631
+ private renderSummaryBody(): void {
632
+ this.bodyContainer.clear();
633
+ this.bodyContainer.addChild(
634
+ new Text(
635
+ this.theme.bold("Review your answers") +
636
+ this.theme.fg("dim", " — press Enter to submit, Esc to cancel"),
637
+ 1,
638
+ 0,
639
+ ),
640
+ );
641
+ this.bodyContainer.addChild(new Spacer(1));
642
+ for (let i = 0; i < this.questions.length; i++) {
643
+ const q = this.questions[i]!;
644
+ const answerText = this.formatAnswerForSummary(i);
645
+ const header = this.theme.bold(q.header);
646
+ const marker = this.theme.fg("accent", `Q${i + 1}:`);
647
+ this.bodyContainer.addChild(
648
+ new Text(` ${marker} ${header} → ${answerText}`, 1, 0),
649
+ );
650
+ const note = this.notes.get(i);
651
+ if (note) {
652
+ this.bodyContainer.addChild(
653
+ new Text(this.theme.fg("dim", ` note: ${note}`), 1, 0),
654
+ );
655
+ }
656
+ }
657
+ }
658
+
659
+ /** Format a single question's answer for the recap line. Skipped
660
+ * questions show a strikethrough-style marker; free-text shows the
661
+ * typed string (or "—" if blank); option picks show the label. */
662
+ private formatAnswerForSummary(qIdx: number): string {
663
+ if (this.skipped.has(qIdx)) {
664
+ return this.theme.fg("dim", "— skipped —");
665
+ }
666
+ const q = this.questions[qIdx]!;
667
+ if (q.freeText) {
668
+ const v = this.answers.get(qIdx);
669
+ const text = typeof v === "string" && v.length > 0 ? v : "—";
670
+ return this.theme.fg("text", text);
671
+ }
672
+ const ans = this.answers.get(qIdx);
673
+ if (ans === undefined) {
674
+ return this.theme.fg("dim", "— no answer —");
675
+ }
676
+ if (q.multiSelect) {
677
+ const arr = ans as AskMultiAnswer;
678
+ if (arr.length === 0) return this.theme.fg("dim", "— none —");
679
+ const labels = arr.map((a) => this.formatOptionLabel(q, a));
680
+ return this.theme.fg("text", labels.join(", "));
681
+ }
682
+ return this.theme.fg("text", this.formatOptionLabel(q, ans as AskAnswer));
683
+ }
684
+
685
+ /** Resolve a single answer value (index or custom string) to a label. */
686
+ private formatOptionLabel(q: AskQuestion, ans: AskAnswer): string {
687
+ if (typeof ans === "string") {
688
+ return `Other: ${ans}`;
689
+ }
690
+ const opt = q.options[ans];
691
+ return opt ? opt.label : `option ${ans}`;
692
+ }
693
+
694
+ /** Footer for the summary view. */
695
+ private renderSummaryFooter(): string {
696
+ return [
697
+ this.theme.fg("accent", "⏎ submit"),
698
+ this.theme.fg("dim", "esc cancel"),
699
+ ].join(" ");
700
+ }
701
+
605
702
  private isAnswered(qIdx: number): boolean {
606
703
  // Explicitly skipped questions never block submission.
607
704
  if (this.skipped.has(qIdx)) return true;
@@ -633,6 +730,27 @@ export class AskProComponent extends Container {
633
730
  handleInput(keyData: string): void {
634
731
  if (this.completed) return;
635
732
 
733
+ // --- Summary view (read-only recap before submit) ------------------
734
+ // After the last question is answered, the picker shows a recap of
735
+ // all answers. Enter commits, Esc cancels. All other keys are
736
+ // ignored (no per-question editing in summary mode).
737
+ if (this.viewMode === "summary") {
738
+ if (keyData === KEY_ESC) {
739
+ this.completed = true;
740
+ this.done({ cancelled: true });
741
+ return;
742
+ }
743
+ if (
744
+ this.keybindings.matches(keyData, "tui.select.confirm") ||
745
+ keyData === KEY_ENTER ||
746
+ keyData === KEY_ENTER_CR
747
+ ) {
748
+ this.submit();
749
+ return;
750
+ }
751
+ return;
752
+ }
753
+
636
754
  // --- Inline text-input mode (note / Other…) --------------------------
637
755
  // When active, all keys route to the embedded Input except the
638
756
  // confirm/cancel gestures, which we intercept to commit or abort.
@@ -775,13 +893,14 @@ export class AskProComponent extends Container {
775
893
 
776
894
  if (isMulti) {
777
895
  // On the LAST question, if all questions are answered, Enter
778
- // submits. Otherwise it advances (if not last) or stays put
896
+ // transitions to the summary view (where another Enter actually
897
+ // submits). Otherwise it advances (if not last) or stays put
779
898
  // (on last + not all answered — user must finish first).
780
899
  if (
781
900
  this.currentIndex === this.questions.length - 1 &&
782
901
  this.allAnswered()
783
902
  ) {
784
- this.submit();
903
+ this.showSummary();
785
904
  return;
786
905
  }
787
906
  if (this.currentIndex < this.questions.length - 1) {
@@ -790,14 +909,15 @@ export class AskProComponent extends Container {
790
909
  }
791
910
  this.repaint();
792
911
  } else {
793
- // Single-select: set current as answer, then advance or submit
912
+ // Single-select: set current as answer, then advance or transition
913
+ // to summary. The final submit happens after Enter in summary mode.
794
914
  this.answers.set(this.currentIndex, this.selectedIndex);
795
915
  if (this.currentIndex < this.questions.length - 1) {
796
916
  this.currentIndex++;
797
917
  this.selectedIndex = this.defaultIndexFor(this.currentIndex);
798
918
  this.repaint();
799
919
  } else if (this.allAnswered()) {
800
- this.submit();
920
+ this.showSummary();
801
921
  } else {
802
922
  this.repaint();
803
923
  }
@@ -846,8 +966,8 @@ export class AskProComponent extends Container {
846
966
  this.selectedIndex = this.defaultIndexFor(this.currentIndex);
847
967
  this.repaint();
848
968
  } else if (this.allAnswered()) {
849
- // Last question + all answered → submit
850
- this.submit();
969
+ // Last question + all answered → summary view (Enter there submits)
970
+ this.showSummary();
851
971
  } else {
852
972
  this.repaint();
853
973
  }
@@ -876,7 +996,7 @@ export class AskProComponent extends Container {
876
996
  this.selectedIndex = this.defaultIndexFor(this.currentIndex);
877
997
  this.repaint();
878
998
  } else if (this.allAnswered()) {
879
- this.submit();
999
+ this.showSummary();
880
1000
  } else {
881
1001
  this.repaint();
882
1002
  }
@@ -107,10 +107,13 @@ describe("AskProComponent — single-select (number keys)", () => {
107
107
  expect(picker.getCurrentIndex()).toBe(1);
108
108
  });
109
109
 
110
- test("on last question, '1' picks AND submits", () => {
110
+ test("on last question, '1' picks AND transitions to summary view (Enter there submits)", () => {
111
111
  const { picker, getDone } = setup();
112
112
  picker.handleInput("1"); // Q1 → JWT cookie (recommended), advance
113
- picker.handleInput("2"); // Q2 → Bearer header, submit (last)
113
+ picker.handleInput("2"); // Q2 → Bearer header, transitions to summary
114
+ // Now in summary view — Enter actually submits.
115
+ expect(getDone()).toBeNull();
116
+ picker.handleInput("\n"); // confirm summary
114
117
  expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
115
118
  });
116
119
  });
@@ -146,14 +149,16 @@ describe("AskProComponent — single-select (arrows + enter)", () => {
146
149
  expect(picker.getSelectedIndex()).toBe(2);
147
150
  });
148
151
 
149
- test("Enter confirms current selection, advances or submits", () => {
152
+ test("Enter confirms current selection, advances or transitions to summary", () => {
150
153
  const { picker, getDone } = setup();
151
154
  picker.handleInput("j"); // selectedIndex = 1 (JWT localStorage)
152
155
  picker.handleInput("\n"); // confirm
153
156
  expect(picker.getAnswers().get(0)).toBe(1);
154
157
  expect(picker.getCurrentIndex()).toBe(1); // advanced
155
158
  expect(getDone()).toBeNull();
156
- picker.handleInput("\n"); // confirm Q2 default (index 0)
159
+ picker.handleInput("\n"); // confirm Q2 default (index 0) → summary
160
+ expect(getDone()).toBeNull();
161
+ picker.handleInput("\n"); // confirm summary
157
162
  expect(getDone()).toEqual({ answers: { 0: 1, 1: 0 } });
158
163
  });
159
164
  });
@@ -275,17 +280,19 @@ describe("AskProComponent — multi-select", () => {
275
280
  expect(picker.getAnswers().size).toBe(0); // nothing toggled
276
281
  });
277
282
 
278
- test("Submit only when all questions answered (multi on last)", () => {
283
+ test("Submit only when all questions answered (multi on last) — Enter there confirms summary", () => {
279
284
  const { picker, getDone } = setup(multiQuestions);
280
285
  picker.handleInput("1"); // Q1 multi: pick Auth
281
286
  picker.handleInput("\t"); // → Q2
282
- picker.handleInput("\n"); // Q2 single: confirm default (High)
287
+ picker.handleInput("\n"); // Q2 single: confirm default (High) → summary
288
+ expect(getDone()).toBeNull();
289
+ picker.handleInput("\n"); // confirm summary
283
290
  expect(getDone()).toEqual({ answers: { 0: [0], 1: 0 } });
284
291
  });
285
292
 
286
- test("Enter on LAST multi question + all answered → submit (universal confirm)", () => {
293
+ test("Enter on LAST multi question + all answered → summary view (Enter there confirms)", () => {
287
294
  // Multi-select LAST question: Enter is the universal confirm gesture.
288
- // If all questions are answered, Enter submits.
295
+ // It transitions to the summary view; another Enter actually submits.
289
296
  const TWO_MULTI: AskQuestion[] = [
290
297
  { header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
291
298
  { header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
@@ -294,7 +301,10 @@ describe("AskProComponent — multi-select", () => {
294
301
  picker.handleInput(" "); // Q1 multi: Space → toggle A
295
302
  picker.handleInput("\t"); // → Q2
296
303
  picker.handleInput(" "); // Q2 multi: Space → toggle H
297
- // Now all answered, on last question, Enter should submit
304
+ // Now all answered, on last question, Enter transitions to summary.
305
+ picker.handleInput("\n");
306
+ expect(getDone()).toBeNull();
307
+ // Enter in summary view actually submits.
298
308
  picker.handleInput("\n");
299
309
  expect(getDone()).toEqual({ answers: { 0: [0], 1: [0] } });
300
310
  });
@@ -662,8 +672,11 @@ describe("AskProComponent — notes (n key)", () => {
662
672
  picker.handleInput("n");
663
673
  for (const ch of "rotate keys monthly") picker.handleInput(ch);
664
674
  picker.handleInput("\n"); // commit
665
- // Submit Q2 (last question, all answered)
675
+ // Pick Q2 (last question, all answered) → summary view
666
676
  picker.handleInput("1");
677
+ expect(doneResult).toBeNull();
678
+ // Enter in summary view actually submits.
679
+ picker.handleInput("\n");
667
680
  expect(doneResult).not.toBeNull();
668
681
  const result = doneResult as AskProResult | null;
669
682
  expect(result?.notes).toBeDefined();
@@ -697,9 +710,12 @@ describe("AskProComponent — notes (n key)", () => {
697
710
  picker.handleInput("n");
698
711
  picker.handleInput("\x15"); // ^U — delete to line start
699
712
  picker.handleInput("\n"); // commit empty → note cleared
700
- // Pick Q1 → Q2 → submit; result must NOT carry notes
713
+ // Pick Q1 → Q2 → summary; Enter there actually submits. Result must
714
+ // NOT carry notes.
701
715
  picker.handleInput("1");
702
716
  picker.handleInput("1");
717
+ expect(doneResult).toBeNull();
718
+ picker.handleInput("\n");
703
719
  expect(doneResult as AskProResult | null).toEqual({ answers: { 0: 0, 1: 0 } });
704
720
  });
705
721
 
@@ -710,6 +726,97 @@ describe("AskProComponent — notes (n key)", () => {
710
726
  });
711
727
  });
712
728
 
729
+ // ---------------------------------------------------------------------------
730
+ // Summary view: read-only recap shown after the last question is answered.
731
+ // Enter there commits the answers; Esc cancels. Other keys are ignored.
732
+ // ---------------------------------------------------------------------------
733
+
734
+ describe("AskProComponent — summary view (post-last-question recap)", () => {
735
+ test("single-select last question transitions to summary (Enter submits)", () => {
736
+ const { picker, getDone } = setup();
737
+ picker.handleInput("1"); // Q1 → advance
738
+ picker.handleInput("2"); // Q2 → summary view
739
+ expect(getDone()).toBeNull();
740
+ picker.handleInput("\n"); // confirm summary
741
+ expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
742
+ });
743
+
744
+ test("Esc in summary view cancels the whole picker", () => {
745
+ const { picker, getDone } = setup();
746
+ picker.handleInput("1");
747
+ picker.handleInput("2"); // → summary
748
+ picker.handleInput("\x1b"); // Esc
749
+ expect(getDone()).toEqual({ cancelled: true });
750
+ });
751
+
752
+ test("multi-select last question transitions to summary", () => {
753
+ const TWO_MULTI: AskQuestion[] = [
754
+ { header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
755
+ { header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
756
+ ];
757
+ const { picker, getDone } = setup(TWO_MULTI);
758
+ picker.handleInput(" "); // Q1: toggle A
759
+ picker.handleInput("\t"); // → Q2
760
+ picker.handleInput(" "); // Q2: toggle H
761
+ picker.handleInput("\n"); // Enter on last multi → summary
762
+ expect(getDone()).toBeNull();
763
+ picker.handleInput("\n"); // confirm
764
+ expect(getDone()).toEqual({ answers: { 0: [0], 1: [0] } });
765
+ });
766
+
767
+ test("summary view ignores non-Enter/non-Esc keys", () => {
768
+ const { picker, getDone } = setup();
769
+ picker.handleInput("1");
770
+ picker.handleInput("2"); // → summary
771
+ // Junk keys: should NOT submit or cancel.
772
+ picker.handleInput("a");
773
+ picker.handleInput("\t");
774
+ picker.handleInput(" ");
775
+ expect(getDone()).toBeNull();
776
+ // Enter still works.
777
+ picker.handleInput("\n");
778
+ expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
779
+ });
780
+
781
+ test("summary view shows all answers in render()", () => {
782
+ const { picker } = setup();
783
+ picker.handleInput("1"); // Q1: JWT cookie (label index 0)
784
+ picker.handleInput("2"); // Q2: Bearer header (label index 1) → summary
785
+ const out = picker.render(80).join("\n");
786
+ // Recap shows both questions with their labels.
787
+ expect(out).toContain("JWT cookie");
788
+ expect(out).toContain("Bearer header");
789
+ // Header hint for the recap itself.
790
+ expect(out).toContain("Review your answers");
791
+ });
792
+
793
+ test("skipped questions render as '— skipped —' in summary", () => {
794
+ const { picker } = setup();
795
+ picker.handleInput("1"); // Q1 → advance
796
+ picker.handleInput("s"); // Q2: skip → advance (but already last; → summary)
797
+ // Q1 answered, Q2 skipped → allAnswered() returns true (skipped counts as answered).
798
+ const { getDone } = setup(); // re-setup for clarity
799
+ void getDone; // silence unused
800
+ // The above setup didn't help; let me assert against the original flow:
801
+ expect(picker.render(80).join("\n")).toContain("skipped");
802
+ });
803
+
804
+ test("free-text typed answer renders verbatim in summary", () => {
805
+ const FT: AskQuestion[] = [
806
+ { header: "Title", question: "?", options: [{ label: "A" }] },
807
+ { header: "Note", question: "?", options: [], freeText: true },
808
+ ];
809
+ const { picker, getDone } = setup(FT);
810
+ picker.handleInput("1"); // Q1 → advance
811
+ picker.handleInput("hello world"); // free-text typing
812
+ picker.handleInput("\n"); // commit
813
+ // Q1 answered, Q2 answered → summary
814
+ expect(getDone()).toBeNull();
815
+ const out = picker.render(80).join("\n");
816
+ expect(out).toContain("hello world");
817
+ });
818
+ });
819
+
713
820
  // ---------------------------------------------------------------------------
714
821
  // Regression: render() must NEVER emit a line wider than `width`.
715
822
  // Previously the side-by-side preview layout measured width with a regex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",