@alexleekt/pi-ask-user-glimpse 0.2.1 → 0.3.1

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/index.ts CHANGED
@@ -2,24 +2,36 @@
2
2
  * pi-ask-user-glimpse — Pi extension that replaces ask_user with native WebView dialogs via glimpseui.
3
3
  */
4
4
 
5
- import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type CustomJournalEntry,
7
+ isCustomEntry,
8
+ } from "@alexleekt/pi-shared/types";
9
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
10
+ import type {
11
+ BuildSystemPromptOptions,
12
+ ExtensionAPI,
13
+ } from "@earendil-works/pi-coding-agent";
6
14
  import { defineTool } from "@earendil-works/pi-coding-agent";
7
- import { Type, StringEnum } from "@earendil-works/pi-ai";
8
- import { askUserHandler, type AskUserParams } from "./tool/ask-user.js";
15
+ import { type AskUserParams, askUserHandler } from "./tool/ask-user.js";
9
16
 
10
17
  /* ── Generic question-session detection ── */
11
18
 
12
19
  const QUESTION_SESSION_PATTERNS = [
13
- /ask the questions? one at a time/i,
14
- /interview me/i,
15
- /grilling session/i,
16
- /ask questions? (one at a time|sequentially|individually)/i,
17
- /wait for feedback/i,
18
- /questionnaire mode/i,
19
- /one question per call/i,
20
+ /ask the questions? one at a time/i,
21
+ /interview me/i,
22
+ /grilling session/i,
23
+ /ask questions? (one at a time|sequentially|individually)/i,
24
+ /wait for feedback/i,
25
+ /questionnaire mode/i,
26
+ /one question per call/i,
20
27
  ];
21
28
 
22
- const QUESTION_SKILL_NAMES = new Set(["grill-with-docs", "questionnaire", "interview", "grill"]);
29
+ const QUESTION_SKILL_NAMES = new Set([
30
+ "grill-with-docs",
31
+ "questionnaire",
32
+ "interview",
33
+ "grill",
34
+ ]);
23
35
 
24
36
  const ASK_USER_MANDATE = `
25
37
  ## Tool Usage Mandate — Auto-injected by pi-ask-user-glimpse
@@ -36,408 +48,542 @@ Rules:
36
48
  - Wait for the tool result before continuing to the next question.
37
49
  `;
38
50
 
