@alexleekt/pi-ask-user-glimpse 0.4.1 → 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.4.1",
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"
@@ -32,7 +33,6 @@
32
33
  "index.ts",
33
34
  "constants",
34
35
  "tool",
35
- "fallback",
36
36
  "shared",
37
37
  "types",
38
38
  "dist",
@@ -42,26 +42,73 @@
42
42
  "LICENSE"
43
43
  ],
44
44
  "scripts": {
45
- "build": "npm run build:css && npm run build:webview",
46
- "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}'",
47
- "build:webview": "npx vite build --config ./webview/vite.config.ts",
45
+ "build": "wireit",
46
+ "build:css": "wireit",
47
+ "build:webview": "wireit",
48
48
  "typecheck": "tsc --noEmit",
49
49
  "check": "npm pack --dry-run",
50
50
  "prepack": "npm run build",
51
51
  "dev:webview": "npx vite --config ./webview/vite.config.ts",
52
- "validate": "npx tsx scripts/validate.ts",
53
- "validate:gui": "npx tsx scripts/validate.ts --gui",
54
- "test:with-context": "npx tsx scripts/test-with-context.ts"
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
+ }
55
97
  },
56
98
  "dependencies": {
57
99
  "@alexleekt/pi-shared": "^0.1.0",
58
100
  "glimpseui": "^0.8.1"
59
101
  },
60
102
  "devDependencies": {
103
+ "@playwright/test": "^1.60.0",
104
+ "@testing-library/jest-dom": "^6.9.1",
105
+ "@testing-library/react": "^16.3.2",
61
106
  "@types/react": "^18.3.0",
62
107
  "@types/react-dom": "^18.3.0",
63
108
  "@vitejs/plugin-react": "^4.3.0",
109
+ "@vitest/coverage-v8": "^4.1.7",
64
110
  "autoprefixer": "^10.4.20",
111
+ "jsdom": "^29.1.1",
65
112
  "marked": "^15.0.12",
66
113
  "mermaid": "^11.15.0",
67
114
  "playwright": "^1.60.0",
@@ -71,7 +118,9 @@
71
118
  "tailwindcss": "^3.4.14",
72
119
  "typescript": "^5.6.0",
73
120
  "vite": "^5.4.0",
74
- "vite-plugin-singlefile": "^2.0.0"
121
+ "vite-plugin-singlefile": "^2.0.0",
122
+ "vitest": "^4.1.7",
123
+ "wireit": "^0.14.12"
75
124
  },
