pi-soly 1.11.2 → 1.12.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.
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
@@ -0,0 +1,368 @@
1
+ // =============================================================================
2
+ // session.ts — shared handle + paths for the artifact server (per project)
3
+ // =============================================================================
4
+ //
5
+ // The artifact tool, the `/artifacts` command, and the status chrome all need
6
+ // the current server (gallery URL, count, list) — and they need it to survive
7
+ // /reload, pi restarts, AND having a second pi window open in the same folder.
8
+ //
9
+ // So the server is PER PROJECT, not per session:
10
+ // • files live in a STABLE per-project dir (hash of the cwd, under the OS temp
11
+ // dir or a configured base) with an on-disk manifest (index.json) — restored
12
+ // on every start;
13
+ // • the server binds a PINNED 127.0.0.1 port + route token derived from the
14
+ // cwd → the SAME gallery URL across windows and restarts;
15
+ // • a registry file (server.json) lets a second window DISCOVER an
16
+ // already-running server and reuse it as a remote client (mutating it over
17
+ // HTTP) instead of starting a second one.
18
+ //
19
+ // This module owns the discovery (registry + liveness probe), the in-process
20
+ // handle (local server OR remote client), and the path math so every surface
21
+ // resolves the same directory and the same URL.
22
+ // =============================================================================
23
+
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import * as fs from "node:fs";
27
+ import * as http from "node:http";
28
+ import { createHash } from "node:crypto";
29
+ import { ArtifactServer, type ArtifactHandle, type ArtifactEntry } from "./server.ts";
30
+ import type { GalleryEntry } from "./render.ts";
31
+ import { atomicWriteFileSync } from "../util.ts";
32
+
33
+ /** Pinned ports live just below the OS ephemeral range, in a narrow band so a
34
+ * project's URL stays stable while keeping collision odds low. */
35
+ const PINNED_PORT_BASE = 43120;
36
+ const PINNED_PORT_RANGE = 2048;
37
+
38
+ /** Liveness probe / write timeouts (ms) — short, since everything is 127.0.0.1. */
39
+ const PROBE_TIMEOUT_MS = 300;
40
+ const WRITE_TIMEOUT_MS = 2000;
41
+
42
+ let current: ArtifactHandle | null = null;
43
+ let currentDir: string | null = null;
44
+ /** True when THIS process started (owns) the running server. */
45
+ let isOwner = false;
46
+
47
+ /** The active artifact handle, or null if none has started this run. */
48
+ export function getArtifactServer(): ArtifactHandle | null {
49
+ return current;
50
+ }
51
+
52
+ /** Base artifact directory: config override (abs / ~ / relative to cwd) or the
53
+ * OS temp dir under pi-soly-artifacts/. */
54
+ export function resolveArtifactBase(configDir: string, cwd: string): string {
55
+ const d = configDir.trim();
56
+ if (!d) return path.join(os.tmpdir(), "pi-soly-artifacts");
57
+ if (d === "~" || d.startsWith("~/") || d.startsWith("~\\")) {
58
+ return path.join(os.homedir(), d.slice(1).replace(/^[/\\]/, ""));
59
+ }
60
+ return path.isAbsolute(d) ? d : path.join(cwd, d);
61
+ }
62
+
63
+ /** Stable per-project key (hash of the absolute cwd) → same folder across
64
+ * reloads/restarts, so a project's artifacts persist and stay browsable. */
65
+ export function projectKey(cwd: string): string {
66
+ return projectHash(cwd).slice(0, 12);
67
+ }
68
+
69
+ /** Full hex hash of the absolute cwd — the single source for port + token. */
70
+ function projectHash(cwd: string): string {
71
+ return createHash("sha1").update(path.resolve(cwd)).digest("hex");
72
+ }
73
+
74
+ /** 16-hex pinned route token derived from cwd (stable across windows/restarts). */
75
+ export function pinnedToken(cwd: string): string {
76
+ return projectHash(cwd).slice(0, 16);
77
+ }
78
+
79
+ /** Pinned 127.0.0.1 port for this project (falls back to ephemeral if busy). */
80
+ export function pinnedPort(cwd: string): number {
81
+ const buf = createHash("sha1").update(path.resolve(cwd)).digest();
82
+ return PINNED_PORT_BASE + (buf.readUInt32BE(0) % PINNED_PORT_RANGE);
83
+ }
84
+
85
+ /** The stable per-project artifact directory. */
86
+ export function artifactDir(configDir: string, cwd: string): string {
87
+ return path.join(resolveArtifactBase(configDir, cwd), projectKey(cwd));
88
+ }
89
+
90
+ // ----------------------------------------------------------------------------
91
+ // Registry: lets one pi window discover a server another window already runs
92
+ // ----------------------------------------------------------------------------
93
+
94
+ type Registry = { port: number; token: string; pid: number; startedAt: number };
95
+
96
+ function registryPath(dir: string): string {
97
+ return path.join(dir, "server.json");
98
+ }
99
+
100
+ function readRegistry(dir: string): Registry | null {
101
+ try {
102
+ const raw = JSON.parse(fs.readFileSync(registryPath(dir), "utf-8")) as Partial<Registry>;
103
+ if (raw && typeof raw.port === "number" && typeof raw.token === "string") {
104
+ return { port: raw.port, token: raw.token, pid: raw.pid ?? 0, startedAt: raw.startedAt ?? 0 };
105
+ }
106
+ } catch {
107
+ // missing or corrupt — treat as "no known server"
108
+ }
109
+ return null;
110
+ }
111
+
112
+ function writeRegistry(dir: string, reg: Registry): void {
113
+ try {
114
+ atomicWriteFileSync(registryPath(dir), JSON.stringify(reg));
115
+ } catch {
116
+ // best effort
117
+ }
118
+ }
119
+
120
+ function deleteRegistry(dir: string): void {
121
+ try {
122
+ fs.unlinkSync(registryPath(dir));
123
+ } catch {
124
+ // best effort
125
+ }
126
+ }
127
+
128
+ // ----------------------------------------------------------------------------
129
+ // Tiny 127.0.0.1 HTTP helpers used by the probe + the remote client
130
+ // ----------------------------------------------------------------------------
131
+
132
+ function httpGetJson(port: number, token: string, seg: string, timeoutMs: number): Promise<unknown> {
133
+ return new Promise((resolve) => {
134
+ const req = http.get(
135
+ { hostname: "127.0.0.1", port, path: `/${token}/${seg}`, timeout: timeoutMs },
136
+ (res) => {
137
+ let data = "";
138
+ res.on("data", (c: Buffer | string) => (data += c));
139
+ res.on("end", () => {
140
+ if (res.statusCode && res.statusCode < 300) {
141
+ try {
142
+ resolve(data ? JSON.parse(data) : null);
143
+ } catch {
144
+ resolve(null);
145
+ }
146
+ } else {
147
+ resolve(null);
148
+ }
149
+ });
150
+ },
151
+ );
152
+ req.on("timeout", () => req.destroy());
153
+ req.on("error", () => resolve(null));
154
+ });
155
+ }
156
+
157
+ function httpPostJson(
158
+ port: number,
159
+ token: string,
160
+ seg: string,
161
+ body: unknown,
162
+ timeoutMs: number,
163
+ ): Promise<unknown> {
164
+ return new Promise((resolve, reject) => {
165
+ const data = JSON.stringify(body);
166
+ const req = http.request(
167
+ {
168
+ hostname: "127.0.0.1",
169
+ port,
170
+ path: `/${token}/${seg}`,
171
+ method: "POST",
172
+ headers: { "content-type": "application/json", "content-length": Buffer.byteLength(data) },
173
+ timeout: timeoutMs,
174
+ },
175
+ (res) => {
176
+ let d = "";
177
+ res.on("data", (c: Buffer | string) => (d += c));
178
+ res.on("end", () => {
179
+ if (res.statusCode && res.statusCode < 300) {
180
+ try {
181
+ resolve(d ? JSON.parse(d) : null);
182
+ } catch {
183
+ resolve(null);
184
+ }
185
+ } else {
186
+ reject(new Error(`artifact server responded ${res.statusCode}`));
187
+ }
188
+ });
189
+ },
190
+ );
191
+ req.on("timeout", () => req.destroy(new Error("artifact server write timed out")));
192
+ req.on("error", reject);
193
+ req.end(data);
194
+ });
195
+ }
196
+
197
+ /** Probe a server's list endpoint; returns entries if alive, else null. */
198
+ async function probeEntries(port: number, token: string): Promise<GalleryEntry[] | null> {
199
+ const r = (await httpGetJson(port, token, "list", PROBE_TIMEOUT_MS)) as GalleryEntry[] | null;
200
+ return Array.isArray(r) ? r : null;
201
+ }
202
+
203
+ // ----------------------------------------------------------------------------
204
+ // Remote client: reuse another window's already-running server over HTTP
205
+ // ----------------------------------------------------------------------------
206
+
207
+ /**
208
+ * A {@link ArtifactHandle} backed by ANOTHER pi window's running server. Mutates
209
+ * the owner's list over HTTP; keeps a local cache so sync surfaces (the footer
210
+ * count, `/artifacts` list) keep working without a round-trip on every render.
211
+ */
212
+ export class RemoteArtifactClient implements ArtifactHandle {
213
+ private opened = false;
214
+ private cached: GalleryEntry[] = [];
215
+
216
+ constructor(
217
+ private readonly port: number,
218
+ private readonly token: string,
219
+ initial: GalleryEntry[],
220
+ ) {
221
+ this.cached = initial.slice();
222
+ }
223
+
224
+ get count(): number {
225
+ return this.cached.length;
226
+ }
227
+
228
+ galleryUrl(): string {
229
+ return `http://127.0.0.1:${this.port}/${this.token}/`;
230
+ }
231
+
232
+ private artifactUrl(file: string): string {
233
+ return `http://127.0.0.1:${this.port}/${this.token}/a/${encodeURIComponent(path.basename(file))}`;
234
+ }
235
+
236
+ list(): ArtifactEntry[] {
237
+ return this.cached.map((e) => ({ ...e, url: this.artifactUrl(e.file) }));
238
+ }
239
+
240
+ /** Re-probe the owner server; refresh the cache if still alive. */
241
+ async refresh(): Promise<boolean> {
242
+ const entries = await probeEntries(this.port, this.token);
243
+ if (!entries) return false;
244
+ this.cached = entries;
245
+ return true;
246
+ }
247
+
248
+ async register(title: string, file: string, id?: string): Promise<string> {
249
+ const res = (await httpPostJson(
250
+ this.port,
251
+ this.token,
252
+ "register",
253
+ { title, file: path.basename(file), id },
254
+ WRITE_TIMEOUT_MS,
255
+ )) as { entries?: GalleryEntry[] } | null;
256
+ if (res?.entries) this.cached = res.entries;
257
+ return this.artifactUrl(file);
258
+ }
259
+
260
+ remove(id: string): boolean {
261
+ const i = this.cached.findIndex((e) => e.id === id);
262
+ if (i < 0) return false;
263
+ this.cached.splice(i, 1);
264
+ void httpPostJson(this.port, this.token, "remove", { id }, WRITE_TIMEOUT_MS).catch(() => {});
265
+ return true;
266
+ }
267
+
268
+ clear(): number {
269
+ const n = this.cached.length;
270
+ this.cached = [];
271
+ void httpPostJson(this.port, this.token, "clear", {}, WRITE_TIMEOUT_MS).catch(() => {});
272
+ return n;
273
+ }
274
+
275
+ consumeFirstOpen(): boolean {
276
+ if (this.opened) return false;
277
+ this.opened = true;
278
+ return true;
279
+ }
280
+ }
281
+
282
+ // ----------------------------------------------------------------------------
283
+ // Discovery + lifecycle
284
+ // ----------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Get-or-resolve the artifact handle for `dir`/`cwd`:
288
+ * 1. if already resolved this run, return it;
289
+ * 2. if another window's server is alive (registry or pinned port), reuse it
290
+ * as a {@link RemoteArtifactClient};
291
+ * 3. otherwise start our own {@link ArtifactServer} on the pinned port and
292
+ * become its owner (writing the registry so later windows find us).
293
+ */
294
+ export async function ensureArtifactServer(dir: string, cwd: string): Promise<ArtifactHandle> {
295
+ fs.mkdirSync(dir, { recursive: true });
296
+ if (current) {
297
+ if (current instanceof RemoteArtifactClient) {
298
+ // Another window's server may have died since we resolved — re-probe
299
+ // before trusting the cache; if it's gone, start our own below.
300
+ if (await current.refresh()) return current;
301
+ current = null;
302
+ currentDir = null;
303
+ isOwner = false;
304
+ } else {
305
+ return current; // we own it → valid for this whole run
306
+ }
307
+ }
308
+
309
+ const token = pinnedToken(cwd);
310
+ const port = pinnedPort(cwd);
311
+
312
+ // Reuse path A: a registry written by another window points at a live server.
313
+ const reg = readRegistry(dir);
314
+ if (reg && reg.token === token) {
315
+ const entries = await probeEntries(reg.port, token);
316
+ if (entries) {
317
+ current = new RemoteArtifactClient(reg.port, token, entries);
318
+ currentDir = dir;
319
+ isOwner = false;
320
+ return current;
321
+ }
322
+ }
323
+
324
+ // Reuse path B: no (matching) registry, but a server with our token may still
325
+ // be up on the pinned port — probe it directly before starting our own.
326
+ if (!reg || reg.port !== port || reg.token !== token) {
327
+ const entries = await probeEntries(port, token);
328
+ if (entries) {
329
+ writeRegistry(dir, { port, token, pid: process.pid, startedAt: Date.now() });
330
+ current = new RemoteArtifactClient(port, token, entries);
331
+ currentDir = dir;
332
+ isOwner = false;
333
+ return current;
334
+ }
335
+ }
336
+
337
+ // Start path: no live server — own one on the pinned port.
338
+ const server = new ArtifactServer(dir, port, token);
339
+ await server.ensureStarted();
340
+ writeRegistry(dir, { port: server.boundPort, token, pid: process.pid, startedAt: Date.now() });
341
+ current = server;
342
+ currentDir = dir;
343
+ isOwner = true;
344
+ return current;
345
+ }
346
+
347
+ /** Drop the cached handle so the next {@link ensureArtifactServer} re-probes.
348
+ * Used when a register call fails (the owner likely died) before a retry. */
349
+ export function invalidateArtifactServer(): void {
350
+ current = null;
351
+ currentDir = null;
352
+ isOwner = false;
353
+ }
354
+
355
+ /**
356
+ * Stop the server if THIS process owns it (frees the pinned port + removes the
357
+ * registry so a later window starts fresh). If we were only a client, leave the
358
+ * owner's server and registry untouched. Safe to call when nothing is running.
359
+ */
360
+ export function disposeArtifactServer(): void {
361
+ if (isOwner && current instanceof ArtifactServer) {
362
+ current.stop();
363
+ if (currentDir) deleteRegistry(currentDir);
364
+ }
365
+ current = null;
366
+ currentDir = null;
367
+ isOwner = false;
368
+ }
package/ask/README.md CHANGED
@@ -12,8 +12,15 @@ showing a **tabbed, multi-question picker** in pi's TUI.
12
12
  is marked ⭐
