@alexleekt/pi-ask-user-glimpse 0.2.1 → 0.3.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/tool/ask-user.ts CHANGED
@@ -1,201 +1,465 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { prompt } from "glimpseui";
3
1
  import { readFileSync } from "node:fs";
4
2
  import { createRequire } from "node:module";
5
3
  import { dirname, join } from "node:path";
6
4
  import { fileURLToPath } from "node:url";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { prompt } from "glimpseui";
7
7
  import { terminalPrompt } from "../fallback/terminal-prompt.js";
8
- import { formatResponse } from "./response-formatter.js";
9
8
  import type { AskUserPayload, Question } from "../shared/ask-user.js";
9
+ import { formatResponse } from "./response-formatter.js";
10
10
 
11
11
  const _require = createRequire(import.meta.url);
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
 
14
14
  /** ~100 common English stopwords for title extraction. */
15
15
  const STOPWORDS = new Set([
16
- "a","an","the","is","are","was","were","be","been","being",
17
- "have","has","had","do","does","did","will","would","could","should",
18
- "may","might","must","shall","can","need","ought","used",
19
- "to","of","in","for","on","with","at","by","from","as","into",
20
- "through","during","before","after","above","below","between","under",
21
- "again","further","then","once","here","there","when","where","why","how",
22
- "all","each","few","more","most","other","some","such","no","nor","not",
23
- "only","own","same","so","than","too","very","just","and","but","if","or",
24
- "because","until","while","which","what","who","whom","this","that",
25
- "these","those","am","it","its","we","our","you","your","they","their",
26
- "them","he","him","his","she","her","i","me","my","mine","us",
27
- "any","both","either","neither","one","two","first","last","another","every",
28
- "many","much","several",
29
- "let","new","use","using",
30
- "make","made","get","got","go","going","want","wanted","like","liked",
31
- "know","knew","known","think","thought","see","saw","seen","come","came",
32
- "give","gave","given","take","took","taken","find","found","say","said",
33
- "tell","told","ask","asked","work","worked","seem","seemed","feel","felt",
34
- "try","tried","leave","left","call","called","good","well","better","best",
35
- "bad","worse","worst","old","long","great","little","right","left","big",
36
- "high","different","important","same","able","next","early","young",
37
- "public","free","real","easy","clear","recent","local","social","full",
38
- "small","large","possible","particular","available","special","certain",
39
- "personal","open","general","enough","probably","actually","especially",
40
- "finally","usually","perhaps","almost","simply","quickly","recently",
41
- "already","eventually","suddenly","certainly","definitely","absolutely",
42
- "completely","totally","entirely","exactly","specifically","particularly",
43
- "especially","mainly","mostly","partly","fully","nearly","quite","rather",
44
- "pretty","fairly","really","even","still","yet","ever","never","always",
45
- "sometimes","often","usually","frequently","rarely","generally",
46
- "typically","normally","largely","potentially","theoretically",
47
- "practically","basically","essentially","fundamentally","primarily",
48
- "chiefly","principally","partially","half","quarter","double","single",
49
- "multiple","various","hundred","thousand","million","billion",
16
+ "a",
17
+ "an",
18
+ "the",
19
+ "is",
20
+ "are",
21
+ "was",
22
+ "were",
23
+ "be",
24
+ "been",
25
+ "being",
26
+ "have",
27
+ "has",
28
+ "had",
29
+ "do",
30
+ "does",
31
+ "did",
32
+ "will",
33
+ "would",
34
+ "could",
35
+ "should",
36
+ "may",
37
+ "might",
38
+ "must",
39
+ "shall",
40
+ "can",
41
+ "need",
42
+ "ought",
43
+ "used",
44
+ "to",
45
+ "of",
46
+ "in",
47
+ "for",
48
+ "on",
49
+ "with",
50
+ "at",
51
+ "by",
52
+ "from",
53
+ "as",
54
+ "into",
55
+ "through",
56
+ "during",
57
+ "before",
58
+ "after",
59
+ "above",
60
+ "below",
61
+ "between",
62
+ "under",
63
+ "again",
64
+ "further",
65
+ "then",
66
+ "once",
67
+ "here",
68
+ "there",
69
+ "when",
70
+ "where",
71
+ "why",
72
+ "how",
73
+ "all",
74
+ "each",
75
+ "few",
76
+ "more",
77
+ "most",
78
+ "other",
79
+ "some",
80
+ "such",
81
+ "no",
82
+ "nor",
83
+ "not",
84
+ "only",
85
+ "own",
86
+ "same",
87
+ "so",
88
+ "than",
89
+ "too",
90
+ "very",
91
+ "just",
92
+ "and",
93
+ "but",
94
+ "if",
95
+ "or",
96
+ "because",
97
+ "until",
98
+ "while",
99
+ "which",
100
+ "what",
101
+ "who",
102
+ "whom",
103
+ "this",
104
+ "that",
105
+ "these",
106
+ "those",
107
+ "am",
108
+ "it",
109
+ "its",
110
+ "we",
111
+ "our",
112
+ "you",
113
+ "your",
114
+ "they",
115
+ "their",
116
+ "them",
117
+ "he",
118
+ "him",
119
+ "his",
120
+ "she",
121
+ "her",
122
+ "i",
123
+ "me",
124
+ "my",
125
+ "mine",
126
+ "us",
127
+ "any",
128
+ "both",
129
+ "either",
130
+ "neither",
131
+ "one",
132
+ "two",
133
+ "first",
134
+ "last",
135
+ "another",
136
+ "every",
137
+ "many",
138
+ "much",
139
+ "several",
140
+ "let",
141
+ "new",
142
+ "use",
143
+ "using",
144
+ "make",
145
+ "made",
146
+ "get",
147
+ "got",
148
+ "go",
149
+ "going",
150
+ "want",
151
+ "wanted",
152
+ "like",
153
+ "liked",
154
+ "know",
155
+ "knew",
156
+ "known",
157
+ "think",
158
+ "thought",
159
+ "see",
160
+ "saw",
161
+ "seen",
162
+ "come",
163
+ "came",
164
+ "give",
165
+ "gave",
166
+ "given",
167
+ "take",
168
+ "took",
169
+ "taken",
170
+ "find",
171
+ "found",
172
+ "say",
173
+ "said",
174
+ "tell",
175
+ "told",
176
+ "ask",
177
+ "asked",
178
+ "work",
179
+ "worked",
180
+ "seem",
181
+ "seemed",
182
+ "feel",
183
+ "felt",
184
+ "try",
185
+ "tried",
186
+ "leave",
187
+ "left",
188
+ "call",
189
+ "called",
190
+ "good",
191
+ "well",
192
+ "better",
193
+ "best",
194
+ "bad",
195
+ "worse",
196
+ "worst",
197
+ "old",
198
+ "long",
199
+ "great",
200
+ "little",
201
+ "right",
202
+ "left",
203
+ "big",
204
+ "high",
205
+ "different",
206
+ "important",
207
+ "same",
208
+ "able",
209
+ "next",
210
+ "early",
211
+ "young",
212
+ "public",
213
+ "free",
214
+ "real",
215
+ "easy",
216
+ "clear",
217
+ "recent",
218
+ "local",
219
+ "social",
220
+ "full",
221
+ "small",
222
+ "large",
223
+ "possible",
224
+ "particular",
225
+ "available",
226
+ "special",
227
+ "certain",
228
+ "personal",
229
+ "open",
230
+ "general",
231
+ "enough",
232
+ "probably",
233
+ "actually",
234
+ "especially",
235
+ "finally",
236
+ "usually",
237
+ "perhaps",
238
+ "almost",
239
+ "simply",
240
+ "quickly",
241
+ "recently",
242
+ "already",
243
+ "eventually",
244
+ "suddenly",
245
+ "certainly",
246
+ "definitely",
247
+ "absolutely",
248
+ "completely",
249
+ "totally",
250
+ "entirely",
251
+ "exactly",
252
+ "specifically",
253
+ "particularly",
254
+ "especially",
255
+ "mainly",
256
+ "mostly",
257
+ "partly",
258
+ "fully",
259
+ "nearly",
260
+ "quite",
261
+ "rather",
262
+ "pretty",
263
+ "fairly",
264
+ "really",
265
+ "even",
266
+ "still",
267
+ "yet",
268
+ "ever",
269
+ "never",
270
+ "always",
271
+ "sometimes",
272
+ "often",
273
+ "usually",
274
+ "frequently",
275
+ "rarely",
276
+ "generally",
277
+ "typically",
278
+ "normally",
279
+ "largely",
280
+ "potentially",
281
+ "theoretically",
282
+ "practically",
283
+ "basically",
284
+ "essentially",
285
+ "fundamentally",
286
+ "primarily",
287
+ "chiefly",
288
+ "principally",
289
+ "partially",
290
+ "half",
291
+ "quarter",
292
+ "double",
293
+ "single",
294
+ "multiple",
295
+ "various",
296
+ "hundred",
297
+ "thousand",
298
+ "million",
299
+ "billion",
50
300
  ]);
