@alexleekt/pi-ask-user-glimpse 0.3.2 → 0.5.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/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@alexleekt/pi-ask-user-glimpse",
3
- "version": "0.3.2",
4
- "description": "Pi extension that replaces ask_user with rich native WebView dialogs via glimpseui and shadcn/ui",
3
+ "version": "0.5.0",
4
+ "description": "Ask better questions. Get better answers. Rich native WebView dialogs for the Pi agent.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
8
8
  "pi-extension",
9
+ "pi",
9
10
  "glimpseui",
10
11
  "ask_user",
11
12
  "interactive"
@@ -30,8 +31,8 @@
30
31
  },
31
32
  "files": [
32
33
  "index.ts",
34
+ "constants",
33
35
  "tool",
34
- "fallback",
35
36
  "shared",
36
37
  "types",
37
38
  "dist",
@@ -41,25 +42,73 @@
41
42
  "LICENSE"
42
43
  ],
43
44
  "scripts": {
44
- "build": "npm run build:css && npm run build:webview",
45
- "build:css": "npx tailwindcss -c ./webview/tailwind.config.js -i ./webview/src/index.css -o ./webview/src/index.generated.css --content './webview/index.html,./webview/src/**/*.{js,ts,jsx,tsx}'",
46
- "build:webview": "npx vite build --config ./webview/vite.config.ts",
45
+ "build": "wireit",
46
+ "build:css": "wireit",
47
+ "build:webview": "wireit",
47
48
  "typecheck": "tsc --noEmit",
48
49
  "check": "npm pack --dry-run",
49
50
  "prepack": "npm run build",
50
51
  "dev:webview": "npx vite --config ./webview/vite.config.ts",
51
- "validate": "npx tsx scripts/validate.ts",
52
- "validate:gui": "npx tsx scripts/validate.ts --gui"
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:coverage": "vitest run --coverage",
55
+ "test:e2e": "playwright test",
56
+ "test:e2e:ui": "playwright test --ui",
57
+ "validate": "test -f dist/index.html && grep -q 'ASK_USER_PAYLOAD' dist/index.html && echo \"✓ dist/index.html ready\" || echo \"✗ Run npm run build first\"",
58
+ "validate:gui": "echo \"validate:gui requires manual testing with pi extension load\""
59
+ },
60
+ "wireit": {
61
+ "build": {
62
+ "dependencies": [
63
+ "build:css",
64
+ "build:webview"
65
+ ]
66
+ },
67
+ "build:css": {
68
+ "command": "npx tailwindcss -c ./webview/tailwind.config.js -i ./webview/src/index.css -o ./webview/src/index.generated.css --content './webview/index.html,./webview/src/**/*.{js,ts,jsx,tsx}'",
69
+ "files": [
70
+ "./webview/src/**/*.css",
71
+ "./webview/src/**/*.{ts,tsx,js,jsx}",
72
+ "./webview/tailwind.config.js",
73
+ "./webview/postcss.config.js",
74
+ "./webview/index.html"
75
+ ],
76
+ "output": [
77
+ "./webview/src/index.generated.css"
78
+ ]
79
+ },
80
+ "build:webview": {
81
+ "command": "npx vite build --config ./webview/vite.config.ts",
82
+ "files": [
83
+ "./webview/src/**/*.{ts,tsx}",
84
+ "./webview/index.html",
85
+ "./webview/vite.config.ts",
86
+ "./webview/tsconfig.json",
87
+ "./webview/postcss.config.js",
88
+ "./webview/src/index.generated.css"
89
+ ],
90
+ "output": [
91
+ "./dist/index.html"
92
+ ],
93
+ "dependencies": [
94
+ "build:css"
95
+ ]
96
+ }
53
97
  },
54
98
  "dependencies": {
55
99
  "@alexleekt/pi-shared": "^0.1.0",
56
100
  "glimpseui": "^0.8.1"
57
101
  },
58
102
  "devDependencies": {
103
+ "@playwright/test": "^1.60.0",
104
+ "@testing-library/jest-dom": "^6.9.1",
105
+ "@testing-library/react": "^16.3.2",
59
106
  "@types/react": "^18.3.0",
60
107
  "@types/react-dom": "^18.3.0",
61
108
  "@vitejs/plugin-react": "^4.3.0",
109
+ "@vitest/coverage-v8": "^4.1.7",
62
110
  "autoprefixer": "^10.4.20",
111
+ "jsdom": "^29.1.1",
63
112
  "marked": "^15.0.12",
64
113
  "mermaid": "^11.15.0",
65
114
  "playwright": "^1.60.0",
@@ -69,7 +118,9 @@
69
118
  "tailwindcss": "^3.4.14",
70
119
  "typescript": "^5.6.0",
71
120
  "vite": "^5.4.0",
72
- "vite-plugin-singlefile": "^2.0.0"
121
+ "vite-plugin-singlefile": "^2.0.0",
122
+ "vitest": "^4.1.7",
123
+ "wireit": "^0.14.12"
73
124
  },