13
13
  - **Single-select** (default) — Enter on an option auto-advances to the next
14
14
  question; on the last question, Enter submits
15
- - **Multi-select** — Enter toggles checkboxes; the last question shows a
16
- visible "Submit" row
15
+ - **Multi-select** — Space toggles checkboxes; `minSelect`/`maxSelect` bound how
16
+ many may be chosen; the last question shows a visible "Submit" row
17
+ - **Free-text questions** — `freeText: true` (with empty `options`) makes a
18
+ typed-answer question; it's optional (blank is allowed)
19
+ - **Option previews** — per-option `preview` shows a side panel while focused;
20
+ fenced ```code blocks in it are syntax-highlighted
21
+ - **"Other…"** — per-question `allowOther: true` adds a free-text custom choice
22
+ - **Notes & skip** — `n` attaches a free-text note to any answer; `s` skips a
23
+ question (returned in `skipped`)
17
24
  - **Cancelled detection** — `Esc` resolves `{cancelled: true}`
18
25
 
19
26
  ## Usage from an LLM
@@ -83,8 +90,10 @@ Result:
83
90
  | `1` – `4` | Instant-pick that option |
84
91
  | `Tab` / `→` | Next question |
85
92
  | `Shift+Tab` / `←` / `Backspace` | Previous question |
86
- | `Space` | Multi-select only: toggle the current option. On "Other…", opens the input dialog. |
87
- | `Enter` | **Single-select:** confirm + advance (or submit on last). **Multi-select:** advance to next question (or submit on last + all answered). Does NOT toggle. |
93
+ | `Space` | Multi-select only: toggle the current option (ignored past `maxSelect`). On "Other…", opens the input dialog. |
94
+ | `Enter` | **Single-select:** confirm + advance (or submit on last). **Multi-select:** advance to next question (or submit on last + all answered). Does NOT toggle. **Free-text:** commit the typed answer + advance/submit. |
95
+ | `n` | Add/edit a free-text note on the current answer |
96
+ | `s` | Skip the current question (reported as skipped, omitted from answers) |
88
97
  | `Esc` | Cancel (returns `{cancelled: true}`) |
89
98
 
90
99
  Multi-select: **Space toggles, Enter advances/submits**. Single-select
@@ -95,17 +104,20 @@ shows `⏎ submit` in accent color.
95
104
  ## Limits
96
105
 
97
106
  - 2–4 options per question (more is bad UX; the picker is meant for focused
98
- choices, not long lists)
107
+ choices, not long lists) — except `freeText: true` questions, which take no
108
+ options
99
109
  - 1–6 questions per call (more = tab-switching fatigue)
100
110
  - At most 1 `recommended: true` per question
111
+ - `minSelect`/`maxSelect` apply to multi-select only and must fall within the
112
+ option count
101
113
  - TUI and RPC modes only (`hasUI: true`); print mode returns an error
102
114
 
103
115
  ## Development
104
116
 
105
117
  ```bash
