@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/index.ts CHANGED
@@ -3,117 +3,175 @@
3
3
  */
4
4
 
5
5
  import {
6
- type CustomJournalEntry,
7
6
  isCustomEntry,
7
+ type CustomJournalEntry,
8
8
  } from "@alexleekt/pi-shared/types";
9
+
9
10
  import { StringEnum, Type } from "@earendil-works/pi-ai";
10
11
  import type {
11
- BuildSystemPromptOptions,
12
12
  ExtensionAPI,
13
+ ExtensionContext,
13
14
  } from "@earendil-works/pi-coding-agent";
14
15
  import { defineTool } from "@earendil-works/pi-coding-agent";
15
- import { type AskUserParams, askUserHandler } from "./tool/ask-user.js";
16
-
17
- /* ── Generic question-session detection ── */
18
-
19
- const QUESTION_SESSION_PATTERNS = [
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,
27
- ];
28
-
29
- const QUESTION_SKILL_NAMES = new Set([
30
- "grill-with-docs",
31
- "questionnaire",
32
- "interview",
33
- "grill",
34
- ]);
35
-
36
- const ASK_USER_MANDATE = `
37
- ## Tool Usage Mandate — Auto-injected by pi-ask-user-glimpse
38
-
39
- When you need to ask the user a question, you MUST use the \`ask_user\`
40
- tool. Do NOT write questions as free-form assistant text. Each question
41
- should be a separate \`ask_user\` tool call.
16
+ import { PROTECTED_ABBREVIATIONS } from "./constants/abbreviations.js";
17
+ import type { AnimationLevel, ThemeMode } from "./shared/ask-user.js";
18
+ import {
19
+ type AskUserMetadata,
20
+ type AskUserParams,
21
+ askUserHandler,
22
+ } from "./tool/ask-user.js";
23
+
24
+ /* ── Module-level reference to ExtensionAPI for tool execute closure ── */
25
+ let _pi: ExtensionAPI | undefined;
26
+
27
+ /* ── Style constants ── */
28
+
29
+ const YOLO_MANDATE = `
30
+ ## Tool Usage Mandate — Auto-injected by pi-ask-user-glimpse (YOLO Style)
31
+
32
+ You are in YOLO style. Do NOT ask the user for input or confirmation.
33
+ Go with your best recommendation and proceed immediately.
34
+
35
+ Only use \`ask_user\` if the action would cause irreversible harm,
36
+ data loss, security compromise, or violate explicit hard constraints.
42
37
 
43
38
  Rules:
44
- - One question per \`ask_user\` call.
45
- - Provide concise options when the question has discrete choices.
46
- - Set \`allowMultiple: true\` when more than one choice is valid.
47
- - Set \`allowFreeform: true\` when the user might want to answer in their own words.
48
- - Wait for the tool result before continuing to the next question.
39
+ - Do NOT use \`ask_user\` for routine decisions or clarifications.
40
+ - Make the call and keep moving.
41
+ - If you must use \`ask_user\`, list options from most recommended to least recommended.
49
42
  `;
50
43
 
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;
62
- }
44
+ type StyleMode = "plain" | "yolo";
63
45
 