74
125
  "peerDependencies": {
75
126
  "@earendil-works/pi-ai": "*",
@@ -9,6 +9,7 @@
9
9
  export interface QuestionOption {
10
10
  title: string;
11
11
  description?: string;
12
+ recommended?: boolean;
12
13
  }
13
14
 
14
15
  export interface Question {
@@ -18,16 +19,23 @@ export interface Question {
18
19
  allowMultiple?: boolean;
19
20
  }
20
21
 
22
+ export type ThemeMode = "light" | "dark" | "system";
23
+ export type AnimationLevel = "none" | "minimal" | "all";
24
+
21
25
  export interface AskUserPayload {
22
26
  type: "single-select" | "multi-select" | "questionnaire" | "freeform";
23
27
  question: string;
24
28
  context?: string;
29
+ contextFormat?: "markdown" | "html";
25
30
  options: QuestionOption[];
26
31
  questions?: Question[];
27
32
  allowMultiple: boolean;
28
33
  allowFreeform: boolean;
29
34
  allowComment: boolean;
30
35
  allowSkip?: boolean;
36
+ sessionName?: string;
37
+ theme?: ThemeMode;
38
+ animationLevel?: AnimationLevel;
31
39
  }
32
40
 
33
41
  export interface QuestionnaireDetail {
@@ -36,3 +44,6 @@ export interface QuestionnaireDetail {
36
44
  kind: "selection" | "freeform";
37
45
  comment?: string;
38
46
  }
47
+
48
+ /** Sentinel title used for the freeform "not listed" option in select dialogs. */
49
+ export const FREEFORM_OPTION_TITLE = "My answer isn't listed above";
package/tool/ask-user.ts CHANGED
@@ -4,300 +4,21 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { prompt } from "glimpseui";
7
- import { terminalPrompt } from "../fallback/terminal-prompt.js";
8
- import type { AskUserPayload, Question } from "../shared/ask-user.js";
7
+ import type {
8
+ AnimationLevel,
9
+ AskUserPayload,
10
+ Question,
11
+ ThemeMode,
12
+ } from "../shared/ask-user.js";
9
13
  import { formatResponse } from "./response-formatter.js";
10
14
 
11
15
  const _require = createRequire(import.meta.url);
12
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
17
 
14
- /** ~100 common English stopwords for title extraction. */
15
- const STOPWORDS = new Set([
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",
300
- ]);
18
+ import { STOPWORDS } from "../constants/stopwords.js";
19
+
20
+ /** Warn once per process when Glimpse is unavailable. */
21
+ let _warnedGlimpseUnavailable = false;
301
22
 
302
23
  /** Extract a short title from a question by removing stopwords.
303
24
  * Falls back to first 5 words if nothing meaningful remains.
@@ -325,12 +46,17 @@ function summarizeTitle(question: string, maxWords = 3): string {
325
46
 
326
47
  function resolveWebviewHtml(): string {
327
48
  const distPath = join(__dirname, "..", "dist", "index.html");
49
+ console.log(`[pi-ask-user-glimpse] Loading webview from: ${distPath}`);
328
50
  try {
329
- return readFileSync(distPath, "utf-8");
51
+ const html = readFileSync(distPath, "utf-8");
52
+ const hasNewCode = html.includes('items-center justify-between');
53
+ console.log(`[pi-ask-user-glimpse] Webview has new code: ${hasNewCode}`);
54
+ return html;
330
55
  } catch {
331
56
  // Fallback for development: resolve from package root
332
57
  const pkgRoot = dirname(_require.resolve("../package.json"));
333
58
  const fallbackPath = join(pkgRoot, "dist", "index.html");
59
+ console.log(`[pi-ask-user-glimpse] Fallback: ${fallbackPath}`);
334
60
  try {
335
61
  return readFileSync(fallbackPath, "utf-8");
336
62
  } catch (err) {
@@ -348,7 +74,11 @@ function resolveWebviewHtml(): string {
348
74
  export interface AskUserParams {
349
75
  question: string;
350
76
  context?: string;
351
- options?: (string | { title: string; description?: string })[];
77
+ contextFormat?: "markdown" | "html";
78
+ options?: (
79
+ | string
80
+ | { title: string; description?: string; recommended?: boolean }
81
+ )[];
352
82
  questions?: Question[];
353
83
  allowMultiple?: boolean;
354
84
  allowFreeform?: boolean;
@@ -356,12 +86,20 @@ export interface AskUserParams {
356
86
  allowSkip?: boolean;
357
87
  displayMode?: string;
358
88
  followCursor?: boolean;
89
+ theme?: ThemeMode;
90
+ animationLevel?: AnimationLevel;
91
+ }
92
+
93
+ export interface AskUserMetadata {
94
+ theme?: string;
95
+ animationLevel?: string;
359
96
  }
360
97
 
361
98
  export async function askUserHandler(
362
99
  params: AskUserParams,
363
100
  signal: AbortSignal | undefined,
364
101
  ctx: ExtensionContext,
102
+ onMetadata?: (metadata: AskUserMetadata) => void,
365
103
  ) {
366
104
  if (signal?.aborted) {
367
105
  return {
@@ -377,7 +115,11 @@ export async function askUserHandler(
377
115
 
378
116
  const normalizedOptions = (params.options ?? []).map((opt) => {
379
117
  if (typeof opt === "string") return { title: opt };
380
- return { title: opt.title, description: opt.description };
118
+ return {
119
+ title: opt.title,
120
+ description: opt.description,
121
+ recommended: opt.recommended,
122
+ };
381
123
  });
382
124
 
383
125
  const hasOptions = normalizedOptions.length > 0;
@@ -413,12 +155,16 @@ export async function askUserHandler(
413
155
  type: payloadType,
414
156
  question,
415
157
  context,
158
+ contextFormat: params.contextFormat,
416
159
  options: normalizedOptions,
417
160
  questions: params.questions,
418
161
  allowMultiple,
419
162
  allowFreeform,
420
163
  allowComment,
421
164
  allowSkip: params.allowSkip,
165
+ sessionName: ctx.sessionManager.getSessionName(),
166
+ theme: params.theme,
167
+ animationLevel: params.animationLevel,
422
168
  };
423
169
 
424
170
  let result: Record<string, unknown> | null = null;
@@ -435,36 +181,60 @@ export async function askUserHandler(
435
181
  .replace(/&/g, "\\u0026"),
436
182
  );
437
183
 
438
- const options: Record<string, unknown> = {
184
+ const sessionName = ctx.sessionManager.getSessionName();
185
+ const questionTitle = summarizeTitle(params.question);
186
+ const title = sessionName
187
+ ? `Pi · ${sessionName} · ${questionTitle}`
188
+ : `Pi · ${questionTitle}`;
189
+
190
+ const windowOptions: Record<string, unknown> = {
439
191
  width: 1200,
440
192
  height: 900,
441
- title: summarizeTitle(params.question),
193
+ title: title.length > 60 ? `${title.slice(0, 57)}…` : title,
442
194
  };
443
195
 
444
196
  if (params.followCursor) {
445
- options.followCursor = true;
197
+ windowOptions.followCursor = true;
446
198
  }
447
199
 
448
- result = (await prompt(html, options)) as Record<
200
+ result = (await prompt(html, windowOptions)) as Record<
449
201
  string,
450
202
  unknown
451
203
  > | null;
452
204
  if (result === null || result?.__cancelled === true) {
453
205
  cancelled = true;
454
206
  result = null;
207
+ } else if (result && onMetadata) {
208
+ onMetadata({
209
+ theme: result.__theme as string | undefined,
210
+ animationLevel: result.__animationLevel as string | undefined,
211
+ });
455
212
  }
456
213
  } catch (err) {
457
- // Glimpse unavailable — fall back to terminal prompt
458
- const fallbackResult = await terminalPrompt(
459
- payload,
460
- ctx.hasUI ? ctx.ui : undefined,
461
- );
462
- if (fallbackResult === null) {
463
- cancelled = true;
464
- } else {
465
- result = fallbackResult;
214
+ // Glimpse unavailable — fast-exit and warn once
215
+ if (!_warnedGlimpseUnavailable) {
216
+ _warnedGlimpseUnavailable = true;
217
+ console.warn(
218
+ "[pi-ask-user-glimpse] Glimpse unavailable — " +
219
+ "ask_user will return errors. " +
220
+ "Install glimpseui or run in a UI-enabled environment.",
221
+ );
466
222
  }
467
- error = err instanceof Error ? err.message : String(err);
223
+ return {
224
+ content: [
225
+ {
226
+ type: "text" as const,
227
+ text: "No UI available for ask_user dialog. Please ask the user directly in free-form text.",
228
+ },
229
+ ],
230
+ details: {
231
+ question: params.question,
232
+ options: normalizedOptions.map((o) => o.title),
233
+ response: null,
234
+ cancelled: true,
235
+ error: "No UI available",
236
+ },
237
+ };
468
238
  }
469
239
 
470
240
  return formatResponse(
@@ -60,6 +60,7 @@ function buildResponse(
60
60
  };
61
61
  })
62
62
  : [],
63
+ additionalComments: pickString(result.additionalComments),
63
64
  };
64
65
  }
65
66
 
@@ -79,14 +80,14 @@ function buildResponse(
79
80
 
80
81
  function responseToText(response: AskResponse): string {
81
82
  if (response.kind === "freeform") {
82
- return response.text ?? "";
83
+ return response.text?.trim() || "No response";
83
84
  }
84
85
  const lines: string[] = [];
85
86
  const selections = response.selections ?? [];
86
87
  if (selections.length > 0) lines.push(selections.join(", "));
87
88
  if (response.comment) lines.push(`Comment: ${response.comment}`);
88
89
  if (response.additionalComments) lines.push(`Additional Comments: ${response.additionalComments}`);
89
- return lines.join("\n\n");
90
+ return lines.join("\n\n") || "No response";
90
91
  }
91
92
 
92
93
  export function formatResponse(