51
301
 
52
302
  /** Extract a short title from a question by removing stopwords.
53
303
  * Falls back to first 5 words if nothing meaningful remains.
54
304
  */
55
305
  function summarizeTitle(question: string, maxWords = 3): string {
56
- const contentWords = question
57
- .toLowerCase()
58
- .replace(/[^\w\s]/g, "")
59
- .split(/\s+/)
60
- .filter((w) => w.length > 0 && !STOPWORDS.has(w));
61
-
62
- if (contentWords.length === 0) {
63
- // Nothing but stopwords — fall back to raw truncation
64
- const words = question.trim().split(/\s+/);
65
- return words.slice(0, 5).join(" ") + (words.length > 5 ? "…" : "");
66
- }
67
-
68
- const result = contentWords
69
- .slice(0, maxWords)
70
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
71
- .join(" ");
72
-
73
- return contentWords.length > maxWords ? result + "…" : result;
306
+ const contentWords = question
307
+ .toLowerCase()
308
+ .replace(/[^\w\s]/g, "")
309
+ .split(/\s+/)
310
+ .filter((w) => w.length > 0 && !STOPWORDS.has(w));
311
+
312
+ if (contentWords.length === 0) {
313
+ // Nothing but stopwords — fall back to raw truncation
314
+ const words = question.trim().split(/\s+/);
315
+ return words.slice(0, 5).join(" ") + (words.length > 5 ? "…" : "");
316
+ }
317
+
318
+ const result = contentWords
319
+ .slice(0, maxWords)
320
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
321
+ .join(" ");
322
+
323
+ return contentWords.length > maxWords ? `${result}…` : result;
74
324
  }