106
- cd packages/pi-soly/ask
107
- bun test # runs tests/picker.test.ts
108
- bun run typecheck # tsc --noEmit
118
+ cd packages/pi-soly
119
+ bun test tests/ask-picker.test.ts # picker behavior (C/D/E/A features)
120
+ bun run typecheck # tsc --noEmit
109
121
  ```
110
122
 
111
123
  ## Why a separate module?
package/ask/index.ts CHANGED
@@ -21,63 +21,99 @@
21
21
  // =============================================================================
22
22
 
23
23
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
24
+ import { highlightCode } from "@earendil-works/pi-coding-agent";
24
25
  import { Type } from "typebox";
25
26
  import { AskProComponent, type AskProResult } from "./picker.ts";
26
- import { buildAskProSection } from "./prompt.ts";
27
27
 
28
- export default function piAskExtension(pi: ExtensionAPI) {
29
- // Inject a "when to use ask_pro" section into the system prompt so the
30
- // LLM reaches for the picker at the right times (and avoids overusing
31
- // it for trivial yes/no or open-ended questions).
32
- pi.on("before_agent_start", async (event) => {
33
- return {
34
- systemPrompt: event.systemPrompt + buildAskProSection(),
35
- };
28
+ type ToolError = {
29
+ content: { type: "text"; text: string }[];
30
+ details: Record<string, unknown>;
31
+ };
32
+
33
+ type BoundsCheck = {
34
+ header: string;
35
+ multiSelect?: boolean;
36
+ minSelect?: number;
37
+ maxSelect?: number;
38
+ allowOther?: boolean;
39
+ options: unknown[];
40
+ };
41
+
42
+ /** Validate multi-select min/max bounds. Returns a tool error, or null if OK.
43
+ * No-op for single-select questions. */
44
+ function validateSelectBounds(q: BoundsCheck, i: number): ToolError | null {
45
+ if (!q.multiSelect) return null;
46
+ const max = q.options.length + (q.allowOther ? 1 : 0);
47
+ const err = (text: string, error: string): ToolError => ({
48
+ content: [{ type: "text", text: `ask_pro: Q${i + 1} ("${q.header}") ${text}` }],
49
+ details: { error, questionIdx: i },
36
50
  });
51
+ if (q.minSelect !== undefined && (q.minSelect < 1 || q.minSelect > max)) {
52
+ return err(`minSelect ${q.minSelect} out of range (1-${max}).`, "bad_min_select");
53
+ }
54
+ if (q.maxSelect !== undefined && (q.maxSelect < 1 || q.maxSelect > max)) {
55
+ return err(`maxSelect ${q.maxSelect} out of range (1-${max}).`, "bad_max_select");
56
+ }
57
+ if (
58
+ q.minSelect !== undefined &&
59
+ q.maxSelect !== undefined &&
60
+ q.minSelect > q.maxSelect
61
+ ) {
62
+ return err(`minSelect ${q.minSelect} > maxSelect ${q.maxSelect}.`, "min_gt_max");
63
+ }
64
+ return null;
65
+ }
37
66
 
67
+ export default function piAskExtension(pi: ExtensionAPI) {
68
+ // Usage guidance lives in the soly-framework skill (loaded on demand) +
69
+ // a one-line pointer in the main soly system prompt — not injected here.
38
70
  pi.registerTool({
39
71
  name: "ask_pro",
40
- label: "pi-ask ask_pro",
72
+ label: "soly · ask_pro",
41
73
  description:
42
- "Ask the user multiple questions at once via a tabbed picker. Each question is a tab at the top. Options are numbered (1-N instant-pick), the recommended answer is marked ⭐. Supports single-select (default, auto-advance on pick) and multi-select (Enter toggles, last question shows Submit). All answers returned in one call. Use for progressive Q&A flows like `soly discuss`.",
74
+ "Ask the user several questions at once via a tabbed picker; returns all answers in one call. Per question: single-select (default), `multiSelect` (+ `minSelect`/`maxSelect`), `allowOther` (free-text choice), or `freeText` (typed answer, no options). Per option: `recommended` () and `preview` (side panel, fenced code highlighted). User can skip (`s`) or note (`n`). Prefer over one-by-one questions; good for `soly discuss`.",
43
75
  parameters: Type.Object({
44
76
  questions: Type.Array(
45
77
  Type.Object({
46
- header: Type.String({
47
- description: "Short label for the tab (1-2 words, max 12 chars).",
48
- }),
49
- question: Type.String({
50
- description: "The full question to ask.",
51
- }),
78
+ header: Type.String({ description: "Tab label (1-2 words)." }),
79
+ question: Type.String({ description: "The question." }),
52
80
  options: Type.Array(
53
81
  Type.Object({
54
- label: Type.String({
55
- description: "Short label (1-5 words).",
56
- }),
82
+ label: Type.String({ description: "Short label." }),
57
83
  description: Type.Optional(
58
- Type.String({
59
- description: "1-2 sentence explanation. Shown below the label.",
60
- }),
84
+ Type.String({ description: "1-2 sentence explanation, shown below the label." }),
61
85
  ),
62
86
  recommended: Type.Optional(
63
- Type.Boolean({
64
- description: "Mark as ⭐ recommended answer.",
87
+ Type.Boolean({ description: "Mark ⭐ recommended (at most one)." }),
88
+ ),
89
+ preview: Type.Optional(
90
+ Type.String({
91
+ description:
92
+ "Snippet shown in a side panel when focused (code shape, signature, sample). Fenced ```code is highlighted.",
65
93
  }),
