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.
- package/README.md +60 -14
- package/artifact/index.ts +241 -0
- package/artifact/render.ts +239 -0
- package/artifact/server.ts +384 -0
- package/artifact/session.ts +368 -0
- package/ask/README.md +20 -8
- package/ask/index.ts +100 -36
- package/ask/picker.ts +345 -29
- package/commands.ts +246 -30
- package/config.ts +124 -8
- package/core.ts +48 -236
- package/deck/deck.ts +386 -0
- package/deck/index.ts +113 -0
- package/index.ts +153 -37
- package/iteration.ts +1 -9
- package/mcp/commands.ts +12 -0
- package/mcp/direct-tools.ts +19 -2
- package/mcp/index.ts +144 -2
- package/mcp/init.ts +11 -0
- package/mcp/lifecycle.ts +9 -2
- package/mcp/server-manager.ts +30 -18
- package/mcp/state.ts +7 -0
- package/mcp/tool-cache.ts +135 -0
- package/nudge.ts +20 -3
- package/package.json +10 -3
- package/skills/soly-framework/SKILL.md +64 -37
- package/tool-hints.ts +62 -0
- package/tools.ts +28 -100
- package/util.ts +250 -0
- package/visual/chrome.ts +166 -0
- package/visual/colors.ts +24 -0
- package/visual/data.ts +62 -0
- package/visual/footer.ts +129 -0
- package/visual/format.ts +73 -0
- package/visual/glyphs.ts +35 -0
- package/visual/gradient.ts +122 -0
- package/visual/index.ts +18 -0
- package/visual/list-panel.ts +207 -0
- package/visual/segments.ts +136 -0
- package/visual/style.ts +34 -0
- package/visual/topbar.ts +55 -0
- package/visual/welcome.ts +265 -0
- package/visual/working.ts +66 -0
- package/workflows/execute.ts +17 -7
- package/workflows/index.ts +91 -44
- package/workflows/inspect.ts +23 -26
- package/workflows/migrate.ts +85 -0
- package/workflows/parser.ts +4 -2
- package/workflows/planning.ts +9 -8
- package/workflows/verify.ts +194 -0
- package/workflows-data/discuss-phase.md +10 -6
- package/agents-install.ts +0 -106
- package/ask/prompt.ts +0 -41
- 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** —
|
|
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
|
|
107
|
-
bun test
|
|
108
|
-
bun run typecheck
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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: "
|
|
72
|
+
label: "soly · ask_pro",
|
|
41
73
|
description:
|
|
42
|
-
"Ask the user
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: `
|
|
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 (
|
|
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: {
|
|
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
|
});
|