@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/index.ts CHANGED
@@ -3,108 +3,106 @@
3
3
  */
4
4
 
5
5
  import {
6
+ isCustomEntry,
6
7
  type CustomJournalEntry,
7
- isCustomEntry as _isCustomEntry,
8
8
  } from "@alexleekt/pi-shared/types";
9
9
 
10
- /* ── Defensive: isCustomEntry may resolve to undefined in some jiti cache states ── */
11
- const isCustomEntry: typeof _isCustomEntry =
12
- typeof _isCustomEntry === "function"
13
- ? _isCustomEntry
14
- : (e: unknown): e is CustomJournalEntry => {
15
- if (!e || typeof e !== "object") return false;
16
- const entry = e as Record<string, unknown>;
17
- return (
18
- entry.type === "custom" &&
19
- typeof entry.customType === "string" &&
20
- typeof entry.data === "object" &&
21
- entry.data !== null
22
- );
23
- };
24
10
  import { StringEnum, Type } from "@earendil-works/pi-ai";
25
11
  import type {
26
- BuildSystemPromptOptions,
27
12
  ExtensionAPI,
28
13
  ExtensionContext,
29
14
  } from "@earendil-works/pi-coding-agent";
30
15
  import { defineTool } from "@earendil-works/pi-coding-agent";
31
- import { type AskUserParams, askUserHandler } from "./tool/ask-user.js";
32
- import type { AnimationLevel, ThemeMode } from "./shared/ask-user.js";
33
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";
34
23
 
35
24
  /* ── Module-level reference to ExtensionAPI for tool execute closure ── */
36
25
  let _pi: ExtensionAPI | undefined;
37
26
 
38
- /* ── Generic question-session detection ── */
39
-
40
- const QUESTION_SESSION_PATTERNS = [
41
- /ask the questions? one at a time/i,
42
- /interview me/i,
43
- /grilling session/i,
44
- /ask questions? (one at a time|sequentially|individually)/i,
45
- /wait for feedback/i,
46
- /questionnaire mode/i,
47
- /one question per call/i,
48
- ];
27
+ /* ── Style constants ── */
49
28
 
50
- const QUESTION_SKILL_NAMES = new Set([
51
- "grill-with-docs",
52
- "questionnaire",
53
- "interview",
54
- "grill",
55
- ]);
29
+ const YOLO_MANDATE = `
30
+ ## Tool Usage Mandate — Auto-injected by pi-ask-user-glimpse (YOLO Style)
56
31
 
57
- const ASK_USER_MANDATE = `
58
- ## Tool Usage Mandate Auto-injected by pi-ask-user-glimpse
32
+ You are in YOLO style. Do NOT ask the user for input or confirmation.
33
+ Go with your best recommendation and proceed immediately.
59
34
 
60
- When you need to ask the user a question, you MUST use the \`ask_user\`
61
- tool. Do NOT write questions as free-form assistant text. Each question
62
- should be a separate \`ask_user\` tool call.
35
+ Only use \`ask_user\` if the action would cause irreversible harm,
36
+ data loss, security compromise, or violate explicit hard constraints.
63
37
 
64
38
  Rules:
65
- - One question per \`ask_user\` call.
66
- - Provide concise options when the question has discrete choices.
67
- - Set \`allowMultiple: true\` when more than one choice is valid.
68
- - Set \`allowFreeform: true\` when the user might want to answer in their own words.
69
- - 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.
70
42
  `;
71
43
 
72
- function isQuestionSession(
73
- systemPrompt: string,
74
- options: BuildSystemPromptOptions,
75
- ): boolean {
76
- const hasQuestionSkill = !!options.skills?.some((s) =>
77
- QUESTION_SKILL_NAMES.has(s.name.toLowerCase()),
78
- );
79
- const hasQuestionLanguage = QUESTION_SESSION_PATTERNS.some((p) =>
80
- p.test(systemPrompt),
81
- );
82
- return hasQuestionSkill || hasQuestionLanguage;
83
- }
44
+ type StyleMode = "plain" | "yolo";
84
45
 