66
94
  ),
67
95
  }),
68
- { description: "2-4 concrete options." },
96
+ { description: "2-4 options. Empty ([]) when freeText is true." },
69
97
  ),
70
98
  multiSelect: Type.Optional(
99
+ Type.Boolean({ description: "Allow multiple picks (default single-select)." }),
100
+ ),
101
+ allowOther: Type.Optional(
102
+ Type.Boolean({ description: "Add a free-text 'Other…' choice." }),
103
+ ),
104
+ minSelect: Type.Optional(
105
+ Type.Number({ description: "Multi-select: min picks (default 1)." }),
106
+ ),
107
+ maxSelect: Type.Optional(
108
+ Type.Number({ description: "Multi-select: max picks (default: no limit)." }),
109
+ ),
110
+ freeText: Type.Optional(
71
111
  Type.Boolean({
72
- description:
73
- "If true, user can pick multiple (checkboxes, Enter toggles). If false (default), single-select with auto-advance.",
112
+ description: "No options — user types an answer (optional, blank allowed).",
74
113
  }),
75
114
  ),
76
115
  }),
77
- {
78
- description:
79
- "Questions to ask, in tab order. Max ~5 recommended (more hurts UX).",
80
- },
116
+ { description: "Questions in tab order (≤5 recommended)." },
81
117
  ),
