pi-soly 1.9.2 → 1.10.0
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/index.ts +17 -5
- package/ask/picker.ts +176 -1
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +162 -0
- package/commands.ts +8 -1
- package/core.ts +6 -3
- package/package.json +1 -1
package/ask/index.ts
CHANGED
|
@@ -165,6 +165,11 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
165
165
|
if (!ctx.hasUI) return undefined;
|
|
166
166
|
return (await ctx.ui.input(req.title, req.placeholder)) ?? undefined;
|
|
167
167
|
},
|
|
168
|
+
// Bridge for the `n` note dialog. Reuses the same text-input UI.
|
|
169
|
+
onRequestNote: async (req) => {
|
|
170
|
+
if (!ctx.hasUI) return undefined;
|
|
171
|
+
return (await ctx.ui.input(req.title, req.placeholder)) ?? undefined;
|
|
172
|
+
},
|
|
168
173
|
title: `pi-ask — ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
|
169
174
|
});
|
|
170
175
|
},
|
|
@@ -184,14 +189,16 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
const answers = result.answers ?? {};
|
|
192
|
+
const notes = result.notes ?? {};
|
|
187
193
|
// Pretty-print for the LLM
|
|
188
194
|
const out: string[] = ["User answers:"];
|
|
189
195
|
for (let i = 0; i < params.questions.length; i++) {
|
|
190
196
|
const q = params.questions[i];
|
|
191
197
|
if (!q) continue;
|
|
192
198
|
const a = answers[i];
|
|
199
|
+
let line: string;
|
|
193
200
|
if (a === undefined) {
|
|
194
|
-
|
|
201
|
+
line = ` Q${i + 1} (${q.header}): (no answer)`;
|
|
195
202
|
} else if (Array.isArray(a)) {
|
|
196
203
|
const parts: string[] = [];
|
|
197
204
|
for (const item of a) {
|
|
@@ -201,17 +208,22 @@ export default function piAskExtension(pi: ExtensionAPI) {
|
|
|
201
208
|
parts.push(`"${item}"`);
|
|
202
209
|
}
|
|
203
210
|
}
|
|
204
|
-
|
|
211
|
+
line = ` Q${i + 1} (${q.header}) [multi]: ${parts.join(", ")}`;
|
|
205
212
|
} else if (typeof a === "number") {
|
|
206
|
-
|
|
213
|
+
line = ` Q${i + 1} (${q.header}): ${q.options[a]?.label ?? `?${a}`}`;
|
|
207
214
|
} else {
|
|
208
|
-
|
|
215
|
+
line = ` Q${i + 1} (${q.header}) [Other]: "${a}"`;
|
|
216
|
+
}
|
|
217
|
+
// Append note if present
|
|
218
|
+
if (notes[i]) {
|
|
219
|
+
line += ` // note: "${notes[i]}"`;
|
|
209
220
|
}
|
|
221
|
+
out.push(line);
|
|
210
222
|
}
|
|
211
223
|
|
|
212
224
|
return {
|
|
213
225
|
content: [{ type: "text", text: out.join("\n") }],
|
|
214
|
-
details: { answers },
|
|
226
|
+
details: { answers, notes: Object.keys(notes).length > 0 ? notes : undefined },
|
|
215
227
|
};
|
|
216
228
|
},
|
|
217
229
|
});
|
package/ask/picker.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface AskOption {
|
|
|
31
31
|
label: string;
|
|
32
32
|
description?: string;
|
|
33
33
|
recommended?: boolean;
|
|
34
|
+
/** Optional preview content shown in a side panel when this option is
|
|
35
|
+
* focused. Use markdown or plain text to show code snippets, structure
|
|
36
|
+
* examples, or elaboration of what the option entails.
|
|
37
|
+
*
|
|
38
|
+
* Example:
|
|
39
|
+
* preview: "```ts\nclass Auth {\n token: string\n}\n```" */
|
|
40
|
+
preview?: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export interface AskQuestion {
|
|
@@ -59,6 +66,10 @@ export interface AskProResult {
|
|
|
59
66
|
cancelled?: boolean;
|
|
60
67
|
/** Map of question index → answer. Single: number | string. Multi: (number | string)[] */
|
|
61
68
|
answers?: Record<number, AskAnswer | AskMultiAnswer>;
|
|
69
|
+
/** Optional free-text notes the user added to specific questions.
|
|
70
|
+
* Keyed by question index. Added when the user pressed `n` after
|
|
71
|
+
* picking an option and typed a note. */
|
|
72
|
+
notes?: Record<number, string>;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** Options for the text-input dialog opened when "Other…" is picked. */
|
|
@@ -80,6 +91,10 @@ interface AskProComponentDeps {
|
|
|
80
91
|
* option is hidden even when `allowOther: true` (caller should ensure
|
|
81
92
|
* the dependency is present if it advertises allowOther). */
|
|
82
93
|
onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
94
|
+
/** Open a text-input dialog for adding a note to the current question
|
|
95
|
+
* (triggered by pressing `n`). Returns the typed text, or undefined
|
|
96
|
+
* if cancelled. If omitted, the `n` shortcut is a no-op. */
|
|
97
|
+
onRequestNote?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
// ---------------------------------------------------------------------------
|
|
@@ -107,12 +122,15 @@ export class AskProComponent extends Container {
|
|
|
107
122
|
private keybindings: KeybindingsManager;
|
|
108
123
|
private done: (result: AskProResult) => void;
|
|
109
124
|
private onRequestInput?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
125
|
+
private onRequestNote?: (req: AskProInputRequest) => Promise<string | undefined>;
|
|
110
126
|
private title: string;
|
|
111
127
|
|
|
112
128
|
private currentIndex = 0;
|
|
113
129
|
private selectedIndex = 0;
|
|
114
130
|
/** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
|
|
115
131
|
private answers = new Map<number, AskAnswer | AskMultiAnswer>();
|
|
132
|
+
/** notes[questionIdx] = free-text note added by user (via `n` key). */
|
|
133
|
+
private notes = new Map<number, string>();
|
|
116
134
|
/** Set true once `done` is called — further input is ignored. */
|
|
117
135
|
private completed = false;
|
|
118
136
|
/** Set while a text-input dialog is awaiting the user's reply. */
|
|
@@ -120,6 +138,7 @@ export class AskProComponent extends Container {
|
|
|
120
138
|
|
|
121
139
|
private tabsText!: Text;
|
|
122
140
|
private bodyContainer!: Container;
|
|
141
|
+
private previewText!: Text;
|
|
123
142
|
private footerText!: Text;
|
|
124
143
|
|
|
125
144
|
constructor(deps: AskProComponentDeps) {
|
|
@@ -129,6 +148,7 @@ export class AskProComponent extends Container {
|
|
|
129
148
|
this.keybindings = deps.keybindings;
|
|
130
149
|
this.done = deps.done;
|
|
131
150
|
this.onRequestInput = deps.onRequestInput;
|
|
151
|
+
this.onRequestNote = deps.onRequestNote;
|
|
132
152
|
this.title = deps.title ?? "pi-ask";
|
|
133
153
|
|
|
134
154
|
const titleText = new Text(this.theme.fg("accent", this.theme.bold(this.title)), 1, 0);
|
|
@@ -194,6 +214,11 @@ export class AskProComponent extends Container {
|
|
|
194
214
|
const q = this.questions[this.currentIndex];
|
|
195
215
|
if (!q) return;
|
|
196
216
|
|
|
217
|
+
// Compute the current preview (from the option under the cursor).
|
|
218
|
+
// Shown side-by-side with the option list via pad-right.
|
|
219
|
+
const currentPreview = this.currentPreviewLines();
|
|
220
|
+
const hasPreview = currentPreview.length > 0;
|
|
221
|
+
|
|
197
222
|
// Question line: "Q1 of 3: <question>"
|
|
198
223
|
this.bodyContainer.addChild(
|
|
199
224
|
new Text(
|
|
@@ -397,6 +422,12 @@ export class AskProComponent extends Container {
|
|
|
397
422
|
// Single-select: Enter is the action key
|
|
398
423
|
parts.push(this.theme.fg("accent", isLast ? "⏎ submit" : "⏎ next"));
|
|
399
424
|
}
|
|
425
|
+
// `n` hint: add/edit note (only if dep is wired)
|
|
426
|
+
if (this.onRequestNote) {
|
|
427
|
+
const hasNote = this.notes.has(this.currentIndex);
|
|
428
|
+
const hint = hasNote ? "n ✓note" : "n note";
|
|
429
|
+
parts.push(this.theme.fg(hasNote ? "success" : "dim", hint));
|
|
430
|
+
}
|
|
400
431
|
parts.push(this.theme.fg("dim", "esc cancel"));
|
|
401
432
|
return parts.join(" ");
|
|
402
433
|
}
|
|
@@ -565,6 +596,13 @@ export class AskProComponent extends Container {
|
|
|
565
596
|
}
|
|
566
597
|
return;
|
|
567
598
|
}
|
|
599
|
+
|
|
600
|
+
// `n` — add/edit a free-text note for the current question.
|
|
601
|
+
// Requires onRequestNote dep; otherwise ignored.
|
|
602
|
+
if (keyData === "n" && this.onRequestNote) {
|
|
603
|
+
void this.requestNoteInput();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
568
606
|
}
|
|
569
607
|
|
|
570
608
|
private handlePick(optionIdx: number): void {
|
|
@@ -659,14 +697,54 @@ export class AskProComponent extends Container {
|
|
|
659
697
|
}
|
|
660
698
|
}
|
|
661
699
|
|
|
700
|
+
/** Open a text-input dialog to add/edit a note for the current question.
|
|
701
|
+
* Triggered by the `n` key. Pre-fills with any existing note so the
|
|
702
|
+
* user can edit it. An empty submission clears the note. */
|
|
703
|
+
private async requestNoteInput(): Promise<void> {
|
|
704
|
+
if (!this.onRequestNote) return;
|
|
705
|
+
const q = this.questions[this.currentIndex];
|
|
706
|
+
if (!q) return;
|
|
707
|
+
this.awaitingInput = true;
|
|
708
|
+
const existing = this.notes.get(this.currentIndex) ?? "";
|
|
709
|
+
const text = await this.onRequestNote({
|
|
710
|
+
title: q.header,
|
|
711
|
+
prompt: `Add a note to your answer for: ${q.question}`,
|
|
712
|
+
placeholder: existing || "Add context, edge cases, or reasoning…",
|
|
713
|
+
});
|
|
714
|
+
this.awaitingInput = false;
|
|
715
|
+
if (text === undefined) {
|
|
716
|
+
// Cancelled — keep existing note, just redraw
|
|
717
|
+
this.repaint();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const trimmed = text.trim();
|
|
721
|
+
if (trimmed === "") {
|
|
722
|
+
// Empty submission clears the note
|
|
723
|
+
this.notes.delete(this.currentIndex);
|
|
724
|
+
} else {
|
|
725
|
+
this.notes.set(this.currentIndex, trimmed);
|
|
726
|
+
}
|
|
727
|
+
this.repaint();
|
|
728
|
+
}
|
|
729
|
+
|
|
662
730
|
private submit(): void {
|
|
663
731
|
if (!this.allAnswered()) return;
|
|
664
732
|
const answers: Record<number, AskAnswer | AskMultiAnswer> = {};
|
|
665
733
|
for (let i = 0; i < this.questions.length; i++) {
|
|
666
734
|
answers[i] = this.answers.get(i) as AskAnswer | AskMultiAnswer;
|
|
667
735
|
}
|
|
736
|
+
// Include notes only if at least one was added
|
|
737
|
+
const notes: Record<number, string> = {};
|
|
738
|
+
let hasNotes = false;
|
|
739
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
740
|
+
const n = this.notes.get(i);
|
|
741
|
+
if (n) {
|
|
742
|
+
notes[i] = n;
|
|
743
|
+
hasNotes = true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
668
746
|
this.completed = true;
|
|
669
|
-
this.done({ answers });
|
|
747
|
+
this.done(hasNotes ? { answers, notes } : { answers });
|
|
670
748
|
}
|
|
671
749
|
|
|
672
750
|
// -------------------------------------------------------------------------
|
|
@@ -678,6 +756,103 @@ export class AskProComponent extends Container {
|
|
|
678
756
|
this.completed = true;
|
|
679
757
|
this.awaitingInput = false;
|
|
680
758
|
}
|
|
759
|
+
|
|
760
|
+
// -----------------------------------------------------------------------
|
|
761
|
+
// Side-by-side render: stack picker column (left) with preview column
|
|
762
|
+
// (right) row-by-row. Each visible body row gets padded to SPLIT_COL and
|
|
763
|
+
// the matching preview row is appended. If no preview is set, the left
|
|
764
|
+
// column takes the full width (no padding).
|
|
765
|
+
// -----------------------------------------------------------------------
|
|
766
|
+
private static readonly SPLIT_COL = 60; // picker column width when preview is present
|
|
767
|
+
private static readonly PREVIEW_COL = 60; // preview column width
|
|
768
|
+
|
|
769
|
+
/** Lines of preview content for the option currently under the cursor.
|
|
770
|
+
* Returns [] when no option is focused or the option has no preview. */
|
|
771
|
+
private currentPreviewLines(): string[] {
|
|
772
|
+
const q = this.questions[this.currentIndex];
|
|
773
|
+
if (!q) return [];
|
|
774
|
+
// Only real options carry previews (not "Other…")
|
|
775
|
+
if (this.selectedIndex < 0 || this.selectedIndex >= q.options.length) return [];
|
|
776
|
+
const opt = q.options[this.selectedIndex];
|
|
777
|
+
if (!opt?.preview) return [];
|
|
778
|
+
// Trim and split on newlines; drop leading/trailing blank lines.
|
|
779
|
+
const lines = opt.preview
|
|
780
|
+
.replace(/\r\n/g, "\n")
|
|
781
|
+
.split("\n")
|
|
782
|
+
.map((l) => l.trimEnd());
|
|
783
|
+
while (lines.length > 0 && lines[0]?.trim() === "") lines.shift();
|
|
784
|
+
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "") lines.pop();
|
|
785
|
+
return lines;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Override render to produce a side-by-side layout when a preview is
|
|
789
|
+
* present. Falls back to default Container render otherwise. */
|
|
790
|
+
render(width: number): string[] {
|
|
791
|
+
const superLines = super.render(width);
|
|
792
|
+
const previewLines = this.currentPreviewLines();
|
|
793
|
+
if (previewLines.length === 0) return superLines;
|
|
794
|
+
|
|
795
|
+
// We need to find which body lines belong to the option list and
|
|
796
|
+
// merge the preview next to them. Simpler approach: append a preview
|
|
797
|
+
// block below the picker body, styled as a framed right-aligned panel.
|
|
798
|
+
// True side-by-side would require row index tracking which Container
|
|
799
|
+
// doesn't expose; the framed block is visually distinct and avoids
|
|
800
|
+
// fragile index math.
|
|
801
|
+
const splitCol = Math.min(AskProComponent.SPLIT_COL, Math.floor(width * 0.6));
|
|
802
|
+
const previewWidth = Math.max(30, width - splitCol - 3);
|
|
803
|
+
const border = this.theme.fg("dim", "│");
|
|
804
|
+
|
|
805
|
+
// Wrap preview lines to previewWidth
|
|
806
|
+
const wrapped: string[] = [];
|
|
807
|
+
for (const line of previewLines) {
|
|
808
|
+
if (line.length === 0) {
|
|
809
|
+
wrapped.push("");
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
// Simple greedy word wrap
|
|
813
|
+
const words = line.split(" ");
|
|
814
|
+
let cur = "";
|
|
815
|
+
for (const w of words) {
|
|
816
|
+
if (cur.length === 0) {
|
|
817
|
+
cur = w;
|
|
818
|
+
} else if (cur.length + 1 + w.length <= previewWidth) {
|
|
819
|
+
cur += " " + w;
|
|
820
|
+
} else {
|
|
821
|
+
wrapped.push(cur);
|
|
822
|
+
cur = w;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (cur) wrapped.push(cur);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Insert preview as a side panel: pad each picker line to splitCol,
|
|
829
|
+
// then add a vertical border and the matching preview line.
|
|
830
|
+
// We do this for ALL super lines so the preview spans the picker's
|
|
831
|
+
// full height.
|
|
832
|
+
const result: string[] = [];
|
|
833
|
+
// Header row above the preview content
|
|
834
|
+
result.push("".padEnd(splitCol) + " " + border + " " + this.theme.fg("dim", "— preview —"));
|
|
835
|
+
for (let i = 0; i < superLines.length; i++) {
|
|
836
|
+
const superLine = superLines[i] ?? "";
|
|
837
|
+
// Strip ANSI for width measurement
|
|
838
|
+
const visibleLen = superLine.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
839
|
+
const pad = Math.max(1, splitCol - visibleLen);
|
|
840
|
+
let row = superLine + " ".repeat(pad) + border + " ";
|
|
841
|
+
const pLine = wrapped[i];
|
|
842
|
+
if (pLine !== undefined) {
|
|
843
|
+
row += this.theme.fg("text", pLine);
|
|
844
|
+
}
|
|
845
|
+
result.push(row);
|
|
846
|
+
}
|
|
847
|
+
// If preview has more lines than the picker, append them below
|
|
848
|
+
if (wrapped.length > superLines.length) {
|
|
849
|
+
for (let i = superLines.length; i < wrapped.length; i++) {
|
|
850
|
+
const pLine = wrapped[i] ?? "";
|
|
851
|
+
result.push(" ".repeat(splitCol + 1) + border + " " + this.theme.fg("text", pLine));
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return result;
|
|
855
|
+
}
|
|
681
856
|
}
|
|
682
857
|
|
|
683
858
|
/** Type guard for the public component. */
|
package/ask/prompt.ts
CHANGED
|
@@ -30,8 +30,12 @@ DON'T use it for:
|
|
|
30
30
|
- When the user already gave a clear answer — don't second-guess
|
|
31
31
|
- Trivial clarifications — use plain text first, escalate to \`ask_pro\` only if the answer matters
|
|
32
32
|
|
|
33
|
-
Keyboard in the picker: \`↑↓\` navigate, \`1-N\` instant-pick, \`Tab\` next question, \`Space\` toggle (multi-select only), \`Enter\` confirm/advance/submit, \`Esc\` cancel.
|
|
33
|
+
Keyboard in the picker: \`↑↓\` navigate, \`1-N\` instant-pick, \`Tab\` next question, \`Space\` toggle (multi-select only), \`Enter\` confirm/advance/submit, \`n\` add a free-text note to the current question, \`Esc\` cancel.
|
|
34
34
|
|
|
35
|
-
Schema reminder: \`questions: [{ header, question, options: [{label, description?, recommended?}], multiSelect? }]\`. Mark exactly one option \`recommended: true\` per question when you have a default.
|
|
35
|
+
Schema reminder: \`questions: [{ header, question, options: [{label, description?, recommended?, preview?}], multiSelect? }]\`. Mark exactly one option \`recommended: true\` per question when you have a default.
|
|
36
|
+
|
|
37
|
+
**Option previews:** \`option.preview\` (markdown/plain string) shows in a side panel next to the option list while that option is focused. Use it when the question is about a code structure, API shape, or concrete example — show a small snippet of what each option entails so the user can decide without asking follow-ups. Example: when asking "how should we model auth?", each option's preview can show the relevant type signature.
|
|
38
|
+
|
|
39
|
+
**Notes:** the user can press \`n\` after picking an answer to attach a free-text note (edge cases, constraints, reasoning). The note is returned to you as \`// note: \"...\"\` next to the chosen answer. Treat it as a hard constraint.
|
|
36
40
|
`;
|
|
37
41
|
}
|
package/ask/tests/picker.test.ts
CHANGED
|
@@ -586,3 +586,165 @@ describe("AskProComponent — Other… option (allowOther)", () => {
|
|
|
586
586
|
expect(picker.getSelectedIndex()).toBeLessThan(2);
|
|
587
587
|
});
|
|
588
588
|
});
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// Option previews (side-by-side)
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
describe("AskProComponent — option previews", () => {
|
|
595
|
+
test("currentPreviewLines returns [] when option has no preview", () => {
|
|
596
|
+
const { picker } = setup();
|
|
597
|
+
// Type-level access: currentPreviewLines is private; verify via render
|
|
598
|
+
// (no preview lines means render falls back to super.render width-only)
|
|
599
|
+
const lines = picker.render(80);
|
|
600
|
+
// Just verify it doesn't throw and returns some lines
|
|
601
|
+
expect(lines.length).toBeGreaterThan(0);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
test("render includes preview content when option has preview", () => {
|
|
605
|
+
const qsWithPreview: AskQuestion[] = [
|
|
606
|
+
{
|
|
607
|
+
header: "Model",
|
|
608
|
+
question: "Which schema?",
|
|
609
|
+
options: [
|
|
610
|
+
{
|
|
611
|
+
label: "Relational",
|
|
612
|
+
description: "Postgres + strict schema",
|
|
613
|
+
preview: "CREATE TABLE users (\n id SERIAL PRIMARY KEY,\n email TEXT NOT NULL\n);",
|
|
614
|
+
},
|
|
615
|
+
{ label: "Document", description: "MongoDB" },
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
const { picker } = setup(qsWithPreview);
|
|
620
|
+
const lines = picker.render(100);
|
|
621
|
+
const joined = lines.join("\n");
|
|
622
|
+
// Preview content should appear in the rendered output
|
|
623
|
+
expect(joined).toContain("CREATE TABLE");
|
|
624
|
+
expect(joined).toContain("preview");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("preview disappears when focused option has none", () => {
|
|
628
|
+
const qsWithPreview: AskQuestion[] = [
|
|
629
|
+
{
|
|
630
|
+
header: "Model",
|
|
631
|
+
question: "Which schema?",
|
|
632
|
+
options: [
|
|
633
|
+
{
|
|
634
|
+
label: "Relational",
|
|
635
|
+
description: "Postgres",
|
|
636
|
+
preview: "CREATE TABLE users (id INT);",
|
|
637
|
+
},
|
|
638
|
+
{ label: "Document", description: "MongoDB" },
|
|
639
|
+
],
|
|
640
|
+
},
|
|
641
|
+
];
|
|
642
|
+
const { picker } = setup(qsWithPreview);
|
|
643
|
+
// Focus option 0 (has preview)
|
|
644
|
+
picker.handleInput("j"); // wait, default 0 already; move to 1
|
|
645
|
+
picker.handleInput("j"); // option 1 (no preview)
|
|
646
|
+
const lines = picker.render(100);
|
|
647
|
+
const joined = lines.join("\n");
|
|
648
|
+
// No preview lines for option without preview
|
|
649
|
+
expect(joined).not.toContain("CREATE TABLE");
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// Notes (`n` key)
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
describe("AskProComponent — notes (n key)", () => {
|
|
658
|
+
test("n is a no-op when onRequestNote is not provided", () => {
|
|
659
|
+
const { picker } = setup();
|
|
660
|
+
// Should not throw, no effect
|
|
661
|
+
expect(() => picker.handleInput("n")).not.toThrow();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("n opens note dialog when onRequestNote is provided", async () => {
|
|
665
|
+
let noteRequestCount = 0;
|
|
666
|
+
let resolveNote: (value: string | undefined) => void = () => {};
|
|
667
|
+
const picker = new AskProComponent({
|
|
668
|
+
questions: sampleQuestions,
|
|
669
|
+
theme,
|
|
670
|
+
keybindings,
|
|
671
|
+
done: () => {},
|
|
672
|
+
onRequestNote: async () => {
|
|
673
|
+
noteRequestCount++;
|
|
674
|
+
return new Promise<string | undefined>((r) => {
|
|
675
|
+
resolveNote = r;
|
|
676
|
+
});
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
// `n` should trigger note request
|
|
680
|
+
expect(noteRequestCount).toBe(0);
|
|
681
|
+
picker.handleInput("n");
|
|
682
|
+
// Microtask to let async start
|
|
683
|
+
await Promise.resolve();
|
|
684
|
+
expect(noteRequestCount).toBe(1);
|
|
685
|
+
// Resolve with a note
|
|
686
|
+
resolveNote("only for prod, use TLS");
|
|
687
|
+
await Promise.resolve();
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("note is included in submit result", async () => {
|
|
691
|
+
let doneResult: AskProResult | null = null;
|
|
692
|
+
const pendingNotes: Array<(v: string | undefined) => void> = [];
|
|
693
|
+
const picker = new AskProComponent({
|
|
694
|
+
questions: sampleQuestions,
|
|
695
|
+
theme,
|
|
696
|
+
keybindings,
|
|
697
|
+
done: (r) => {
|
|
698
|
+
doneResult = r;
|
|
699
|
+
},
|
|
700
|
+
onRequestNote: async () => {
|
|
701
|
+
return new Promise<string | undefined>((resolve) => {
|
|
702
|
+
pendingNotes.push(resolve);
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
// Pick Q1 → advance to Q2
|
|
707
|
+
picker.handleInput("1");
|
|
708
|
+
// Add note to Q2
|
|
709
|
+
picker.handleInput("n");
|
|
710
|
+
// Wait for the promise to register
|
|
711
|
+
await Promise.resolve();
|
|
712
|
+
await Promise.resolve();
|
|
713
|
+
expect(pendingNotes.length).toBe(1);
|
|
714
|
+
pendingNotes[0]!("rotate keys monthly");
|
|
715
|
+
// Wait for the dialog to settle
|
|
716
|
+
await Promise.resolve();
|
|
717
|
+
await Promise.resolve();
|
|
718
|
+
// Submit Q2 (last question, all answered)
|
|
719
|
+
picker.handleInput("1");
|
|
720
|
+
expect(doneResult).not.toBeNull();
|
|
721
|
+
const result = doneResult as AskProResult | null;
|
|
722
|
+
expect(result?.notes).toBeDefined();
|
|
723
|
+
expect(result?.notes?.[1]).toBe("rotate keys monthly");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test("empty note submission clears existing note", async () => {
|
|
727
|
+
let resolveNote: (value: string | undefined) => void = () => {};
|
|
728
|
+
const picker = new AskProComponent({
|
|
729
|
+
questions: sampleQuestions,
|
|
730
|
+
theme,
|
|
731
|
+
keybindings,
|
|
732
|
+
done: () => {},
|
|
733
|
+
onRequestNote: async () => {
|
|
734
|
+
return new Promise<string | undefined>((r) => {
|
|
735
|
+
resolveNote = r;
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
});
|
|
739
|
+
// Add a note
|
|
740
|
+
picker.handleInput("n");
|
|
741
|
+
resolveNote("first note");
|
|
742
|
+
await Promise.resolve();
|
|
743
|
+
// Clear it with empty
|
|
744
|
+
picker.handleInput("n");
|
|
745
|
+
resolveNote(" ");
|
|
746
|
+
await Promise.resolve();
|
|
747
|
+
// No crash — note cleared
|
|
748
|
+
expect(() => picker.render(80)).not.toThrow();
|
|
749
|
+
});
|
|
750
|
+
});
|
package/commands.ts
CHANGED
|
@@ -51,6 +51,7 @@ export interface CommandUI {
|
|
|
51
51
|
notify: (text: string, kind?: "info" | "warning" | "error") => void;
|
|
52
52
|
select: (label: string, options: string[]) => Promise<number | null>;
|
|
53
53
|
confirm: (title: string, message: string) => Promise<boolean>;
|
|
54
|
+
input?: (label: string, placeholder?: string) => Promise<string | undefined>;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export interface CommandsDeps {
|
|
@@ -430,7 +431,13 @@ What must the LLM do?
|
|
|
430
431
|
| "minimal" | "web-app" | "library" | "cli" | undefined) ?? undefined;
|
|
431
432
|
const autoYes = args.includes("--yes");
|
|
432
433
|
const projectName = args.match(/--name[= ](\S+)/)?.[1];
|
|
433
|
-
|
|
434
|
+
const initUi = {
|
|
435
|
+
notify: (t: string, k?: "info" | "warning" | "error") => ctx.ui.notify(t, k ?? "info"),
|
|
436
|
+
select: async (label: string, options: string[]) => ctx.ui.select(label, options),
|
|
437
|
+
confirm: (title: string, message: string) => ctx.ui.confirm(title, message),
|
|
438
|
+
input: (label: string, placeholder?: string) => ctx.ui.input(label, placeholder),
|
|
439
|
+
};
|
|
440
|
+
await initSolyProject(ctx.cwd, initUi, { template, autoYes, projectName });
|
|
434
441
|
},
|
|
435
442
|
});
|
|
436
443
|
|
package/core.ts
CHANGED
|
@@ -26,7 +26,10 @@ import * as path from "node:path";
|
|
|
26
26
|
export type RuleSource =
|
|
27
27
|
| "project-soly"
|
|
28
28
|
| "global-soly"
|
|
29
|
-
| "phase-soly"
|
|
29
|
+
| "phase-soly"
|
|
30
|
+
| "project-agents"
|
|
31
|
+
| "global-agents"
|
|
32
|
+
| "phase-agents";
|
|
30
33
|
|
|
31
34
|
export interface RuleFrontmatter {
|
|
32
35
|
description?: string;
|
|
@@ -59,7 +62,7 @@ export interface RuleFile {
|
|
|
59
62
|
enabled: boolean;
|
|
60
63
|
mtimeMs: number;
|
|
61
64
|
source: RuleSource;
|
|
62
|
-
sourceLabel: "soly" | "phase" | "local";
|
|
65
|
+
sourceLabel: "soly" | "phase" | "local" | "agents";
|
|
63
66
|
priority: number; // higher wins on relPath collision
|
|
64
67
|
/** Phase number for phase-scoped rules; undefined otherwise. */
|
|
65
68
|
phaseNumber?: number;
|
|
@@ -70,7 +73,7 @@ export interface RuleFile {
|
|
|
70
73
|
export interface SourceSpec {
|
|
71
74
|
dir: string;
|
|
72
75
|
source: RuleSource;
|
|
73
|
-
sourceLabel: "soly" | "phase" | "local";
|
|
76
|
+
sourceLabel: "soly" | "phase" | "local" | "agents";
|
|
74
77
|
priority: number; // higher wins on relPath collision
|
|
75
78
|
/** Optional phase number (for phase-scoped sources). */
|
|
76
79
|
phaseNumber?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Project management + workflow engine for pi-coding-agent. Plans, state, MANDATORY rules, multi-question picker — one npm install, zero config. LLM is the executor (no subagent layer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|