75
325
 
76
326
  function resolveWebviewHtml(): string {
77
- const distPath = join(__dirname, "..", "dist", "index.html");
78
- try {
79
- return readFileSync(distPath, "utf-8");
80
- } catch {
81
- // Fallback for development: resolve from package root
82
- const pkgRoot = dirname(_require.resolve("../package.json"));
83
- const fallbackPath = join(pkgRoot, "dist", "index.html");
84
- try {
85
- return readFileSync(fallbackPath, "utf-8");
86
- } catch (err) {
87
- throw new Error(
88
- `Could not find webview bundle. Tried:\n` +
89
- ` 1. ${distPath}\n` +
90
- ` 2. ${fallbackPath}\n` +
91
- `Run 'npm run build' first to generate dist/index.html.`,
92
- { cause: err },
93
- );
94
- }
95
- }
327
+ const distPath = join(__dirname, "..", "dist", "index.html");
328
+ try {
329
+ return readFileSync(distPath, "utf-8");
330
+ } catch {
331
+ // Fallback for development: resolve from package root
332
+ const pkgRoot = dirname(_require.resolve("../package.json"));
333
+ const fallbackPath = join(pkgRoot, "dist", "index.html");
334
+ try {
335
+ return readFileSync(fallbackPath, "utf-8");
336
+ } catch (err) {
337
+ throw new Error(
338
+ `Could not find webview bundle. Tried:\n` +
339
+ ` 1. ${distPath}\n` +
340
+ ` 2. ${fallbackPath}\n` +
341
+ `Run 'npm run build' first to generate dist/index.html.`,
342
+ { cause: err },
343
+ );
344
+ }
345
+ }
96
346
  }