82
118
  }),
83
119
  async execute(_id, params, _signal, _onUpdate, ctx) {
@@ -117,12 +153,27 @@ export default function piAskExtension(pi: ExtensionAPI) {
117
153
  for (let i = 0; i < params.questions.length; i++) {
118
154
  const q = params.questions[i];
119
155
  if (!q) continue;
156
+ // Free-text questions carry no options — skip the option checks.
157
+ if (q.freeText) {
158
+ if (q.multiSelect) {
159
+ return {
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: `ask_pro: Q${i + 1} ("${q.header}") can't be both freeText and multiSelect.`,
164
+ },
165
+ ],
166
+ details: { error: "freetext_multiselect", questionIdx: i },
167
+ };
168
+ }
169
+ continue;
170
+ }
120
171
  if (q.options.length < 2 || q.options.length > 4) {
121
172
  return {
122
173
  content: [
123
174
  {
124
175
  type: "text",
125
- text: `ask_pro: Q${i + 1} ("${q.header}") has ${q.options.length} options, need 2-4.`,
176
+ text: `ask_pro: Q${i + 1} ("${q.header}") has ${q.options.length} options, need 2-4 (or set freeText:true).`,
126
177
  },
127
178
  ],
128
179
  details: {
@@ -145,6 +196,9 @@ export default function piAskExtension(pi: ExtensionAPI) {
145
196
  details: { error: "multiple_recommended", questionIdx: i },
146
197
  };
147
198
  }
199
+ // Validate multi-select min/max bounds when provided.
200
+ const bounds = validateSelectBounds(q, i);
201
+ if (bounds) return bounds;
148
202
  }
149
203
 
150
204
  // --- show the picker ---
@@ -159,7 +213,8 @@ export default function piAskExtension(pi: ExtensionAPI) {
159
213
  theme: askTheme,
160
214
  keybindings,
161
215
  done,
162
- title: `pi-ask ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
216
+ title: `soly · ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
217
+ highlight: highlightCode,
163
218
  });
164
219
  },