39
- function isQuestionSession(systemPrompt: string, options: BuildSystemPromptOptions): boolean {
40
- const hasQuestionSkill = !!options.skills?.some((s) =>
41
- QUESTION_SKILL_NAMES.has(s.name.toLowerCase()),
42
- );
43
- const hasQuestionLanguage = QUESTION_SESSION_PATTERNS.some((p) => p.test(systemPrompt));
44
- return hasQuestionSkill || hasQuestionLanguage;
51
+ function isQuestionSession(
52
+ systemPrompt: string,
53
+ options: BuildSystemPromptOptions,
54
+ ): boolean {
55
+ const hasQuestionSkill = !!options.skills?.some((s) =>
56
+ QUESTION_SKILL_NAMES.has(s.name.toLowerCase()),
57
+ );
58
+ const hasQuestionLanguage = QUESTION_SESSION_PATTERNS.some((p) =>
59
+ p.test(systemPrompt),
60
+ );
61
+ return hasQuestionSkill || hasQuestionLanguage;
45
62
  }
46
63
 
47
- function getStyleMode(entries: any[]): boolean | null {
48
- const entry = entries.find((e) => e.type === "custom" && e.customType === "ask-user-style");
49
- return (entry as any)?.data?.enabled ?? null;
64
+ function getStyleMode(entries: unknown[]): boolean | null {
65
+ const entry = entries.find(
66
+ (e): e is CustomJournalEntry =>
67
+ isCustomEntry(e) && e.customType === "ask-user-style",
68
+ );
69
+ const enabled = entry?.data?.enabled;
70
+ return typeof enabled === "boolean" ? enabled : null;
50
71
  }
51
72
 
52
- function extractTextFromAssistantEntry(entry: any): string {
53
- const content = entry.message?.content;
54
- if (typeof content === "string") return content;
55
- if (!Array.isArray(content)) return "";
56
- return content
57
- .filter((c) => c.type === "text")
58
- .map((c) => c.text)
59
- .join("\n");
73
+ function extractTextFromAssistantEntry(entry: unknown): string {
74
+ const message = (entry as unknown as Record<string, unknown>)?.message;
75
+ const content = (message as unknown as Record<string, unknown>)?.content;
76
+ if (typeof content === "string") return content;
77
+ if (!Array.isArray(content)) return "";
78
+ return content
79
+ .filter(
80
+ (c): c is { type: string; text: string } =>
81
+ typeof (c as Record<string, unknown>)?.type === "string" &&
82
+ typeof (c as Record<string, unknown>)?.text === "string",
83
+ )
84
+ .map((c) => c.text)
85
+ .join("\n");
60
86
  }
61
87
 
62
88
  /* ── /ask-last: extract questions & implicit requests ── */
63
89
 
64
90
  const PROTECTED_ABBREVIATIONS = new Set([
65
- "etc", "vs", "fig", "dr", "mr", "mrs", "ms",
66
- "prof", "jr", "sr", "inc", "ltd", "corp", "co", "llc", "al",
67
- "et", "vol", "vols", "pg", "pp", "ch", "chap", "sec", "secs",
91
+ "etc",
92
+ "vs",
93
+ "fig",
94
+ "dr",
95
+ "mr",
96
+ "mrs",
97
+ "ms",
98
+ "prof",
99
+ "jr",
100
+ "sr",
101
+ "inc",
102
+ "ltd",
103
+ "corp",
104
+ "co",
105
+ "llc",
106
+ "al",
107
+ "et",
108
+ "vol",
109
+ "vols",
110
+ "pg",
111
+ "pp",
112
+ "ch",
113
+ "chap",
114
+ "sec",
115
+ "secs",
68
116
  ]);
69
117
 
70
118
  function splitSentences(text: string): string[] {
71
- const PLACEHOLDER = "\x00";
72
-
73
- let buffer = text.replace(
74
- /\b(e\.g\.|i\.e\.)\b/gi,
75
- (m) => m.replace(/\./g, PLACEHOLDER),
76
- );
77
-
78
- buffer = buffer.replace(
79
- /\b([a-zA-Z]{1,4})\./g,
80
- (match, abbr) => (PROTECTED_ABBREVIATIONS.has(abbr.toLowerCase()) ? match.replace(".", PLACEHOLDER) : match),
81
- );
82
-
83
- buffer = buffer.replace(/\d+\.\d+/g, (m) => m.replace(/\./g, PLACEHOLDER));
84
- buffer = buffer.replace(/https?:\/\/\S+/g, (m) => m.replace(/\./g, PLACEHOLDER));
85
- buffer = buffer.replace(/\b\w\.\w\./g, (m) => m.replace(/\./g, PLACEHOLDER));
86
-
87
- return buffer
88
- .split(/(?<=[.!?])\s+/)
89
- .map((s) => s.trim().replace(new RegExp(PLACEHOLDER, "g"), "."))
90
- .filter((s) => s.length > 0);
119
+ const PLACEHOLDER = "\x00";
120
+
121
+ let buffer = text.replace(/\b(e\.g\.|i\.e\.)\b/gi, (m) =>
122
+ m.replace(/\./g, PLACEHOLDER),
123
+ );
124
+
125
+ buffer = buffer.replace(/\b([a-zA-Z]{1,4})\./g, (match, abbr) =>
126
+ PROTECTED_ABBREVIATIONS.has(abbr.toLowerCase())
127
+ ? match.replace(".", PLACEHOLDER)
128
+ : match,
129
+ );
130
+
131
+ buffer = buffer.replace(/\d+\.\d+/g, (m) => m.replace(/\./g, PLACEHOLDER));
132
+ buffer = buffer.replace(/https?:\/\/\S+/g, (m) =>
133
+ m.replace(/\./g, PLACEHOLDER),
134
+ );
135
+ buffer = buffer.replace(/\b\w\.\w\./g, (m) =>
136
+ m.replace(/\./g, PLACEHOLDER),
137
+ );
138
+
139
+ return buffer
140
+ .split(/(?<=[.!?])\s+/)
141
+ .map((s) => s.trim().replace(new RegExp(PLACEHOLDER, "g"), "."))
142
+ .filter((s) => s.length > 0);
91
143
  }
92
144
 
93
145
  const IMPLICIT_REQUEST_PATTERNS = [
94
- /\b(let me know|let us know)\b/i,
95
- /\b(tell me|tell us)\b/i,
96
- /\b(share your|share any)\b/i,
97
- /\b(what do you think|what are your thoughts)\b/i,
98
- /\b(which\b.*\b(would you|do you|should we)\b)/i,
99
- /\b(should we|can you confirm|please confirm|could you confirm)\b/i,
100
- /\b(i need your (input|feedback|thoughts|opinion))\b/i,
101
- /\b(your (thoughts|opinion|preference|feedback))\b/i,
102
- /\b(please provide|could you provide|can you provide)\b/i,
103
- /\b(would you like|do you want|do you prefer)\b/i,
146
+ /\b(let me know|let us know)\b/i,
147
+ /\b(tell me|tell us)\b/i,
148
+ /\b(share your|share any)\b/i,
149
+ /\b(what do you think|what are your thoughts)\b/i,
150
+ /\b(which\b.*\b(would you|do you|should we)\b)/i,
151
+ /\b(should we|can you confirm|please confirm|could you confirm)\b/i,
152
+ /\b(i need your (input|feedback|thoughts|opinion))\b/i,
153
+ /\b(your (thoughts|opinion|preference|feedback))\b/i,
154
+ /\b(please provide|could you provide|can you provide)\b/i,
155
+ /\b(would you like|do you want|do you prefer)\b/i,
104
156
  ];
105
157
 
106
158
  function hasQuotedQuestion(sentence: string): boolean {
107
- return /["'`].*\?.*["'`]/.test(sentence) && !sentence.endsWith("?");
159
+ return /["'`].*\?.*["'`]/.test(sentence) && !sentence.endsWith("?");
108
160
  }
109
161
 
110
162
  function looksLikeTernary(sentence: string): boolean {
111
- return /\?\s*[:;]/.test(sentence) || /=\s*\S+\s*\?/.test(sentence);
163
+ return /\?\s*[:;]/.test(sentence) || /=\s*\S+\s*\?/.test(sentence);
112
164
  }
113
165
 
114
166
  function extractQuestions(text: string): string[] {
115
- const explicit: string[] = [];
116
- const implicit: string[] = [];
117
-
118
- for (const sentence of splitSentences(text)) {
119
- if (sentence.endsWith("?")) {
120
- if (hasQuotedQuestion(sentence)) continue;
121
- if (looksLikeTernary(sentence)) continue;
122
- if (sentence.length < 10) continue;
123
- explicit.push(sentence);
124
- continue;
125
- }
126
-
127
- if (IMPLICIT_REQUEST_PATTERNS.some((p) => p.test(sentence))) {
128
- implicit.push(sentence);
129
- }
130
- }
131
-
132
- return [...explicit, ...implicit];
167
+ const explicit: string[] = [];
168
+ const implicit: string[] = [];
169
+
170
+ for (const sentence of splitSentences(text)) {
171
+ if (sentence.endsWith("?")) {
172
+ if (hasQuotedQuestion(sentence)) continue;
173
+ if (looksLikeTernary(sentence)) continue;
174
+ if (sentence.length < 10) continue;
175
+ explicit.push(sentence);
176
+ continue;
177
+ }
178
+
179
+ if (IMPLICIT_REQUEST_PATTERNS.some((p) => p.test(sentence))) {
180
+ implicit.push(sentence);
181
+ }
182
+ }
183
+
184
+ return [...explicit, ...implicit];
133
185
  }
134
186
 
135
187
  function truncate(str: string, max: number): string {
136
- return str.length > max ? str.slice(0, max - 3) + "..." : str;
188
+ return str.length > max ? `${str.slice(0, max - 3)}...` : str;
137
189
  }
138
190
 
139
- function buildAskLastParams(questions: string[], fullText: string): AskUserParams {
140
- if (questions.length === 0) {
141
- return {
142
- question: "The assistant would like your input on the following:",
143
- context: fullText,
144
- allowFreeform: true,
145
- };
146
- }
147
- if (questions.length === 1) {
148
- return {
149
- question: questions[0],
150
- context: fullText,
151
- allowFreeform: true,
152
- };
153
- }
154
- return {
155
- question: "The assistant asked multiple questions",
156
- context: fullText,
157
- questions: questions.map((q) => ({
158
- title: truncate(q, 60),
159
- description: q,
160
- })),
161
- allowComment: true,
162
- allowSkip: true,
163
- };
191
+ function buildAskLastParams(
192
+ questions: string[],
193
+ fullText: string,
194
+ ): AskUserParams {
195
+ if (questions.length === 0) {
196
+ return {
197
+ question: "The assistant would like your input on the following:",
198
+ context: fullText,
199
+ allowFreeform: true,
200
+ };
201
+ }
202
+ if (questions.length === 1) {
203
+ return {
204
+ question: questions[0],
205
+ context: fullText,
206
+ allowFreeform: true,
207
+ };
208
+ }
209
+ return {
210
+ question: "The assistant asked multiple questions",
211
+ context: fullText,
212
+ questions: questions.map((q) => ({
213
+ title: truncate(q, 60),
214
+ description: q,
215
+ })),
216
+ allowComment: true,
217
+ allowSkip: true,
218
+ };
164
219
  }
165
220
 
166
221
  function buildDebugParams(mode: string): AskUserParams | null {
167
- switch (mode) {
168
- case "single-select":
169
- return {
170
- question: "Test: Single Select",
171
- context: "Pick one option (with optional freeform and comment)",
172
- options: [
173
- { title: "Option A", description: "Description for A" },
174
- { title: "Option B", description: "Description for B" },
175
- { title: "Option C", description: "Description for C" },
176
- ],
177
- allowFreeform: true,
178
- allowComment: true,
179
- };
180
- case "multi-select":
181
- return {
182
- question: "Test: Multi Select",
183
- context: "Pick multiple options (with optional freeform and comment)",
184
- options: [
185
- { title: "Feature X", description: "Enable feature X" },
186
- { title: "Feature Y", description: "Enable feature Y" },
187
- { title: "Feature Z", description: "Enable feature Z" },
188
- ],
189
- allowMultiple: true,
190
- allowFreeform: true,
191
- allowComment: true,
192
- };
193
- case "freeform":
194
- return {
195
- question: "Test: Freeform",
196
- context: "Type any answer you like",
197
- allowFreeform: true,
198
- };
199
- case "questionnaire":
200
- return {
201
- question: "Test: Questionnaire",
202
- context: "Answer multiple structured questions",
203
- questions: [
204
- {
205
- title: "Database",
206
- description: "Which database should we use?",
207
- options: [
208
- { title: "PostgreSQL", description: "Relational, proven" },
209
- { title: "SQLite", description: "Zero-config" },
210
- ],
211
- },
212
- {
213
- title: "Architecture",
214
- description: "Preferred style?",
215
- options: [
216
- { title: "Monolith", description: "Simple" },
217
- { title: "Microservices", description: "Scalable" },
218
- ],
219
- allowMultiple: true,
220
- },
221
- {
222
- title: "Notes",
223
- description: "Any additional thoughts?",
224
- },
225
- ],
226
- allowComment: true,
227
- };
228
- default:
229
- return null;
230
- }
222
+ switch (mode) {
223
+ case "single-select":
224
+ return {
225
+ question: "Test: Single Select",
226
+ context: "Pick one option (with optional freeform and comment)",
227
+ options: [
228
+ { title: "Option A", description: "Description for A" },
229
+ { title: "Option B", description: "Description for B" },
230
+ { title: "Option C", description: "Description for C" },
231
+ ],
232
+ allowFreeform: true,
233
+ allowComment: true,
234
+ };
235
+ case "multi-select":
236
+ return {
237
+ question: "Test: Multi Select",
238
+ context:
239
+ "Pick multiple options (with optional freeform and comment)",
240
+ options: [
241
+ { title: "Feature X", description: "Enable feature X" },
242
+ { title: "Feature Y", description: "Enable feature Y" },
243
+ { title: "Feature Z", description: "Enable feature Z" },
244
+ ],
245
+ allowMultiple: true,
246
+ allowFreeform: true,
247
+ allowComment: true,
248
+ };
249
+ case "freeform":
250
+ return {
251
+ question: "Test: Freeform",
252
+ context: "Type any answer you like",
253
+ allowFreeform: true,
254
+ };
255
+ case "questionnaire":
256
+ return {
257
+ question: "Test: Questionnaire",
258
+ context: "Answer multiple structured questions",
259
+ questions: [
260
+ {
261
+ title: "Database",
262
+ description: "Which database should we use?",
263
+ options: [
264
+ {
265
+ title: "PostgreSQL",
266
+ description: "Relational, proven",
267
+ },
268
+ { title: "SQLite", description: "Zero-config" },
269
+ ],
270
+ },
271
+ {
272
+ title: "Architecture",
273
+ description: "Preferred style?",
274
+ options: [
275
+ { title: "Monolith", description: "Simple" },
276
+ { title: "Microservices", description: "Scalable" },
277
+ ],
278
+ allowMultiple: true,
279
+ },
280
+ {
281
+ title: "Notes",
282
+ description: "Any additional thoughts?",
283
+ },
284
+ ],
285
+ allowComment: true,
286
+ };
287
+ case "mermaid":
288
+ return {
289
+ question: "Test: Mermaid Diagrams",
290
+ context: `This prompt includes a Mermaid diagram to test rendering.
291
+
292
+ \`\`\`mermaid
293
+ graph TD
294
+ A[User asks question] --> B{Has context?}
295
+ B -->|Yes| C[Show left panel]
296
+ B -->|No| D[Single panel]
297
+ C --> E[Render markdown + diagrams]
298
+ D --> E
299
+ \`\`\`
300
+
301
+ The diagram above should render as an SVG. Below is a sequence diagram:
302
+
303
+ \`\`\`mermaid
304
+ sequenceDiagram
305
+ participant Agent
306
+ participant User
307
+ Agent->>User: Ask question
308
+ User->>Agent: Submit answer
309
+ \`\`\`
310
+ `,
311
+ options: [
312
+ { title: "Looks good", description: "Diagrams render correctly" },
313
+ { title: "Broken", description: "Something is wrong" },
314
+ ],
315
+ allowComment: true,
316
+ };
317
+ default:
318
+ return null;
319
+ }
231
320
  }
232
321
 
233
322
  const askUserTool = defineTool({
234
- name: "ask_user",
235
- label: "Ask User",
236
- description:
237
- "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
238
- promptSnippet:
239
- "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
240
- promptGuidelines: [
241
- "Always use ask_user instead of guessing when user input would improve the answer.",
242
- "Pass a concise question and, when applicable, a list of options with short titles and optional longer descriptions.",
243
- "Set allowMultiple: true when more than one choice is valid.",
244
- "Set allowFreeform: true (default) when the user might want to answer in their own words.",
245
- ],
246
- parameters: Type.Object({
247
- question: Type.String({ description: "The question to ask the user" }),
248
- context: Type.Optional(
249
- Type.String({
250
- description: "Additional context to help the user understand the question",
251
- }),
252
- ),
253
- options: Type.Optional(
254
- Type.Array(
255
- Type.Union([
256
- Type.String({ description: "Short option label" }),
257
- Type.Object({
258
- title: Type.String({ description: "Short title for this option" }),
259
- description: Type.Optional(
260
- Type.String({
261
- description: "Longer description explaining this option",
262
- }),
263
- ),
264
- }),
265
- ]),
266
- { description: "List of options for the user to choose from" },
267
- ),
268
- ),
269
- questions: Type.Optional(
270
- Type.Array(
271
- Type.Object({
272
- title: Type.String({ description: "Question title" }),
273
- description: Type.Optional(Type.String({ description: "Question description" })),
274
- options: Type.Optional(
275
- Type.Array(
276
- Type.Object({
277
- title: Type.String({ description: "Option title" }),
278
- description: Type.Optional(Type.String({ description: "Option description" })),
279
- }),
280
- { description: "Options for this question (omit for freeform text)" },
281
- ),
282
- ),
283
- allowMultiple: Type.Optional(
284
- Type.Boolean({ description: "Allow multiple selections for this question. Default: false" }),
285
- ),
286
- },
287
- { description: "For questionnaire mode: structured questions with optional multiple-choice options per question" },
288
- ),
289
- ),
290
- ),
291
- allowMultiple: Type.Optional(
292
- Type.Boolean({
293
- description: "Allow selecting multiple options. Default: false",
294
- }),
295
- ),
296
- allowFreeform: Type.Optional(
297
- Type.Boolean({
298
- description: "Add a freeform text option. Default: true",
299
- }),
300
- ),
301
- allowComment: Type.Optional(
302
- Type.Boolean({
303
- description:
304
- "Collect an optional comment after selecting one or more options. Default: false",
305
- }),
306
- ),
307
- displayMode: Type.Optional(
308
- StringEnum(["overlay", "inline"], {
309
- description: "Legacy option; ignored by Glimpse (always opens a centered dialog)",
310
- }),
311
- ),
312
- allowSkip: Type.Optional(
313
- Type.Boolean({
314
- description: "Allow submitting a questionnaire without answering all questions. Default: false",
315
- }),
316
- ),
317
- followCursor: Type.Optional(
318
- Type.Boolean({
319
- description: "Make the dialog follow the terminal cursor. Default: false",
320
- }),
321
- ),
322
- }),
323
-
324
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
325
- return askUserHandler(params, signal, ctx);
326
- },
323
+ name: "ask_user",
324
+ label: "Ask User",
325
+ description:
326
+ "Ask the user a question with optional multiple-choice answers. Use this to gather information interactively. Ask exactly one focused question per call. Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
327
+ promptSnippet:
328
+ "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
329
+ promptGuidelines: [
330
+ "Always use ask_user instead of guessing when user input would improve the answer.",
331
+ "Pass a concise question and, when applicable, a list of options with short titles and optional longer descriptions.",
332
+ "Set allowMultiple: true when more than one choice is valid.",
333
+ "Set allowFreeform: true (default) when the user might want to answer in their own words.",
334
+ ],
335
+ parameters: Type.Object({
336
+ question: Type.String({ description: "The question to ask the user" }),
337
+ context: Type.Optional(
338
+ Type.String({
339
+ description:
340
+ "Additional context to help the user understand the question",
341
+ }),
342
+ ),
343
+ options: Type.Optional(
344
+ Type.Array(
345
+ Type.Union([
346
+ Type.String({ description: "Short option label" }),
347
+ Type.Object({
348
+ title: Type.String({
349
+ description: "Short title for this option",
350
+ }),
351
+ description: Type.Optional(
352
+ Type.String({
353
+ description:
354
+ "Longer description explaining this option",
355
+ }),
356
+ ),
357
+ }),
358
+ ]),
359
+ { description: "List of options for the user to choose from" },
360
+ ),
361
+ ),
362
+ questions: Type.Optional(
363
+ Type.Array(
364
+ Type.Object(
365
+ {
366
+ title: Type.String({ description: "Question title" }),
367
+ description: Type.Optional(
368
+ Type.String({
369
+ description: "Question description",
370
+ }),
371
+ ),
372
+ options: Type.Optional(
373
+ Type.Array(
374
+ Type.Object({
375
+ title: Type.String({
376
+ description: "Option title",
377
+ }),
378
+ description: Type.Optional(
379
+ Type.String({
380
+ description: "Option description",
381
+ }),
382
+ ),
383
+ }),
384
+ {
385
+ description:
386
+ "Options for this question (omit for freeform text)",
387
+ },
388
+ ),
389
+ ),
390
+ allowMultiple: Type.Optional(
391
+ Type.Boolean({
392
+ description:
393
+ "Allow multiple selections for this question. Default: false",
394
+ }),
395
+ ),
396
+ },
397
+ {
398
+ description:
399
+ "For questionnaire mode: structured questions with optional multiple-choice options per question",
400
+ },
401
+ ),
402
+ ),
403
+ ),
404
+ allowMultiple: Type.Optional(
405
+ Type.Boolean({
406
+ description: "Allow selecting multiple options. Default: false",
407
+ }),
408
+ ),
409
+ allowFreeform: Type.Optional(
410
+ Type.Boolean({
411
+ description: "Add a freeform text option. Default: true",
412
+ }),
413
+ ),
414
+ allowComment: Type.Optional(
415
+ Type.Boolean({
416
+ description:
417
+ "Collect an optional comment after selecting one or more options. Default: false",
418
+ }),
419
+ ),
420
+ displayMode: Type.Optional(
421
+ StringEnum(["overlay", "inline"], {
422
+ description:
423
+ "Legacy option; ignored by Glimpse (always opens a centered dialog)",
424
+ }),
425
+ ),
426
+ allowSkip: Type.Optional(
427
+ Type.Boolean({
428
+ description:
429
+ "Allow submitting a questionnaire without answering all questions. Default: false",
430
+ }),
431
+ ),
432
+ followCursor: Type.Optional(
433
+ Type.Boolean({
434
+ description:
435
+ "Make the dialog follow the terminal cursor. Default: false",
436
+ }),
437
+ ),
438
+ }),
439
+
440
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
441
+ return askUserHandler(params, signal, ctx);
442
+ },
327
443
  });
328
444
 
329
445
  export default function (pi: ExtensionAPI) {
330
- pi.registerTool(askUserTool);
331
-
332
- // ── Auto-detect question sessions and force ask_user usage ──
333
- pi.on("before_agent_start", async (event, ctx) => {
334
- const hasAskUser = event.systemPromptOptions.selectedTools?.includes("ask_user");
335
- if (!hasAskUser) return;
336
-
337
- const styleMode = getStyleMode(ctx.sessionManager.getEntries());
338
- const shouldInject =
339
- styleMode === true ||
340
- (styleMode === null && isQuestionSession(event.systemPrompt, event.systemPromptOptions));
341
-
342
- if (shouldInject) {
343
- return { systemPrompt: event.systemPrompt + ASK_USER_MANDATE };
344
- }
345
- });
346
-
347
- // ── Manual style toggle for ask_user behavior ──
348
- pi.registerCommand("ask-style", {
349
- description: "Cycle ask_user style: Auto → Always Dialog → Plain Text → Auto",
350
- handler: async (_args, ctx) => {
351
- const styleMode = getStyleMode(ctx.sessionManager.getEntries());
352
-
353
- let nextMode: boolean | null;
354
- let label: string;
355
-
356
- if (styleMode === null) {
357
- nextMode = true;
358
- label = "Always Dialog (auto-detection overridden)";
359
- } else if (styleMode === true) {
360
- nextMode = false;
361
- label = "Plain Text (no dialog injection)";
362
- } else {
363
- nextMode = null;
364
- label = "Auto (skill + pattern detection)";
365
- }
366
-
367
- pi.appendEntry("ask-user-style", { enabled: nextMode });
368
- ctx.ui.notify(`ask_user style: ${label}`, "info");
369
- },
370
- });
371
-
372
- pi.registerCommand("ask-last", {
373
- description: "Extract questions from the last assistant message and ask them via ask_user",
374
- handler: async (_args, ctx) => {
375
- if (!ctx.hasUI) {
376
- console.warn("[pi-ask-user-glimpse] ask-last requires interactive mode");
377
- return;
378
- }
379
-
380
- const entries = ctx.sessionManager.getEntries();
381
- const lastAssistant = [...entries]
382
- .reverse()
383
- .find((e) => e.type === "message" && (e as any).message?.role === "assistant");
384
-
385
- if (!lastAssistant) {
386
- ctx.ui.notify("No assistant messages found in this session", "warning");
387
- return;
388
- }
389
-
390
- const fullText = extractTextFromAssistantEntry(lastAssistant);
391
- if (!fullText.trim()) {
392
- ctx.ui.notify("Last assistant message has no text content", "warning");
393
- return;
394
- }
395
-
396
- const questions = extractQuestions(fullText);
397
-
398
- const result = await askUserHandler(buildAskLastParams(questions, fullText), undefined, ctx);
399
-
400
- if ((result.details as any)?.cancelled) {
401
- ctx.ui.notify("Cancelled no answer sent", "info");
402
- return;
403
- }
404
-
405
- const textContent = result.content[0];
406
- const answer = textContent?.type === "text" ? textContent.text : "";
407
- if (!answer) return;
408
-
409
- let prefix: string;
410
- if (questions.length === 0) {
411
- prefix = "Responding to your last message:";
412
- } else {
413
- const plural = questions.length > 1 ? "s" : "";
414
- prefix = `Answering the question${plural} from your last message:`;
415
- }
416
- pi.sendUserMessage(`${prefix}\n\n${answer}`);
417
- },
418
- });
419
-
420
- pi.registerCommand("ask-debug", {
421
- description: "Open a debug prompt to test each ask_user dialog type",
422
- handler: async (_args, ctx) => {
423
- if (!ctx.hasUI) {
424
- console.warn("[pi-ask-user-glimpse] ask-debug requires interactive mode");
425
- return;
426
- }
427
-
428
- const mode = await ctx.ui.select(
429
- "Choose a prompt type to test:",
430
- ["single-select", "multi-select", "freeform", "questionnaire"],
431
- );
432
- if (!mode) return;
433
-
434
- const params = buildDebugParams(mode);
435
- if (!params) return;
436
-
437
- const result = await askUserHandler(params, undefined, ctx);
438
- const textContent = result.content[0];
439
- const text = textContent.type === "text" ? textContent.text : "No response";
440
- ctx.ui.notify(`Result: ${text}`, "info");
441
- },
442
- });
446
+ pi.registerTool(askUserTool);
447
+
448
+ // ── Auto-detect question sessions and force ask_user usage ──
449
+ pi.on("before_agent_start", async (event, ctx) => {
450
+ const hasAskUser =
451
+ event.systemPromptOptions.selectedTools?.includes("ask_user");
452
+ if (!hasAskUser) return;
453
+
454
+ const styleMode = getStyleMode(ctx.sessionManager.getEntries());
455
+ const shouldInject =
456
+ styleMode === true ||
457
+ (styleMode === null &&
458
+ isQuestionSession(
459
+ event.systemPrompt,
460
+ event.systemPromptOptions,
461
+ ));
462
+
463
+ if (shouldInject) {
464
+ return { systemPrompt: event.systemPrompt + ASK_USER_MANDATE };
465
+ }
466
+ });
467
+
468
+ // ── Manual style toggle for ask_user behavior ──
469
+ pi.registerCommand("ask-style", {
470
+ description:
471
+ "Cycle ask_user style: Auto → Always Dialog → Plain Text → Auto",
472
+ handler: async (_args, ctx) => {
473
+ const styleMode = getStyleMode(ctx.sessionManager.getEntries());
474
+
475
+ let nextMode: boolean | null;
476
+ let label: string;
477
+
478
+ if (styleMode === null) {
479
+ nextMode = true;
480
+ label = "Always Dialog (auto-detection overridden)";
481
+ } else if (styleMode === true) {
482
+ nextMode = false;
483
+ label = "Plain Text (no dialog injection)";
484
+ } else {
485
+ nextMode = null;
486
+ label = "Auto (skill + pattern detection)";
487
+ }
488
+
489
+ pi.appendEntry("ask-user-style", { enabled: nextMode });
490
+ ctx.ui.notify(`ask_user style: ${label}`, "info");
491
+ },
492
+ });
493
+
494
+ pi.registerCommand("ask-last", {
495
+ description:
496
+ "Extract questions from the last assistant message and ask them via ask_user",
497
+ handler: async (_args, ctx) => {
498
+ if (!ctx.hasUI) {
499
+ console.warn(
500
+ "[pi-ask-user-glimpse] ask-last requires interactive mode",
501
+ );
502
+ return;
503
+ }
504
+
505
+ const entries = ctx.sessionManager.getEntries();
506
+ const lastAssistant = [...entries].reverse().find((e) => {
507
+ const msg = (e as unknown as Record<string, unknown>).message;
508
+ return (
509
+ typeof msg === "object" &&
510
+ msg !== null &&
511
+ (msg as Record<string, unknown>).role === "assistant"
512
+ );
513
+ });
514
+
515
+ if (!lastAssistant) {
516
+ ctx.ui.notify(
517
+ "No assistant messages found in this session",
518
+ "warning",
519
+ );
520
+ return;
521
+ }
522
+
523
+ const fullText = extractTextFromAssistantEntry(lastAssistant);
524
+ if (!fullText.trim()) {
525
+ ctx.ui.notify(
526
+ "Last assistant message has no text content",
527
+ "warning",
528
+ );
529
+ return;
530
+ }
531
+
532
+ const questions = extractQuestions(fullText);
533
+
534
+ const result = await askUserHandler(
535
+ buildAskLastParams(questions, fullText),
536
+ undefined,
537
+ ctx,
538
+ );
539
+
540
+ if (result.details.cancelled) {
541
+ ctx.ui.notify("Cancelled — no answer sent", "info");
542
+ return;
543
+ }
544
+
545
+ const textContent = result.content[0];
546
+ const answer = textContent?.type === "text" ? textContent.text : "";
547
+ if (!answer) return;
548
+
549
+ let prefix: string;
550
+ if (questions.length === 0) {
551
+ prefix = "Responding to your last message:";
552
+ } else {
553
+ const plural = questions.length > 1 ? "s" : "";
554
+ prefix = `Answering the question${plural} from your last message:`;
555
+ }
556
+ pi.sendUserMessage(`${prefix}\n\n${answer}`);
557
+ },
558
+ });
559
+
560
+ pi.registerCommand("ask-debug", {
561
+ description: "Open a debug prompt to test each ask_user dialog type",
562
+ handler: async (_args, ctx) => {
563
+ if (!ctx.hasUI) {
564
+ console.warn(
565
+ "[pi-ask-user-glimpse] ask-debug requires interactive mode",
566
+ );
567
+ return;
568
+ }
569
+
570
+ const mode = await ctx.ui.select("Choose a prompt type to test:", [
571
+ "single-select",
572
+ "multi-select",
573
+ "freeform",
574
+ "questionnaire",
575
+ "mermaid",
576
+ ]);
577
+ if (!mode) return;
578
+
579
+ const params = buildDebugParams(mode);
580
+ if (!params) return;
581
+
582
+ const result = await askUserHandler(params, undefined, ctx);
583
+ const textContent = result.content[0];
584
+ const text =
585
+ textContent.type === "text" ? textContent.text : "No response";
586
+ ctx.ui.notify(`Result: ${text}`, "info");
587
+ },
588
+ });
443
589
  }