97
347
 
98
348
  export interface AskUserParams {
99
- question: string;
100
- context?: string;
101
- options?: (string | { title: string; description?: string })[];
102
- questions?: Question[];
103
- allowMultiple?: boolean;
104
- allowFreeform?: boolean;
105
- allowComment?: boolean;
106
- allowSkip?: boolean;
107
- displayMode?: string;
108
- followCursor?: boolean;
349
+ question: string;
350
+ context?: string;
351
+ options?: (string | { title: string; description?: string })[];
352
+ questions?: Question[];
353
+ allowMultiple?: boolean;
354
+ allowFreeform?: boolean;
355
+ allowComment?: boolean;
356
+ allowSkip?: boolean;
357
+ displayMode?: string;
358
+ followCursor?: boolean;
109
359
  }
110
360
 
111
361
  export async function askUserHandler(
112
- params: AskUserParams,
113
- signal: AbortSignal | undefined,
114
- ctx: ExtensionContext,
362
+ params: AskUserParams,
363
+ signal: AbortSignal | undefined,
364
+ ctx: ExtensionContext,
115
365
  ) {
116
- if (signal?.aborted) {
117
- return {
118
- content: [{ type: "text" as const, text: "Cancelled" }],
119
- details: { question: params.question, options: [], response: null, cancelled: true },
120
- };
121
- }
122
-
123
- const normalizedOptions = (params.options ?? []).map((opt) => {
124
- if (typeof opt === "string") return { title: opt };
125
- return { title: opt.title, description: opt.description };
126
- });
127
-
128
- const hasOptions = normalizedOptions.length > 0;
129
- const hasQuestions = params.questions && params.questions.length > 0;
130
- const allowMultiple = params.allowMultiple ?? false;
131
- const allowFreeform = params.allowFreeform ?? true;
132
- const allowComment = params.allowComment ?? false;
133
-
134
- let payloadType: AskUserPayload["type"];
135
- if (hasQuestions) {
136
- payloadType = "questionnaire";
137
- } else if (!hasOptions) {
138
- payloadType = "freeform";
139
- } else if (allowMultiple) {
140
- payloadType = "multi-select";
141
- } else {
142
- payloadType = "single-select";
143
- }
144
-
145
- const payload: AskUserPayload = {
146
- type: payloadType,
147
- question: params.question,
148
- context: params.context,
149
- options: normalizedOptions,
150
- questions: params.questions,
151
- allowMultiple,
152
- allowFreeform,
153
- allowComment,
154
- allowSkip: params.allowSkip,
155
- };
156
-
157
- let result: Record<string, unknown> | null = null;
158
- let cancelled = false;
159
- let error: string | undefined;
160
-
161
- try {
162
- const baseHtml = resolveWebviewHtml();
163
- const html = baseHtml.replace(
164
- "/*ASK_USER_PAYLOAD*/",
165
- JSON.stringify(payload)
166
- .replace(/</g, "\\u003c")
167
- .replace(/>/g, "\\u003e")
168
- .replace(/&/g, "\\u0026"),
169
- );
170
-
171
- const options: Record<string, unknown> = {
172
- width: 1200,
173
- height: 900,
174
- title: summarizeTitle(params.question),
175
- };
176
-
177
- if (params.followCursor) {
178
- options.followCursor = true;
179
- }
180
-
181
- result = (await prompt(html, options)) as Record<string, unknown> | null;
182
- if (result === null || result?.__cancelled === true) {
183
- cancelled = true;
184
- result = null;
185
- }
186
- } catch (err) {
187
- // Glimpse unavailable — fall back to terminal prompt
188
- const fallbackResult = await terminalPrompt(
189
- payload,
190
- ctx.hasUI ? ctx.ui : undefined,
191
- );
192
- if (fallbackResult === null) {
193
- cancelled = true;
194
- } else {
195
- result = fallbackResult;
196
- }
197
- error = err instanceof Error ? err.message : String(err);
198
- }
199
-
200
- return formatResponse(params.question, normalizedOptions, result, cancelled, error);
366
+ if (signal?.aborted) {
367
+ return {
368
+ content: [{ type: "text" as const, text: "Cancelled" }],
369
+ details: {
370
+ question: params.question,
371
+ options: [],
372
+ response: null,
373
+ cancelled: true,
374
+ },
375
+ };
376
+ }
377
+
378
+ const normalizedOptions = (params.options ?? []).map((opt) => {
379
+ if (typeof opt === "string") return { title: opt };
380
+ return { title: opt.title, description: opt.description };
381
+ });
382
+
383
+ const hasOptions = normalizedOptions.length > 0;
384
+ const hasQuestions = params.questions && params.questions.length > 0;
385
+ const allowMultiple = params.allowMultiple ?? false;
386
+ const allowFreeform = params.allowFreeform ?? true;
387
+ const allowComment = params.allowComment ?? false;
388
+
389
+ let payloadType: AskUserPayload["type"];
390
+ if (hasQuestions) {
391
+ payloadType = "questionnaire";
392
+ } else if (!hasOptions) {
393
+ payloadType = "freeform";
394
+ } else if (allowMultiple) {
395
+ payloadType = "multi-select";
396
+ } else {
397
+ payloadType = "single-select";
398
+ }
399
+
400
+ const payload: AskUserPayload = {
401
+ type: payloadType,
402
+ question: params.question,
403
+ context: params.context,
404
+ options: normalizedOptions,
405
+ questions: params.questions,
406
+ allowMultiple,
407
+ allowFreeform,
408
+ allowComment,
409
+ allowSkip: params.allowSkip,
410
+ };
411
+
412
+ let result: Record<string, unknown> | null = null;
413
+ let cancelled = false;
414
+ let error: string | undefined;
415
+
416
+ try {
417
+ const baseHtml = resolveWebviewHtml();
418
+ const html = baseHtml.replace(
419
+ "/*ASK_USER_PAYLOAD*/",
420
+ JSON.stringify(payload)
421
+ .replace(/</g, "\\u003c")
422
+ .replace(/>/g, "\\u003e")
423
+ .replace(/&/g, "\\u0026"),
424
+ );
425
+
426
+ const options: Record<string, unknown> = {
427
+ width: 1200,
428
+ height: 900,
429
+ title: summarizeTitle(params.question),
430
+ };
431
+
432
+ if (params.followCursor) {
433
+ options.followCursor = true;
434
+ }
435
+
436
+ result = (await prompt(html, options)) as Record<
437
+ string,
438
+ unknown
439
+ > | null;
440
+ if (result === null || result?.__cancelled === true) {
441
+ cancelled = true;
442
+ result = null;
443
+ }
444
+ } catch (err) {
445
+ // Glimpse unavailable — fall back to terminal prompt
446
+ const fallbackResult = await terminalPrompt(
447
+ payload,
448
+ ctx.hasUI ? ctx.ui : undefined,
449
+ );
450
+ if (fallbackResult === null) {
451
+ cancelled = true;
452
+ } else {
453
+ result = fallbackResult;
454
+ }
455
+ error = err instanceof Error ? err.message : String(err);
456
+ }
457
+
458
+ return formatResponse(
459
+ params.question,
460
+ normalizedOptions,
461
+ result,
462
+ cancelled,
463
+ error,
464
+ );
201
465
  }