64
- function getStyleMode(entries: unknown[]): boolean | null {
46
+ function findCustomData(
47
+ entries: unknown[],
48
+ customType: string,
49
+ ): Record<string, unknown> | undefined {
65
50
  const entry = entries.find(
66
51
  (e): e is CustomJournalEntry =>
67
- isCustomEntry(e) && e.customType === "ask-user-style",
52
+ isCustomEntry(e) && e.customType === customType,
68
53
  );
69
- const enabled = entry?.data?.enabled;
70
- return typeof enabled === "boolean" ? enabled : null;
54
+ return entry?.data as Record<string, unknown> | undefined;
71
55
  }
72
56
 
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;
57
+ function getStyleMode(entries: unknown[]): StyleMode {
58
+ const data = findCustomData(entries, "ask-user-style");
59
+ const mode = data?.mode;
60
+ if (mode === "plain" || mode === "yolo") {
61
+ return mode;
62
+ }
63
+ return "plain";
64
+ }
65
+
66
+ function getThemeSettings(entries: unknown[]): {
67
+ theme?: ThemeMode;
68
+ animationLevel?: AnimationLevel;
69
+ } {
70
+ const data = findCustomData(entries, "ask-user-theme");
71
+ const theme = typeof data?.theme === "string" ? data.theme : undefined;
72
+ const animationLevel =
73
+ typeof data?.animationLevel === "string"
74
+ ? data.animationLevel
75
+ : undefined;
76
+ return {
77
+ theme:
78
+ theme === "light" || theme === "dark" || theme === "system"
79
+ ? theme
80
+ : undefined,
81
+ animationLevel:
82
+ animationLevel === "none" ||
83
+ animationLevel === "minimal" ||
84
+ animationLevel === "all"
85
+ ? animationLevel
86
+ : undefined,
87
+ };
88
+ }
89
+
90
+ /** Extract text blocks from a content array (journal entry). */
91
+ function extractTextFromContent(content: unknown): string {
76
92
  if (typeof content === "string") return content;
77
93
  if (!Array.isArray(content)) return "";
78
94
  return content
79
95
  .filter(
80
96
  (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",
97
+ typeof c === "object" &&
98
+ c !== null &&
99
+ typeof (c as Record<string, unknown>).type === "string" &&
100
+ typeof (c as Record<string, unknown>).text === "string",
83
101
  )
84
102
  .map((c) => c.text)
85
103
  .join("\n");
86
104
  }
87
105
 
88
- /* ── /ask-last: extract questions & implicit requests ── */
89
-
90
- const PROTECTED_ABBREVIATIONS = new Set([
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",
116
- ]);
106
+ /* ── Shared helpers for consistent ask_user UX across all entry points ── */
107
+
108
+ /** Enrich raw ask_user params with persisted theme/animation settings. */
109
+ function enrichWithThemeSettings(
110
+ params: AskUserParams,
111
+ entries: unknown[],
112
+ ): AskUserParams {
113
+ const { theme, animationLevel } = getThemeSettings(entries);
114
+ return { ...params, theme, animationLevel };
115
+ }
116
+
117
+ /** Persist theme/animation changes back to the session journal. */
118
+ function saveThemeMetadata(metadata: AskUserMetadata) {
119
+ if ((metadata.theme || metadata.animationLevel) && _pi) {
120
+ _pi.appendEntry("ask-user-theme", {
121
+ theme: metadata.theme,
122
+ animationLevel: metadata.animationLevel,
123
+ });
124
+ }
125
+ }
126
+
127
+ /** Strip XML-style `<thinking>` blocks and markdown reasoning blocks from text. */
128
+ function stripThinkingBlocks(text: string): string {
129
+ return text
130
+ .replace(/<thinking>[\s\S]*?<\/thinking>/g, "")
131
+ .replace(/```\s*thinking\n[\s\S]*?```/g, "")
132
+ .trim();
133
+ }
134
+
135
+ /** Execute ask_user with full enrichment + persistence, used by tool and commands alike. */
136
+ async function runAskUserWithTheme(
137
+ rawParams: AskUserParams,
138
+ signal: AbortSignal | undefined,
139
+ ctx: ExtensionContext,
140
+ ): Promise<ReturnType<typeof askUserHandler>> {
141
+ const entries = ctx.sessionManager.getEntries();
142
+ const params = enrichWithThemeSettings(rawParams, entries);
143
+ let metadata: AskUserMetadata = {};
144
+
145
+ // Strip reasoning chains from explicitly-passed context
146
+ const cleanedParams: AskUserParams = params.context
147
+ ? { ...params, context: stripThinkingBlocks(params.context) }
148
+ : params;
149
+
150
+ const result = await askUserHandler(cleanedParams, signal, ctx, (m) => {
151
+ metadata = m;
152
+ });
153
+ saveThemeMetadata(metadata);
154
+ return result;
155
+ }
156
+
157
+ /** Extract plain text from a Pi journal assistant entry. */
158
+ function extractTextFromAssistantEntry(entry: unknown): string {
159
+ if (!entry || typeof entry !== "object") return "";
160
+ const content = ((entry as Record<string, unknown>).message as Record<string, unknown> | undefined)?.content;
161
+ return extractTextFromContent(content);
162
+ }
163
+
164
+ /** Find the most recent assistant entry in the session journal. */
165
+ function findLastAssistantEntry(entries: unknown[]): unknown | undefined {
166
+ return [...entries].reverse().find((e) => {
167
+ if (!e || typeof e !== "object") return false;
168
+ const msg = (e as Record<string, unknown>).message;
169
+ if (!msg || typeof msg !== "object") return false;
170
+ return (msg as Record<string, unknown>).role === "assistant";
171
+ });
172
+ }
173
+
174
+ /* ── /ask: extract explicit questions only ── */
117
175
 
118
176
  function splitSentences(text: string): string[] {
119
177
  const PLACEHOLDER = "\x00";
@@ -142,19 +200,6 @@ function splitSentences(text: string): string[] {
142
200
  .filter((s) => s.length > 0);
143
201
  }
144
202
 
145
- const IMPLICIT_REQUEST_PATTERNS = [
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,
156
- ];
157
-
158
203
  function hasQuotedQuestion(sentence: string): boolean {
159
204
  return /["'`].*\?.*["'`]/.test(sentence) && !sentence.endsWith("?");
160
205
  }
@@ -163,25 +208,17 @@ function looksLikeTernary(sentence: string): boolean {
163
208
  return /\?\s*[:;]/.test(sentence) || /=\s*\S+\s*\?/.test(sentence);
164
209
  }
165
210
 
211
+ /** Extract only explicit questions (sentences ending in ?).
212
+ * Implicit requests like "let me know" are ignored — the freeform textarea
213
+ * already handles open-ended input without creating phantom questionnaire rows. */
166
214
  function extractQuestions(text: string): string[] {
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];
215
+ return splitSentences(text).filter((sentence) => {
216
+ if (!sentence.endsWith("?")) return false;
217
+ if (hasQuotedQuestion(sentence)) return false;
218
+ if (looksLikeTernary(sentence)) return false;
219
+ if (sentence.length < 3) return false;
220
+ return true;
221
+ });
185
222
  }
186
223
 
187
224
  function truncate(str: string, max: number): string {
@@ -192,23 +229,24 @@ function buildAskLastParams(
192
229
  questions: string[],
193
230
  fullText: string,
194
231
  ): AskUserParams {
232
+ const cleanContext = stripThinkingBlocks(fullText);
195
233
  if (questions.length === 0) {
196
234
  return {
197
235
  question: "The assistant would like your input on the following:",
198
- context: fullText,
236
+ context: cleanContext,
199
237
  allowFreeform: true,
200
238
  };
201
239
  }
202
240
  if (questions.length === 1) {
203
241
  return {
204
242
  question: questions[0],
205
- context: fullText,
243
+ context: cleanContext,
206
244
  allowFreeform: true,
207
245
  };
208
246
  }
209
247
  return {
210
248
  question: "The assistant asked multiple questions",
211
- context: fullText,
249
+ context: cleanContext,
212
250
  questions: questions.map((q) => ({
213
251
  title: truncate(q, 60),
214
252
  description: q,
@@ -218,14 +256,23 @@ function buildAskLastParams(
218
256
  };
219
257
  }
220
258
 
259
+ /** Build a user-facing prefix for an auto-caught or manual /ask answer. */
260
+ function answerPrefix(questionCount: number): string {
261
+ if (questionCount === 0) {
262
+ return "Responding to your last message:";
263
+ }
264
+ const plural = questionCount > 1 ? "s" : "";
265
+ return `Answering the question${plural} from your last message:`;
266
+ }
267
+
221
268
  function buildDebugParams(mode: string): AskUserParams | null {
222
269
  switch (mode) {
223
270
  case "single-select":
224
271
  return {
225
272
  question: "Test: Single Select",
226
- context: "Pick one option (with optional freeform and comment)",
273
+ context: "Pick one option (with optional freeform and comment). This tests radio-style selection, recommended badges, search filtering, and keyboard navigation.",
227
274
  options: [
228
- { title: "Option A", description: "Description for A" },
275
+ { title: "Option A", description: "Description for A", recommended: true },
229
276
  { title: "Option B", description: "Description for B" },
230
277
  { title: "Option C", description: "Description for C" },
231
278
  ],
@@ -236,9 +283,9 @@ function buildDebugParams(mode: string): AskUserParams | null {
236
283
  return {
237
284
  question: "Test: Multi Select",
238
285
  context:
239
- "Pick multiple options (with optional freeform and comment)",
286
+ "Pick multiple options (with optional freeform and comment). This tests checkbox-style selection, select-all/none links, and submit-gating.",
240
287
  options: [
241
- { title: "Feature X", description: "Enable feature X" },
288
+ { title: "Feature X", description: "Enable feature X", recommended: true },
242
289
  { title: "Feature Y", description: "Enable feature Y" },
243
290
  { title: "Feature Z", description: "Enable feature Z" },
244
291
  ],
@@ -249,13 +296,13 @@ function buildDebugParams(mode: string): AskUserParams | null {
249
296
  case "freeform":
250
297
  return {
251
298
  question: "Test: Freeform",
252
- context: "Type any answer you like",
299
+ context: "Type any answer you like. This tests the textarea, character counter, and platform-aware keyboard shortcuts.",
253
300
  allowFreeform: true,
254
301
  };
255
302
  case "questionnaire":
256
303
  return {
257
304
  question: "Test: Questionnaire",
258
- context: "Answer multiple structured questions",
305
+ context: "Answer multiple structured questions. This tests the card layout, progress bar, required-field badges, and per-question character counters.",
259
306
  questions: [
260
307
  {
261
308
  title: "Database",
@@ -264,6 +311,7 @@ function buildDebugParams(mode: string): AskUserParams | null {
264
311
  {
265
312
  title: "PostgreSQL",
266
313
  description: "Relational, proven",
314
+ recommended: true,
267
315
  },
268
316
  { title: "SQLite", description: "Zero-config" },
269
317
  ],
@@ -272,7 +320,7 @@ function buildDebugParams(mode: string): AskUserParams | null {
272
320
  title: "Architecture",
273
321
  description: "Preferred style?",
274
322
  options: [
275
- { title: "Monolith", description: "Simple" },
323
+ { title: "Monolith", description: "Simple", recommended: true },
276
324
  { title: "Microservices", description: "Scalable" },
277
325
  ],
278
326
  allowMultiple: true,
@@ -284,71 +332,165 @@ function buildDebugParams(mode: string): AskUserParams | null {
284
332
  ],
285
333
  allowComment: true,
286
334
  };
287
- case "long-question":
288
- return {
289
- question: "This is a very long question that exceeds one hundred and twenty characters so it should trigger the auto-split behavior. The first sentence becomes the dialog title, and the rest flows to the context panel.",
290
- options: [
291
- { title: "Split worked", description: "Title is short, context has the rest" },
292
- { title: "Not split", description: "Everything is still in the title" },
293
- ],
294
- allowComment: true,
295
- };
296
- case "mermaid":
335
+ case "kitchen-sink":
297
336
  return {
298
- question: "Test: Mermaid Diagrams",
299
- context: `This prompt includes Mermaid diagrams to test rendering in the left context panel.
300
-
301
- \`\`\`mermaid
302
- graph TD
303
- A[User asks question] --> B{Has context?}
304
- B -->|Yes| C[Show left panel]
305
- B -->|No| D[Single panel]
306
- C --> E[Render markdown + diagrams]
307
- D --> E
308
- \`\`\`
309
-
310
- The diagram above should render as an SVG. Below is a sequence diagram:
311
-
312
- \`\`\`mermaid
313
- sequenceDiagram
314
- participant Agent
315
- participant User
316
- Agent->>User: Ask question
317
- User->>Agent: Submit answer
318
- \`\`\`
319
- `,
320
- options: [
321
- { title: "Looks good", description: "Diagrams render correctly" },
322
- { title: "Broken", description: "Something is wrong" },
337
+ question: "Kitchen Sink: Every Feature",
338
+ contextFormat: "html",
339
+ context: `<div style="font-family: ui-sans-serif, system-ui, sans-serif; overflow-wrap: break-word;">
340
+ <h2 style="color: hsl(var(--primary)); margin-bottom: 0.75rem; font-size: 1.25rem; font-weight: 600;">🧪 Debug Kitchen Sink</h2>
341
+ <p style="color: hsl(var(--muted-foreground)); margin-bottom: 1rem; line-height: 1.5;">
342
+ This dialog demonstrates every major feature — including the built-in <code style="background: hsl(var(--muted)); padding: 0.125rem 0.25rem; border-radius: 4px; font-size: 0.875em;">pi</code> charting helpers.
343
+ </p>
344
+
345
+ <div id="chart-bar" style="margin-bottom: 1rem;"></div>
346
+ <div id="chart-pie" style="margin-bottom: 1rem;"></div>
347
+ <div id="comparison-table" style="margin-bottom: 1rem;"></div>
348
+ <div id="pros-cons" style="margin-bottom: 1rem;"></div>
349
+ <div id="timeline" style="margin-bottom: 1rem;"></div>
350
+ <div id="metrics" style="margin-bottom: 1rem;"></div>
351
+
352
+ <script>
353
+ pi.barChart('#chart-bar', [
354
+ {label: 'Monolith', value: 95},
355
+ {label: 'Microservices', value: 70},
356
+ {label: 'Serverless', value: 55}
357
+ ], {title: 'Deployment Velocity Score', highlightIndex: 0, showValues: true});
358
+
359
+ pi.pieChart('#chart-pie', [
360
+ {label: 'Auth', value: 30},
361
+ {label: 'Cache', value: 25},
362
+ {label: 'Rate Limit', value: 20},
363
+ {label: 'Observability', value: 25}
364
+ ], {title: 'Feature Effort Distribution', donut: true, showLegend: true});
365
+
366
+ pi.table('#comparison-table',
367
+ ['Feature', 'Monolith', 'Microservices', 'Serverless'],
368
+ [
369
+ ['Complexity', 'Low', 'High', 'Medium'],
370
+ ['Scalability', 'Vertical', 'Horizontal', 'Auto'],
371
+ ['Cost', 'Fixed', 'Variable', 'Pay-per-use'],
372
+ ['Team Size', 'Small', 'Large', 'Any']
373
+ ],
374
+ {title: 'Architecture Comparison', highlightColumn: 1, striped: true, compact: true}
375
+ );
376
+
377
+ pi.prosCons('#pros-cons',
378
+ ['Simple deployment', 'Single codebase', 'Easy debugging', 'Low infra cost'],
379
+ ['Hard to scale', 'Tight coupling', 'Single point of failure', 'Slower CI/CD'],
380
+ {title: 'Monolith Trade-offs'}
381
+ );
382
+
383
+ pi.timeline('#timeline', [
384
+ {date: 'Week 1', title: 'Scoping', status: 'complete'},
385
+ {date: 'Week 2', title: 'Design', status: 'complete'},
386
+ {date: 'Week 3', title: 'Build MVP', status: 'current'},
387
+ {date: 'Week 4', title: 'Launch', status: 'pending'}
388
+ ], {title: 'Project Timeline'});
389
+
390
+ pi.metrics('#metrics', [
391
+ {label: 'Latency (p99)', value: '42ms', change: '-12%', trend: 'down'},
392
+ {label: 'Throughput', value: '12.4k rps', change: '+8%', trend: 'up'},
393
+ {label: 'Error Rate', value: '0.02%', change: '-0.01%', trend: 'down'},
394
+ {label: 'Uptime', value: '99.97%', change: '+0.02%', trend: 'up'}
395
+ ], {title: 'System Metrics', columns: 2});
396
+ </script>
397
+
398
+ <p style="color: hsl(var(--muted-foreground)); font-size: 0.875rem; line-height: 1.5; margin-top: 1rem;">
399
+ Try keyboard shortcuts: <strong>1-9</strong> per question · <strong>0</strong> comments · <strong>Tab</strong> next · <strong>Esc</strong> cancel · <strong>⌘Enter</strong> submit · <strong>↑↓</strong> navigate · <strong>Space</strong> toggle · theme toggle (⚙️).
400
+ </p>
401
+ </div>`,
402
+ questions: [
403
+ {
404
+ title: "Architecture",
405
+ description: "Which architecture style should we use?",
406
+ options: [
407
+ { title: "Monolith", description: "Simple, single deployable", recommended: true },
408
+ { title: "Microservices", description: "Scalable, complex" },
409
+ { title: "Serverless", description: "Event-driven, pay-per-use" }
410
+ ]
411
+ },
412
+ {
413
+ title: "Features",
414
+ description: "Select all features to implement:",
415
+ options: [
416
+ { title: "Authentication", description: "OAuth + JWT" },
417
+ { title: "Caching", description: "Redis layer" },
418
+ { title: "Rate Limiting", description: "Token bucket" },
419
+ { title: "Observability", description: "Metrics + logs" }
420
+ ],
421
+ allowMultiple: true
422
+ },
423
+ {
424
+ title: "Deployment Target",
425
+ description: "Where should we deploy?",
426
+ options: [
427
+ { title: "Vercel", description: "Edge, serverless-first" },
428
+ { title: "AWS", description: "Full control, scalable" },
429
+ { title: "Self-hosted", description: "Own the infrastructure" }
430
+ ]
431
+ },
432
+ {
433
+ title: "Notes",
434
+ description: "Any additional requirements or constraints?"
435
+ }
323
436
  ],
324
437
  allowComment: true,
438
+ allowSkip: true
325
439
  };
326
440
  default:
327
441
  return null;
328
442
  }
329
443
  }
330
444
 
445
+ const TOOL_DESCRIPTION = [
446
+ "Ask the user a question with optional multiple-choice answers.",
447
+ "Use this to gather information interactively. Ask exactly one focused question per call.",
448
+ "Before calling, gather context with tools (read/web/ref) and pass a short summary via the context field.",
449
+ "The context panel supports Mermaid diagrams (flowcharts, sequence diagrams, etc.).",
450
+ "For richer visualizations, use contextFormat: 'html' with the built-in pi charting helpers:",
451
+ " pi.table(['Feature','A','B'], [['Auth','OAuth','SAML']], {highlightColumn:1}) — comparison tables;",
452
+ " pi.barChart('#chart', [{label:'A',value:30},{label:'B',value:80}], {highlightIndex:1}) — bar charts;",
453
+ " pi.prosCons('#pc', ['Fast','Simple'], ['Expensive','Locked'], {}) — trade-offs;",
454
+ " pi.metrics('#m', [{label:'Uptime',value:'99.9%',change:'+0.1%',trend:'up'}]) — KPI cards;",
455
+ " pi.pieChart('#pie', [{label:'X',value:30},{label:'Y',value:70}], {donut:true}) — distributions;",
456
+ " pi.timeline('#t', [{date:'Q1',title:'Plan',status:'complete'},{date:'Q2',title:'Build',status:'current'}]) — roadmaps.",
457
+ "All helpers auto-theme to light/dark mode.",
458
+ ].join(" ");
459
+
331
460
  const askUserTool = defineTool({
332
461
  name: "ask_user",
333
462
  label: "Ask User",
334
- description:
335
- "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. The context panel supports Mermaid diagrams (flowcharts, sequence diagrams, etc.) — include them when visualizing architecture, flows, or relationships would aid understanding.",
463
+ description: TOOL_DESCRIPTION,
336
464
  promptSnippet:
337
465
  "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
338
466
  promptGuidelines: [
339
467
  "Always use ask_user instead of guessing when user input would improve the answer.",
340
468
  "Keep the question field short and focused (ideally one sentence). Put background, examples, or elaboration in the context field.",
341
469
  "Include Mermaid diagrams in the context field when visualizing architecture, data flows, or decision trees would help the user understand the question.",
470
+ "Use contextFormat: 'html' for rich visualizations (comparison tables, bar charts, pros/cons lists, metric cards, timelines, and layouts) that help the user understand trade-offs and make faster decisions. The iframe inherits the wrapper's CSS variables for automatic theme consistency.",
471
+ "When comparing 3+ options, render a comparison table with pi.table(headers, rows, {highlightColumn: recommendedIndex}).",
472
+ "When showing quantitative data or performance metrics, use pi.barChart() or pi.metrics() to visualize the numbers.",
473
+ "When weighing trade-offs, use pi.prosCons() to show a side-by-side comparison.",
342
474
  "Pass a concise question and, when applicable, a list of options with short titles and optional longer descriptions.",
475
+ "List options from most recommended to least recommended.",
343
476
  "Set allowMultiple: true when more than one choice is valid.",
344
477
  "Set allowFreeform: true (default) when the user might want to answer in their own words.",
345
478
  ],
346
479
  parameters: Type.Object({
347
- question: Type.String({ description: "A short, focused question (ideally one sentence). Put background detail in context." }),
480
+ question: Type.String({
481
+ description:
482
+ "A short, focused question (ideally one sentence). Put background detail in context.",
483
+ }),
348
484
  context: Type.Optional(
349
485
  Type.String({
350
486
  description:
351
- "Background, examples, or elaboration that helps the user understand the question. Shown in a side panel, so keep the question itself concise. Supports Mermaid diagrams (flowcharts, sequence diagrams, etc.) — wrap them in ```mermaid code blocks.",
487
+ "Background, examples, or elaboration that helps the user understand the question. Shown in a side panel, so keep the question itself concise. Supports Mermaid diagrams (flowcharts, sequence diagrams, etc.) — wrap them in ```mermaid code blocks. Use contextFormat: 'html' for rich visualizations with the built-in pi helpers (pi.table, pi.barChart, pi.prosCons, pi.metrics, pi.pieChart, pi.timeline).",
488
+ }),
489
+ ),
490
+ contextFormat: Type.Optional(
491
+ StringEnum(["markdown", "html"], {
492
+ description:
493
+ "Format of the context field. 'markdown' (default) renders as formatted text. 'html' renders in a sandboxed iframe with automatic light/dark theme consistency. Use HTML context for comparison tables, bar charts, pros/cons lists, metric cards, timelines, and interactive layouts that help the user understand trade-offs and decide faster.",
352
494
  }),
353
495
  ),
354
496
  options: Type.Optional(
@@ -365,6 +507,12 @@ const askUserTool = defineTool({
365
507
  "Longer description explaining this option",
366
508
  }),
367
509
  ),
510
+ recommended: Type.Optional(
511
+ Type.Boolean({
512
+ description:
513
+ "Mark this option as most recommended. Shows a badge in the dialog.",
514
+ }),
515
+ ),
368
516
  }),
369
517
  ]),
370
518
  { description: "List of options for the user to choose from" },
@@ -391,6 +539,12 @@ const askUserTool = defineTool({
391
539
  description: "Option description",
392
540
  }),
393
541
  ),
542
+ recommended: Type.Optional(
543
+ Type.Boolean({
544
+ description:
545
+ "Mark this option as most recommended. Shows a badge in the dialog.",
546
+ }),
547
+ ),
394
548
  }),
395
549
  {
396
550
  description:
@@ -449,79 +603,86 @@ const askUserTool = defineTool({
449
603
  }),
450
604
 
451
605
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
452
- return askUserHandler(params, signal, ctx);
606
+ return runAskUserWithTheme(params as AskUserParams, signal, ctx);
453
607
  },
454
608
  });
455
609
 
456
610
  export default function (pi: ExtensionAPI) {
611
+ _pi = pi;
457
612
  pi.registerTool(askUserTool);
458
613
 
459
- // ── Auto-detect question sessions and force ask_user usage ──
614
+ /** Send a user answer back into the journal with consistent error handling. */
615
+ async function deliverAnswer(
616
+ prefix: string,
617
+ answer: string,
618
+ ctx: ExtensionContext,
619
+ ) {
620
+ try {
621
+ await pi.sendUserMessage(`${prefix}\n\n${answer}`, {
622
+ deliverAs: "steer",
623
+ });
624
+ } catch (err) {
625
+ const msg = err instanceof Error ? err.message : String(err);
626
+ console.error(
627
+ `[pi-ask-user-glimpse] sendUserMessage failed: ${msg}`,
628
+ );
629
+ ctx.ui?.notify(`Failed to send answer: ${msg}`, "error");
630
+ }
631
+ }
632
+
633
+ // ── Inject mandate based on ask style ──
460
634
  pi.on("before_agent_start", async (event, ctx) => {
461
635
  const hasAskUser =
462
636
  event.systemPromptOptions.selectedTools?.includes("ask_user");
463
637
  if (!hasAskUser) return;
464
638
 
639
+ // Don't force ask_user in headless environments — the tool can't render dialogs
640
+ if (!ctx.hasUI) return;
641
+
465
642
  const styleMode = getStyleMode(ctx.sessionManager.getEntries());
466
- const shouldInject =
467
- styleMode === true ||
468
- (styleMode === null &&
469
- isQuestionSession(
470
- event.systemPrompt,
471
- event.systemPromptOptions,
472
- ));
473
-
474
- if (shouldInject) {
475
- return { systemPrompt: event.systemPrompt + ASK_USER_MANDATE };
643
+
644
+ if (styleMode === "yolo") {
645
+ return { systemPrompt: event.systemPrompt + YOLO_MANDATE };
476
646
  }
647
+ // "plain" → no injection
477
648
  });
478
649
 
479
- // ── Manual style toggle for ask_user behavior ──
650
+ // ── Manual style toggle ──
480
651
  pi.registerCommand("ask-style", {
481
652
  description:
482
- "Cycle ask_user style: AutoAlways Dialog → Plain Text → Auto",
653
+ "Cycle ask_user style: Plain Text YOLO → Plain Text",
483
654
  handler: async (_args, ctx) => {
484
655
  const styleMode = getStyleMode(ctx.sessionManager.getEntries());
485
656
 
486
- let nextMode: boolean | null;
657
+ let nextMode: StyleMode;
487
658
  let label: string;
488
659
 
489
- if (styleMode === null) {
490
- nextMode = true;
491
- label = "Always Dialog (auto-detection overridden)";
492
- } else if (styleMode === true) {
493
- nextMode = false;
494
- label = "Plain Text (no dialog injection)";
660
+ if (styleMode === "plain") {
661
+ nextMode = "yolo";
662
+ label = "YOLO go with your recommendation";
495
663
  } else {
496
- nextMode = null;
497
- label = "Auto (skill + pattern detection)";
664
+ nextMode = "plain";
665
+ label = "Plain Text (no dialog injection)";
498
666
  }
499
667
 
500
- pi.appendEntry("ask-user-style", { enabled: nextMode });
668
+ await pi.appendEntry("ask-user-style", { mode: nextMode });
501
669
  ctx.ui.notify(`ask_user style: ${label}`, "info");
502
670
  },
503
671
  });
504
672
 
505
- pi.registerCommand("ask-last", {
673
+ pi.registerCommand("ask", {
506
674
  description:
507
675
  "Extract questions from the last assistant message and ask them via ask_user",
508
676
  handler: async (_args, ctx) => {
509
677
  if (!ctx.hasUI) {
510
678
  console.warn(
511
- "[pi-ask-user-glimpse] ask-last requires interactive mode",
679
+ "[pi-ask-user-glimpse] /ask requires interactive mode",
512
680
  );
513
681
  return;
514
682
  }
515
683
 
516
684
  const entries = ctx.sessionManager.getEntries();
517
- const lastAssistant = [...entries].reverse().find((e) => {
518
- const msg = (e as unknown as Record<string, unknown>).message;
519
- return (
520
- typeof msg === "object" &&
521
- msg !== null &&
522
- (msg as Record<string, unknown>).role === "assistant"
523
- );
524
- });
685
+ const lastAssistant = findLastAssistantEntry(entries);
525
686
 
526
687
  if (!lastAssistant) {
527
688
  ctx.ui.notify(
@@ -542,7 +703,7 @@ export default function (pi: ExtensionAPI) {
542
703
 
543
704
  const questions = extractQuestions(fullText);
544
705
 
545
- const result = await askUserHandler(
706
+ const result = await runAskUserWithTheme(
546
707
  buildAskLastParams(questions, fullText),
547
708
  undefined,
548
709
  ctx,
@@ -557,14 +718,7 @@ export default function (pi: ExtensionAPI) {
557
718
  const answer = textContent?.type === "text" ? textContent.text : "";
558
719
  if (!answer) return;
559
720
 
560
- let prefix: string;
561
- if (questions.length === 0) {
562
- prefix = "Responding to your last message:";
563
- } else {
564
- const plural = questions.length > 1 ? "s" : "";
565
- prefix = `Answering the question${plural} from your last message:`;
566
- }
567
- pi.sendUserMessage(`${prefix}\n\n${answer}`);
721
+ await deliverAnswer(answerPrefix(questions.length), answer, ctx);
568
722
  },
569
723
  });
570
724
 
@@ -583,19 +737,28 @@ export default function (pi: ExtensionAPI) {
583
737
  "multi-select",
584
738
  "freeform",
585
739
  "questionnaire",
586
- "long-question",
587
- "mermaid",
740
+ "kitchen-sink",
588
741
  ]);
589
742
  if (!mode) return;
590
743
 
591
744
  const params = buildDebugParams(mode);
592
745
  if (!params) return;
593
746
 
594
- const result = await askUserHandler(params, undefined, ctx);
747
+ const result = await runAskUserWithTheme(params, undefined, ctx);
595
748
  const textContent = result.content[0];
596
749
  const text =
597
750
  textContent.type === "text" ? textContent.text : "No response";
598
- ctx.ui.notify(`Result: ${text}`, "info");
751
+
752
+ // Render debug result in the conversation thread without triggering AI processing
753
+ _pi?.sendMessage(
754
+ {
755
+ customType: "ask-debug-result",
756
+ content: [{ type: "text", text: `[debug] ${mode} → ${text}` }],
757
+ display: true,
758
+ },
759
+ { triggerTurn: false },
760
+ );
599
761
  },
600
762
  });
763
+
601
764
  }