85
- function getStyleMode(entries: unknown[]): boolean | null {
46
+ function findCustomData(
47
+ entries: unknown[],
48
+ customType: string,
49
+ ): Record<string, unknown> | undefined {
86
50
  const entry = entries.find(
87
51
  (e): e is CustomJournalEntry =>
88
- isCustomEntry(e) && e.customType === "ask-user-style",
52
+ isCustomEntry(e) && e.customType === customType,
89
53
  );
90
- const enabled = entry?.data?.enabled;
91
- return typeof enabled === "boolean" ? enabled : null;
54
+ return entry?.data as Record<string, unknown> | undefined;
92
55
  }
93
56
 
94
- function getThemeSettings(entries: unknown[]): { theme?: ThemeMode; animationLevel?: AnimationLevel } {
95
- const entry = entries.find(
96
- (e): e is CustomJournalEntry =>
97
- isCustomEntry(e) && e.customType === "ask-user-theme",
98
- );
99
- const data = entry?.data as Record<string, unknown> | undefined;
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");
100
71
  const theme = typeof data?.theme === "string" ? data.theme : undefined;
101
- const animationLevel = typeof data?.animationLevel === "string" ? data.animationLevel : undefined;
72
+ const animationLevel =
73
+ typeof data?.animationLevel === "string"
74
+ ? data.animationLevel
75
+ : undefined;
102
76
  return {
103
- theme: theme === "light" || theme === "dark" || theme === "system" ? theme : undefined,
104
- animationLevel: animationLevel === "none" || animationLevel === "minimal" || animationLevel === "all" ? animationLevel : undefined,
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,
105
87
  };
106
88
  }
107
89
 
90
+ /** Extract text blocks from a content array (journal entry). */
91
+ function extractTextFromContent(content: unknown): string {
92
+ if (typeof content === "string") return content;
93
+ if (!Array.isArray(content)) return "";
94
+ return content
95
+ .filter(
96
+ (c): c is { type: string; 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",
101
+ )
102
+ .map((c) => c.text)
103
+ .join("\n");
104
+ }
105
+
108
106
  /* ── Shared helpers for consistent ask_user UX across all entry points ── */
109
107
 
110
108
  /** Enrich raw ask_user params with persisted theme/animation settings. */
@@ -116,16 +114,22 @@ function enrichWithThemeSettings(
116
114
  return { ...params, theme, animationLevel };
117
115
  }
118
116
 
119
- /** Build a metadata saver that writes theme changes back to the session journal. */
120
- function createThemeSaver(): (metadata: import("./tool/ask-user.js").AskUserMetadata) => void {
121
- return (metadata) => {
122
- if ((metadata.theme || metadata.animationLevel) && _pi) {
123
- _pi.appendEntry("ask-user-theme", {
124
- theme: metadata.theme,
125
- animationLevel: metadata.animationLevel,
126
- });
127
- }
128
- };
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();
129
133
  }
130
134
 
131
135
  /** Execute ask_user with full enrichment + persistence, used by tool and commands alike. */
@@ -136,97 +140,38 @@ async function runAskUserWithTheme(
136
140
  ): Promise<ReturnType<typeof askUserHandler>> {
137
141
  const entries = ctx.sessionManager.getEntries();
138
142
  const params = enrichWithThemeSettings(rawParams, entries);
139
- const saveTheme = createThemeSaver();
140
- let metadata: import("./tool/ask-user.js").AskUserMetadata = {};
141
-
142
- // Capture the agent's preceding message as additional context
143
- const preamble = buildAgentPreamble(params, entries);
144
- const enrichedParams: AskUserParams = preamble
145
- ? {
146
- ...params,
147
- context: params.context
148
- ? `${preamble}\n\n---\n\n${params.context}`
149
- : preamble,
150
- }
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) }
151
148
  : params;
152
149
 
153
- const result = await askUserHandler(enrichedParams, signal, ctx, (m) => {
150
+ const result = await askUserHandler(cleanedParams, signal, ctx, (m) => {
154
151
  metadata = m;
155
152
  });
156
- saveTheme(metadata);
153
+ saveThemeMetadata(metadata);
157
154
  return result;
158
155
  }
159
156
 
160
157
  /** Extract plain text from a Pi journal assistant entry. */
161
158
  function extractTextFromAssistantEntry(entry: unknown): string {
162
159
  if (!entry || typeof entry !== "object") return "";
163
- const e = entry as Record<string, unknown>;
164
- const message = e.message;
165
- if (!message || typeof message !== "object") return "";
166
- const msg = message as Record<string, unknown>;
167
-
168
- const content = msg.content;
169
- if (typeof content === "string") return content;
170
- if (!Array.isArray(content)) return "";
171
-
172
- return content
173
- .filter(
174
- (c): c is { type: string; text: string } =>
175
- typeof c === "object" &&
176
- c !== null &&
177
- typeof (c as Record<string, unknown>).type === "string" &&
178
- typeof (c as Record<string, unknown>).text === "string",
179
- )
180
- .map((c) => c.text)
181
- .join("\n");
160
+ const content = ((entry as Record<string, unknown>).message as Record<string, unknown> | undefined)?.content;
161
+ return extractTextFromContent(content);
182
162
  }
183
163
 
184
164
  /** Find the most recent assistant entry in the session journal. */
185
165
  function findLastAssistantEntry(entries: unknown[]): unknown | undefined {
186
166
  return [...entries].reverse().find((e) => {
187
167
  if (!e || typeof e !== "object") return false;
188
- const entry = e as unknown as Record<string, unknown>;
189
- const msg = entry.message;
190
- if (!msg || typeof msg !== "object" || msg === null) return false;
168
+ const msg = (e as Record<string, unknown>).message;
169
+ if (!msg || typeof msg !== "object") return false;
191
170
  return (msg as Record<string, unknown>).role === "assistant";
192
171
  });
193
172
  }
194
173
 
195
- /**
196
- * Extract the agent's introductory text from the most recent assistant
197
- * message, excluding text that is already duplicated in the question or
198
- * context fields.
199
- */
200
- function buildAgentPreamble(
201
- params: AskUserParams,
202
- entries: unknown[],
203
- ): string | undefined {
204
- const lastAssistant = findLastAssistantEntry(entries);
205
- if (!lastAssistant) return undefined;
206
-
207
- const text = extractTextFromAssistantEntry(lastAssistant).trim();
208
- if (!text) return undefined;
209
-
210
- const question = params.question.trim();
211
-
212
- // Skip if the assistant text is just the question itself
213
- if (text === question) return undefined;
214
-
215
- // If the text ends with the question, take only the prefix
216
- if (text.endsWith(question)) {
217
- const prefix = text.slice(0, text.length - question.length).trim();
218
- return prefix || undefined;
219
- }
220
-
221
- // Skip if the assistant text is already fully contained in the context
222
- if (params.context?.trim().includes(text)) return undefined;
223
-
224
- return text;
225
- }
226
-
227
- /* ── /ask: extract questions & implicit requests ── */
228
-
229
-
174
+ /* ── /ask: extract explicit questions only ── */
230
175
 
231
176
  function splitSentences(text: string): string[] {
232
177
  const PLACEHOLDER = "\x00";
@@ -255,19 +200,6 @@ function splitSentences(text: string): string[] {
255
200
  .filter((s) => s.length > 0);
256
201
  }
257
202
 
258
- const IMPLICIT_REQUEST_PATTERNS = [
259
- /\b(let me know|let us know)\b/i,
260
- /\b(tell me|tell us)\b/i,
261
- /\b(share your|share any)\b/i,
262
- /\b(what do you think|what are your thoughts)\b/i,
263
- /\b(which\b.*\b(would you|do you|should we)\b)/i,
264
- /\b(should we|can you confirm|please confirm|could you confirm)\b/i,
265
- /\b(i need your (input|feedback|thoughts|opinion))\b/i,
266
- /\b(your (thoughts|opinion|preference|feedback))\b/i,
267
- /\b(please provide|could you provide|can you provide)\b/i,
268
- /\b(would you like|do you want|do you prefer)\b/i,
269
- ];
270
-
271
203
  function hasQuotedQuestion(sentence: string): boolean {
272
204
  return /["'`].*\?.*["'`]/.test(sentence) && !sentence.endsWith("?");
273
205
  }
@@ -276,25 +208,17 @@ function looksLikeTernary(sentence: string): boolean {
276
208
  return /\?\s*[:;]/.test(sentence) || /=\s*\S+\s*\?/.test(sentence);
277
209
  }
278
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. */
279
214
  function extractQuestions(text: string): string[] {
280
- const explicit: string[] = [];
281
- const implicit: string[] = [];
282
-
283
- for (const sentence of splitSentences(text)) {
284
- if (sentence.endsWith("?")) {
285
- if (hasQuotedQuestion(sentence)) continue;
286
- if (looksLikeTernary(sentence)) continue;
287
- if (sentence.length < 3) continue;
288
- explicit.push(sentence);
289
- continue;
290
- }
291
-
292
- if (IMPLICIT_REQUEST_PATTERNS.some((p) => p.test(sentence))) {
293
- implicit.push(sentence);
294
- }
295
- }
296
-
297
- 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
+ });
298
222
  }
299
223
 
300
224
  function truncate(str: string, max: number): string {
@@ -305,23 +229,24 @@ function buildAskLastParams(
305
229
  questions: string[],
306
230
  fullText: string,
307
231
  ): AskUserParams {
232
+ const cleanContext = stripThinkingBlocks(fullText);
308
233
  if (questions.length === 0) {
309
234
  return {
310
235
  question: "The assistant would like your input on the following:",
311
- context: fullText,
236
+ context: cleanContext,
312
237
  allowFreeform: true,
313
238
  };
314
239
  }
315
240
  if (questions.length === 1) {
316
241
  return {
317
242
  question: questions[0],
318
- context: fullText,
243
+ context: cleanContext,
319
244
  allowFreeform: true,
320
245
  };
321
246
  }
322
247
  return {
323
248
  question: "The assistant asked multiple questions",
324
- context: fullText,
249
+ context: cleanContext,
325
250
  questions: questions.map((q) => ({
326
251
  title: truncate(q, 60),
327
252
  description: q,
@@ -331,14 +256,23 @@ function buildAskLastParams(
331
256
  };
332
257
  }
333
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
+
334
268
  function buildDebugParams(mode: string): AskUserParams | null {
335
269
  switch (mode) {
336
270
  case "single-select":
337
271
  return {
338
272
  question: "Test: Single Select",
339
- 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.",
340
274
  options: [
341
- { title: "Option A", description: "Description for A" },
275
+ { title: "Option A", description: "Description for A", recommended: true },
342
276
  { title: "Option B", description: "Description for B" },
343
277
  { title: "Option C", description: "Description for C" },
344
278
  ],
@@ -349,9 +283,9 @@ function buildDebugParams(mode: string): AskUserParams | null {
349
283
  return {
350
284
  question: "Test: Multi Select",
351
285
  context:
352
- "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.",
353
287
  options: [
354
- { title: "Feature X", description: "Enable feature X" },
288
+ { title: "Feature X", description: "Enable feature X", recommended: true },
355
289
  { title: "Feature Y", description: "Enable feature Y" },
356
290
  { title: "Feature Z", description: "Enable feature Z" },
357
291
  ],
@@ -362,13 +296,13 @@ function buildDebugParams(mode: string): AskUserParams | null {
362
296
  case "freeform":
363
297
  return {
364
298
  question: "Test: Freeform",
365
- context: "Type any answer you like",
299
+ context: "Type any answer you like. This tests the textarea, character counter, and platform-aware keyboard shortcuts.",
366
300
  allowFreeform: true,
367
301
  };
368
302
  case "questionnaire":
369
303
  return {
370
304
  question: "Test: Questionnaire",
371
- 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.",
372
306
  questions: [
373
307
  {
374
308
  title: "Database",
@@ -377,6 +311,7 @@ function buildDebugParams(mode: string): AskUserParams | null {
377
311
  {
378
312
  title: "PostgreSQL",
379
313
  description: "Relational, proven",
314
+ recommended: true,
380
315
  },
381
316
  { title: "SQLite", description: "Zero-config" },
382
317
  ],
@@ -385,7 +320,7 @@ function buildDebugParams(mode: string): AskUserParams | null {
385
320
  title: "Architecture",
386
321
  description: "Preferred style?",
387
322
  options: [
388
- { title: "Monolith", description: "Simple" },
323
+ { title: "Monolith", description: "Simple", recommended: true },
389
324
  { title: "Microservices", description: "Scalable" },
390
325
  ],
391
326
  allowMultiple: true,
@@ -397,71 +332,165 @@ function buildDebugParams(mode: string): AskUserParams | null {
397
332
  ],
398
333
  allowComment: true,
399
334
  };
400
- case "long-question":
335
+ case "kitchen-sink":
401
336
  return {
402
- 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.",
403
- options: [
404
- { title: "Split worked", description: "Title is short, context has the rest" },
405
- { title: "Not split", description: "Everything is still in the title" },
406
- ],
407
- allowComment: true,
408
- };
409
- case "mermaid":
410
- return {
411
- question: "Test: Mermaid Diagrams",
412
- context: `This prompt includes Mermaid diagrams to test rendering in the left context panel.
413
-
414
- \`\`\`mermaid
415
- graph TD
416
- A[User asks question] --> B{Has context?}
417
- B -->|Yes| C[Show left panel]
418
- B -->|No| D[Single panel]
419
- C --> E[Render markdown + diagrams]
420
- D --> E
421
- \`\`\`
422
-
423
- The diagram above should render as an SVG. Below is a sequence diagram:
424
-
425
- \`\`\`mermaid
426
- sequenceDiagram
427
- participant Agent
428
- participant User
429
- Agent->>User: Ask question
430
- User->>Agent: Submit answer
431
- \`\`\`
432
- `,
433
- options: [
434
- { title: "Looks good", description: "Diagrams render correctly" },
435
- { 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
+ }
436
436
  ],
437
437
  allowComment: true,
438
+ allowSkip: true
438
439
  };
439
440
  default:
440
441
  return null;
441
442
  }
442
443
  }
443
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
+
444
460
  const askUserTool = defineTool({
445
461
  name: "ask_user",
446
462
  label: "Ask User",
447
- description:
448
- "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,
449
464
  promptSnippet:
450
465
  "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
451
466
  promptGuidelines: [
452
467
  "Always use ask_user instead of guessing when user input would improve the answer.",
453
468
  "Keep the question field short and focused (ideally one sentence). Put background, examples, or elaboration in the context field.",
454
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.",
455
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.",
456
476
  "Set allowMultiple: true when more than one choice is valid.",
457
477
  "Set allowFreeform: true (default) when the user might want to answer in their own words.",
458
478
  ],
459
479
  parameters: Type.Object({
460
- 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
+ }),
461
484
  context: Type.Optional(
462
485
  Type.String({
463
486
  description:
464
- "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.",
465
494
  }),
466
495
  ),
467
496
  options: Type.Optional(
@@ -478,6 +507,12 @@ const askUserTool = defineTool({
478
507
  "Longer description explaining this option",
479
508
  }),
480
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
+ ),
481
516
  }),
482
517
  ]),
483
518
  { description: "List of options for the user to choose from" },
@@ -504,6 +539,12 @@ const askUserTool = defineTool({
504
539
  description: "Option description",
505
540
  }),
506
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
+ ),
507
548
  }),
508
549
  {
509
550
  description:
@@ -562,7 +603,7 @@ const askUserTool = defineTool({
562
603
  }),
563
604
 
564
605
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
565
- return runAskUserWithTheme(params, signal, ctx);
606
+ return runAskUserWithTheme(params as AskUserParams, signal, ctx);
566
607
  },
567
608
  });
568
609
 
@@ -570,48 +611,61 @@ export default function (pi: ExtensionAPI) {
570
611
  _pi = pi;
571
612
  pi.registerTool(askUserTool);
572
613
 
573
- // ── 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 ──
574
634
  pi.on("before_agent_start", async (event, ctx) => {
575
635
  const hasAskUser =
576
636
  event.systemPromptOptions.selectedTools?.includes("ask_user");
577
637
  if (!hasAskUser) return;
578
638
 
639
+ // Don't force ask_user in headless environments — the tool can't render dialogs
640
+ if (!ctx.hasUI) return;
641
+
579
642
  const styleMode = getStyleMode(ctx.sessionManager.getEntries());
580
- const shouldInject =
581
- styleMode === true ||
582
- (styleMode === null &&
583
- isQuestionSession(
584
- event.systemPrompt,
585
- event.systemPromptOptions,
586
- ));
587
-
588
- if (shouldInject) {
589
- return { systemPrompt: event.systemPrompt + ASK_USER_MANDATE };
643
+
644
+ if (styleMode === "yolo") {
645
+ return { systemPrompt: event.systemPrompt + YOLO_MANDATE };
590
646
  }
647
+ // "plain" → no injection
591
648
  });
592
649
 
593
- // ── Manual style toggle for ask_user behavior ──
650
+ // ── Manual style toggle ──
594
651
  pi.registerCommand("ask-style", {
595
652
  description:
596
- "Cycle ask_user style: AutoAlways Dialog → Plain Text → Auto",
653
+ "Cycle ask_user style: Plain Text YOLO → Plain Text",
597
654
  handler: async (_args, ctx) => {
598
655
  const styleMode = getStyleMode(ctx.sessionManager.getEntries());
599
656
 
600
- let nextMode: boolean | null;
657
+ let nextMode: StyleMode;
601
658
  let label: string;
602
659
 
603
- if (styleMode === null) {
604
- nextMode = true;
605
- label = "Always Dialog (auto-detection overridden)";
606
- } else if (styleMode === true) {
607
- nextMode = false;
608
- label = "Plain Text (no dialog injection)";
660
+ if (styleMode === "plain") {
661
+ nextMode = "yolo";
662
+ label = "YOLO go with your recommendation";
609
663
  } else {
610
- nextMode = null;
611
- label = "Auto (skill + pattern detection)";
664
+ nextMode = "plain";
665
+ label = "Plain Text (no dialog injection)";
612
666
  }
613
667
 
614
- pi.appendEntry("ask-user-style", { enabled: nextMode });
668
+ await pi.appendEntry("ask-user-style", { mode: nextMode });
615
669
  ctx.ui.notify(`ask_user style: ${label}`, "info");
616
670
  },
617
671
  });
@@ -664,14 +718,7 @@ export default function (pi: ExtensionAPI) {
664
718
  const answer = textContent?.type === "text" ? textContent.text : "";
665
719
  if (!answer) return;
666
720
 
667
- let prefix: string;
668
- if (questions.length === 0) {
669
- prefix = "Responding to your last message:";
670
- } else {
671
- const plural = questions.length > 1 ? "s" : "";
672
- prefix = `Answering the question${plural} from your last message:`;
673
- }
674
- pi.sendUserMessage(`${prefix}\n\n${answer}`);
721
+ await deliverAnswer(answerPrefix(questions.length), answer, ctx);
675
722
  },
676
723
  });
677
724
 
@@ -690,8 +737,7 @@ export default function (pi: ExtensionAPI) {
690
737
  "multi-select",
691
738
  "freeform",
692
739
  "questionnaire",
693
- "long-question",
694
- "mermaid",
740
+ "kitchen-sink",
695
741
  ]);
696
742
  if (!mode) return;
697
743
 
@@ -702,7 +748,17 @@ export default function (pi: ExtensionAPI) {
702
748
  const textContent = result.content[0];
703
749
  const text =
704
750
  textContent.type === "text" ? textContent.text : "No response";
705
- 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
+ );
706
761
  },
707
762
  });
763
+
708
764
  }