pi-soly 2.1.2 → 2.1.4
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 +129 -9
- package/ask/tests/picker.test.ts +118 -11
- package/core.ts +37 -1
- package/index.ts +8 -2
- package/package.json +1 -1
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.
|
|
255
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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.
|
|
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 →
|
|
850
|
-
this.
|
|
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.
|
|
999
|
+
this.showSummary();
|
|
880
1000
|
} else {
|
|
881
1001
|
this.repaint();
|
|
882
1002
|
}
|
package/ask/tests/picker.test.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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 →
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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 →
|
|
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/core.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from "node:fs";
|
|
17
17
|
import * as os from "node:os";
|
|
18
18
|
import * as path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
19
20
|
|
|
20
21
|
// Shared leaf utilities now live in util.ts; re-exported here so existing
|
|
21
22
|
// `import { ... } from "./core.ts"` call sites keep working unchanged.
|
|
@@ -58,7 +59,8 @@ export type RuleSource =
|
|
|
58
59
|
| "phase-soly"
|
|
59
60
|
| "project-agents"
|
|
60
61
|
| "global-agents"
|
|
61
|
-
| "phase-agents"
|
|
62
|
+
| "phase-agents"
|
|
63
|
+
| "built-in";
|
|
62
64
|
|
|
63
65
|
export interface RuleFrontmatter {
|
|
64
66
|
description?: string;
|
|
@@ -259,6 +261,40 @@ function loadRulesFromSource(spec: SourceSpec): RuleFile[] {
|
|
|
259
261
|
return rules;
|
|
260
262
|
}
|
|
261
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Resolve the path to the extension's `built-in-rules/` directory. Lives
|
|
266
|
+
* at `<package>/built-in-rules/` relative to the compiled module. We use
|
|
267
|
+
* `fileURLToPath(import.meta.url)` so this works whether the package is
|
|
268
|
+
* loaded directly from source (Bun, tsx) or from compiled output.
|
|
269
|
+
*
|
|
270
|
+
* `here` is the directory containing this core.ts file (i.e. the package
|
|
271
|
+
* root for source, the package root for compiled output too — both files
|
|
272
|
+
* live alongside package.json), so we don't need to traverse up.
|
|
273
|
+
*/
|
|
274
|
+
export function builtInRulesDir(): string {
|
|
275
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
276
|
+
return path.resolve(here, "built-in-rules");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Load every markdown rule from the extension's `built-in-rules/` directory.
|
|
281
|
+
* These rules ship with the soly extension and are always present in the
|
|
282
|
+
* system prompt. Source spec uses priority=10 (highest), so a user rule at
|
|
283
|
+
* the same relPath is silently dropped into `overridden[]` — built-in
|
|
284
|
+
* rules win by design. `sourceLabel: "soly"` makes them visible in
|
|
285
|
+
* `/rules list` so users know which rules come from the package itself.
|
|
286
|
+
*/
|
|
287
|
+
export function loadBuiltInRules(): RuleFile[] {
|
|
288
|
+
const dir = builtInRulesDir();
|
|
289
|
+
if (!fs.existsSync(dir)) return [];
|
|
290
|
+
return loadRulesFromSource({
|
|
291
|
+
dir,
|
|
292
|
+
source: "built-in",
|
|
293
|
+
sourceLabel: "soly",
|
|
294
|
+
priority: 10,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
262
298
|
export function loadAllRules(sources: SourceSpec[]): {
|
|
263
299
|
rules: RuleFile[];
|
|
264
300
|
overridden: string[];
|
package/index.ts
CHANGED
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
extractFilePathsFromPrompt,
|
|
35
35
|
formatTok,
|
|
36
36
|
loadAllRules,
|
|
37
|
+
builtInRulesDir,
|
|
38
|
+
loadBuiltInRules,
|
|
37
39
|
loadPhaseRules,
|
|
38
40
|
loadProjectState,
|
|
39
41
|
matchesGlob,
|
|
@@ -441,10 +443,14 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
441
443
|
// Project rules always beat global rules. `.agents/rules.local/` is
|
|
442
444
|
// gitignored — for personal overrides on top of the project's rules.
|
|
443
445
|
// `.agents/rules/` is the vendor-neutral project-level convention.
|
|
446
|
+
// Built-in rules ship with the extension (priority=10 — highest) and
|
|
447
|
+
// can't be overridden: a user rule at the same relPath is dropped
|
|
448
|
+
// silently into the `overridden[]` list (see loadAllRules).
|
|
444
449
|
ruleSources = [
|
|
445
|
-
{ dir:
|
|
446
|
-
{ dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
|
|
450
|
+
{ dir: builtInRulesDir(), source: "built-in", sourceLabel: "soly", priority: 10 },
|
|
447
451
|
{ dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 },
|
|
452
|
+
{ dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
|
|
453
|
+
{ dir: path.join(ctx.cwd, ".agents", "rules.local"), source: "project-agents", sourceLabel: "local", priority: 5 },
|
|
448
454
|
];
|
|
449
455
|
refreshRules();
|
|
450
456
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
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",
|