165
220
  );
@@ -179,6 +234,7 @@ export default function piAskExtension(pi: ExtensionAPI) {
179
234
 
180
235
  const answers = result.answers ?? {};
181
236
  const notes = result.notes ?? {};
237
+ const skipped = new Set(result.skipped ?? []);
182
238
  // Pretty-print for the LLM
183
239
  const out: string[] = ["User answers:"];
184
240
  for (let i = 0; i < params.questions.length; i++) {
@@ -186,7 +242,9 @@ export default function piAskExtension(pi: ExtensionAPI) {
186
242
  if (!q) continue;
187
243
  const a = answers[i];
188
244
  let line: string;
189
- if (a === undefined) {
245
+ if (skipped.has(i)) {
246
+ line = ` Q${i + 1} (${q.header}): (skipped)`;
247
+ } else if (a === undefined) {
190
248
  line = ` Q${i + 1} (${q.header}): (no answer)`;
191
249
  } else if (Array.isArray(a)) {
192
250
  const parts: string[] = [];
@@ -200,6 +258,8 @@ export default function piAskExtension(pi: ExtensionAPI) {
200
258
  line = ` Q${i + 1} (${q.header}) [multi]: ${parts.join(", ")}`;
201
259
  } else if (typeof a === "number") {
202
260
  line = ` Q${i + 1} (${q.header}): ${q.options[a]?.label ?? `?${a}`}`;
261
+ } else if (q.freeText) {
262
+ line = ` Q${i + 1} (${q.header}): "${a}"`;
203
263
  } else {
204
264
  line = ` Q${i + 1} (${q.header}) [Other]: "${a}"`;
205
265
  }
@@ -212,7 +272,11 @@ export default function piAskExtension(pi: ExtensionAPI) {
212
272
 
213
273
  return {
214
274
  content: [{ type: "text", text: out.join("\n") }],
215
- details: { answers, notes: Object.keys(notes).length > 0 ? notes : undefined },
275
+ details: {
276
+ answers,
277
+ notes: Object.keys(notes).length > 0 ? notes : undefined,
278
+ skipped: skipped.size > 0 ? [...skipped] : undefined,
279
+ },
216
280
  };
217
281
  },
218
282
  });