76
125
  "peerDependencies": {
77
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 {
@@ -25,6 +26,7 @@ export interface AskUserPayload {
25
26
  type: "single-select" | "multi-select" | "questionnaire" | "freeform";
26
27
  question: string;
27
28
  context?: string;
29
+ contextFormat?: "markdown" | "html";
28
30
  options: QuestionOption[];
29
31
  questions?: Question[];
30
32
  allowMultiple: boolean;
@@ -42,3 +44,6 @@ export interface QuestionnaireDetail {
42
44
  kind: "selection" | "freeform";
43
45
  comment?: string;
44
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,8 +4,12 @@ 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 { AnimationLevel, AskUserPayload, Question, ThemeMode } 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);
@@ -13,6 +17,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
13
17
 
14
18
  import { STOPWORDS } from "../constants/stopwords.js";
15
19
 
20
+ /** Warn once per process when Glimpse is unavailable. */
21
+ let _warnedGlimpseUnavailable = false;
22
+
16
23
  /** Extract a short title from a question by removing stopwords.
17
24
  * Falls back to first 5 words if nothing meaningful remains.
18
25
  */
@@ -39,12 +46,17 @@ function summarizeTitle(question: string, maxWords = 3): string {
39
46
 
40
47
  function resolveWebviewHtml(): string {
41
48
  const distPath = join(__dirname, "..", "dist", "index.html");
49
+ console.log(`[pi-ask-user-glimpse] Loading webview from: ${distPath}`);
42
50
  try {
43
- 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;
44
55
  } catch {
45
56
  // Fallback for development: resolve from package root
46
57
  const pkgRoot = dirname(_require.resolve("../package.json"));
47
58
  const fallbackPath = join(pkgRoot, "dist", "index.html");
59
+ console.log(`[pi-ask-user-glimpse] Fallback: ${fallbackPath}`);
48
60
  try {
49
61
  return readFileSync(fallbackPath, "utf-8");
50
62
  } catch (err) {
@@ -62,7 +74,11 @@ function resolveWebviewHtml(): string {
62
74
  export interface AskUserParams {
63
75
  question: string;
64
76
  context?: string;
65
- options?: (string | { title: string; description?: string })[];
77
+ contextFormat?: "markdown" | "html";
78
+ options?: (
79
+ | string
80
+ | { title: string; description?: string; recommended?: boolean }
81
+ )[];
66
82
  questions?: Question[];
67
83
  allowMultiple?: boolean;
68
84
  allowFreeform?: boolean;
@@ -99,7 +115,11 @@ export async function askUserHandler(
99
115
 
100
116
  const normalizedOptions = (params.options ?? []).map((opt) => {
101
117
  if (typeof opt === "string") return { title: opt };
102
- return { title: opt.title, description: opt.description };
118
+ return {
119
+ title: opt.title,
120
+ description: opt.description,
121
+ recommended: opt.recommended,
122
+ };
103
123
  });
104
124
 
105
125
  const hasOptions = normalizedOptions.length > 0;
@@ -135,6 +155,7 @@ export async function askUserHandler(
135
155
  type: payloadType,
136
156
  question,
137
157
  context,
158
+ contextFormat: params.contextFormat,
138
159
  options: normalizedOptions,
139
160
  questions: params.questions,
140
161
  allowMultiple,
@@ -166,17 +187,17 @@ export async function askUserHandler(
166
187
  ? `Pi · ${sessionName} · ${questionTitle}`
167
188
  : `Pi · ${questionTitle}`;
168
189
 
169
- const options: Record<string, unknown> = {
190
+ const windowOptions: Record<string, unknown> = {
170
191
  width: 1200,
171
192
  height: 900,
172
193
  title: title.length > 60 ? `${title.slice(0, 57)}…` : title,
173
194
  };
174
195
 
175
196
  if (params.followCursor) {
176
- options.followCursor = true;
197
+ windowOptions.followCursor = true;
177
198
  }
178
199
 
179
- result = (await prompt(html, options)) as Record<
200
+ result = (await prompt(html, windowOptions)) as Record<
180
201
  string,
181
202
  unknown
182
203
  > | null;
@@ -190,17 +211,30 @@ export async function askUserHandler(
190
211
  });
191
212
  }
192
213
  } catch (err) {
193
- // Glimpse unavailable — fall back to terminal prompt
194
- const fallbackResult = await terminalPrompt(
195
- payload,
196
- ctx.hasUI ? ctx.ui : undefined,
197
- );
198
- if (fallbackResult === null) {
199
- cancelled = true;
200
- } else {
201
- 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
+ );
202
222
  }
203
- 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
+ };
204
238
  }
205
239
 
206
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(
@@ -1,191 +0,0 @@
1
- import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
- import type { AskUserPayload, Question } from "../shared/ask-user.js";
3
-
4
- export async function terminalPrompt(
5
- payload: AskUserPayload,
6
- ui: ExtensionUIContext | undefined,
7
- ): Promise<Record<string, unknown> | null> {
8
- if (!ui) {
9
- return null;
10
- }
11
-
12
- // Questionnaire mode: structured questions with per-question options
13
- if (payload.questions && payload.questions.length > 0) {
14
- return questionnaireFallback(
15
- payload.questions,
16
- payload.allowComment,
17
- ui,
18
- payload.context,
19
- );
20
- }
21
-
22
- // Legacy flat options mode
23
- return flatOptionsFallback(payload, ui);
24
- }
25
-
26
- type QuestionnaireAnswer = {
27
- question: string;
28
- answer: string;
29
- kind: "selection" | "freeform";
30
- comment?: string;
31
- };
32
-
33
- async function questionnaireFallback(
34
- questions: Question[],
35
- allowComment: boolean,
36
- ui: ExtensionUIContext,
37
- context?: string,
38
- ): Promise<Record<string, unknown> | null> {
39
- const answers: QuestionnaireAnswer[] = [];
40
-
41
- for (const q of questions) {
42
- const prompt = context ? `${q.title}\n\nContext: ${context}` : q.title;
43
- let answer: string | undefined;
44
-
45
- if (q.options && q.options.length > 0) {
46
- const labels = q.options.map((opt, i) => `${i + 1}. ${opt.title}`);
47
-
48
- if (q.allowMultiple) {
49
- const selections: string[] = [];
50
- while (true) {
51
- const remaining = labels.filter((_, i) => {
52
- const title = q.options?.[i]?.title;
53
- return title ? !selections.includes(title) : false;
54
- });
55
- if (remaining.length === 0) break;
56
-
57
- const choice = await ui.select(
58
- `${prompt}\nSelected: ${selections.join(", ") || "none"}\nChoose one (or cancel to finish)`,
59
- remaining,
60
- );
61
- if (choice === undefined) break;
62
-
63
- const idx = labels.indexOf(choice);
64
- const title = q.options[idx]?.title;
65
- if (title && !selections.includes(title)) {
66
- selections.push(title);
67
- }
68
- }
69
- answer = selections.join(", ");
70
- } else {
71
- const choice = await ui.select(prompt, labels);
72
- if (choice === undefined) return null;
73
- const idx = labels.indexOf(choice);
74
- answer = q.options[idx]?.title;
75
- }
76
- } else {
77
- answer = await ui.input(
78
- prompt + (q.description ? `\n${q.description}` : ""),
79
- );
80
- }
81
-
82
- if (answer === undefined) return null;
83
-
84
- let comment: string | undefined;
85
- if (allowComment) {
86
- comment =
87
- (await ui.input(
88
- `Comment for "${q.title}" (press Enter to skip):`,
89
- )) ?? undefined;
90
- }
91
-
92
- answers.push({
93
- question: q.title,
94
- answer,
95
- kind: q.options && q.options.length > 0 ? "selection" : "freeform",
96
- comment,
97
- });
98
- }
99
-
100
- return {
101
- kind: "questionnaire",
102
- selections: answers.map((a) => `${a.question}: ${a.answer}`),
103
- questionnaireDetails: answers,
104
- };
105
- }
106
-
107
- async function flatOptionsFallback(
108
- payload: AskUserPayload,
109
- ui: ExtensionUIContext,
110
- ): Promise<Record<string, unknown> | null> {
111
- const {
112
- question,
113
- context,
114
- options,
115
- allowMultiple,
116
- allowFreeform,
117
- allowComment,
118
- } = payload;
119
-
120
- const prompt = context ? `${question}\n\nContext: ${context}` : question;
121
-
122
- if (options.length === 0) {
123
- const text = await ui.input(prompt);
124
- if (text === undefined) return null;
125
- return { kind: "freeform", text };
126
- }
127
- const optionLabels = options.map((opt, i) => `${i + 1}. ${opt.title}`);
128
- if (allowFreeform) {
129
- optionLabels.push("Other (freeform)");
130
- }
131
-
132
- if (allowMultiple) {
133
- const selections: string[] = [];
134
- while (true) {
135
- const remaining = optionLabels.filter(
136
- (_, i) => !selections.includes(options[i]?.title ?? ""),
137
- );
138
- if (remaining.length === 0) break;
139
-
140
- const choice = await ui.select(
141
- `${prompt}\nSelected: ${selections.join(", ") || "none"}\nChoose one (or cancel to finish)`,
142
- remaining,
143
- );
144
- if (choice === undefined) break;
145
-
146
- const idx = optionLabels.indexOf(choice);
147
- if (idx >= options.length) {
148
- const text = await ui.input("Enter your answer:");
149
- if (text?.trim()) {
150
- selections.push(`Other: ${text.trim()}`);
151
- }
152
- continue;
153
- }
154
- const title = options[idx]?.title;
155
- if (title && !selections.includes(title)) {
156
- selections.push(title);
157
- }
158
- }
159
-
160
- let comment: string | undefined;
161
- if (allowComment && selections.length > 0) {
162
- comment =
163
- (await ui.input("Optional comment (press Enter to skip):")) ??
164
- undefined;
165
- }
166
-
167
- return { kind: "selection", selections, comment };
168
- } else {
169
- const choice = await ui.select(prompt, optionLabels);
170
- if (choice === undefined) return null;
171
-
172
- const idx = optionLabels.indexOf(choice);
173
- if (idx >= options.length) {
174
- const text = await ui.input("Enter your answer:");
175
- if (text === undefined) return null;
176
- return { kind: "freeform", text };
177
- }
178
-
179
- const title = options[idx]?.title;
180
- if (!title) return null;
181
-
182
- let comment: string | undefined;
183
- if (allowComment) {
184
- comment =
185
- (await ui.input("Optional comment (press Enter to skip):")) ??
186
- undefined;
187
- }
188
-
189
- return { kind: "selection", selections: [title], comment };
190
- }
191
- }