@alexleekt/pi-ask-user-glimpse 0.3.1 → 0.4.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
@@ -4,15 +4,36 @@
4
4
 
5
5
  import {
6
6
  type CustomJournalEntry,
7
- isCustomEntry,
7
+ isCustomEntry as _isCustomEntry,
8
8
  } from "@alexleekt/pi-shared/types";
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
+ };
9
24
  import { StringEnum, Type } from "@earendil-works/pi-ai";
10
25
  import type {
11
26
  BuildSystemPromptOptions,
12
27
  ExtensionAPI,
28
+ ExtensionContext,
13
29
  } from "@earendil-works/pi-coding-agent";
14
30
  import { defineTool } from "@earendil-works/pi-coding-agent";
15
31
  import { type AskUserParams, askUserHandler } from "./tool/ask-user.js";
32
+ import type { AnimationLevel, ThemeMode } from "./shared/ask-user.js";
33
+ import { PROTECTED_ABBREVIATIONS } from "./constants/abbreviations.js";
34
+
35
+ /* ── Module-level reference to ExtensionAPI for tool execute closure ── */
36
+ let _pi: ExtensionAPI | undefined;
16
37
 
17
38
  /* ── Generic question-session detection ── */
18
39
 
@@ -70,50 +91,142 @@ function getStyleMode(entries: unknown[]): boolean | null {
70
91
  return typeof enabled === "boolean" ? enabled : null;
71
92
  }
72
93
 
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;
100
+ const theme = typeof data?.theme === "string" ? data.theme : undefined;
101
+ const animationLevel = typeof data?.animationLevel === "string" ? data.animationLevel : undefined;
102
+ return {
103
+ theme: theme === "light" || theme === "dark" || theme === "system" ? theme : undefined,
104
+ animationLevel: animationLevel === "none" || animationLevel === "minimal" || animationLevel === "all" ? animationLevel : undefined,
105
+ };
106
+ }
107
+
108
+ /* ── Shared helpers for consistent ask_user UX across all entry points ── */
109
+
110
+ /** Enrich raw ask_user params with persisted theme/animation settings. */
111
+ function enrichWithThemeSettings(
112
+ params: AskUserParams,
113
+ entries: unknown[],
114
+ ): AskUserParams {
115
+ const { theme, animationLevel } = getThemeSettings(entries);
116
+ return { ...params, theme, animationLevel };
117
+ }
118
+
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
+ };
129
+ }
130
+
131
+ /** Execute ask_user with full enrichment + persistence, used by tool and commands alike. */
132
+ async function runAskUserWithTheme(
133
+ rawParams: AskUserParams,
134
+ signal: AbortSignal | undefined,
135
+ ctx: ExtensionContext,
136
+ ): Promise<ReturnType<typeof askUserHandler>> {
137
+ const entries = ctx.sessionManager.getEntries();
138
+ 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
+ }
151
+ : params;
152
+
153
+ const result = await askUserHandler(enrichedParams, signal, ctx, (m) => {
154
+ metadata = m;
155
+ });
156
+ saveTheme(metadata);
157
+ return result;
158
+ }
159
+
160
+ /** Extract plain text from a Pi journal assistant entry. */
73
161
  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;
162
+ 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;
76
169
  if (typeof content === "string") return content;
77
170
  if (!Array.isArray(content)) return "";
171
+
78
172
  return content
79
173
  .filter(
80
174
  (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",
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",
83
179
  )
84
180
  .map((c) => c.text)
85
181
  .join("\n");
86
182
  }
87
183
 
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
- ]);
184
+ /** Find the most recent assistant entry in the session journal. */
185
+ function findLastAssistantEntry(entries: unknown[]): unknown | undefined {
186
+ return [...entries].reverse().find((e) => {
187
+ 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;
191
+ return (msg as Record<string, unknown>).role === "assistant";
192
+ });
193
+ }
194
+
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
+
117
230
 
118
231
  function splitSentences(text: string): string[] {
119
232
  const PLACEHOLDER = "\x00";
@@ -171,7 +284,7 @@ function extractQuestions(text: string): string[] {
171
284
  if (sentence.endsWith("?")) {
172
285
  if (hasQuotedQuestion(sentence)) continue;
173
286
  if (looksLikeTernary(sentence)) continue;
174
- if (sentence.length < 10) continue;
287
+ if (sentence.length < 3) continue;
175
288
  explicit.push(sentence);
176
289
  continue;
177
290
  }
@@ -284,10 +397,19 @@ function buildDebugParams(mode: string): AskUserParams | null {
284
397
  ],
285
398
  allowComment: true,
286
399
  };
400
+ case "long-question":
401
+ 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
+ };
287
409
  case "mermaid":
288
410
  return {
289
411
  question: "Test: Mermaid Diagrams",
290
- context: `This prompt includes a Mermaid diagram to test rendering.
412
+ context: `This prompt includes Mermaid diagrams to test rendering in the left context panel.
291
413
 
292
414
  \`\`\`mermaid
293
415
  graph TD
@@ -323,21 +445,23 @@ const askUserTool = defineTool({
323
445
  name: "ask_user",
324
446
  label: "Ask User",
325
447
  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.",
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.",
327
449
  promptSnippet:
328
450
  "Ask the user one focused question with optional multiple-choice answers to gather information interactively",
329
451
  promptGuidelines: [
330
452
  "Always use ask_user instead of guessing when user input would improve the answer.",
453
+ "Keep the question field short and focused (ideally one sentence). Put background, examples, or elaboration in the context field.",
454
+ "Include Mermaid diagrams in the context field when visualizing architecture, data flows, or decision trees would help the user understand the question.",
331
455
  "Pass a concise question and, when applicable, a list of options with short titles and optional longer descriptions.",
332
456
  "Set allowMultiple: true when more than one choice is valid.",
333
457
  "Set allowFreeform: true (default) when the user might want to answer in their own words.",
334
458
  ],
335
459
  parameters: Type.Object({
336
- question: Type.String({ description: "The question to ask the user" }),
460
+ question: Type.String({ description: "A short, focused question (ideally one sentence). Put background detail in context." }),
337
461
  context: Type.Optional(
338
462
  Type.String({
339
463
  description:
340
- "Additional context to help the user understand the question",
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.",
341
465
  }),
342
466
  ),
343
467
  options: Type.Optional(
@@ -438,11 +562,12 @@ const askUserTool = defineTool({
438
562
  }),
439
563
 
440
564
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
441
- return askUserHandler(params, signal, ctx);
565
+ return runAskUserWithTheme(params, signal, ctx);
442
566
  },
443
567
  });
444
568
 
445
569
  export default function (pi: ExtensionAPI) {
570
+ _pi = pi;
446
571
  pi.registerTool(askUserTool);
447
572
 
448
573
  // ── Auto-detect question sessions and force ask_user usage ──
@@ -491,26 +616,19 @@ export default function (pi: ExtensionAPI) {
491
616
  },
492
617
  });
493
618
 
494
- pi.registerCommand("ask-last", {
619
+ pi.registerCommand("ask", {
495
620
  description:
496
621
  "Extract questions from the last assistant message and ask them via ask_user",
497
622
  handler: async (_args, ctx) => {
498
623
  if (!ctx.hasUI) {
499
624
  console.warn(
500
- "[pi-ask-user-glimpse] ask-last requires interactive mode",
625
+ "[pi-ask-user-glimpse] /ask requires interactive mode",
501
626
  );
502
627
  return;
503
628
  }
504
629
 
505
630
  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
- });
631
+ const lastAssistant = findLastAssistantEntry(entries);
514
632
 
515
633
  if (!lastAssistant) {
516
634
  ctx.ui.notify(
@@ -531,7 +649,7 @@ export default function (pi: ExtensionAPI) {
531
649
 
532
650
  const questions = extractQuestions(fullText);
533
651
 
534
- const result = await askUserHandler(
652
+ const result = await runAskUserWithTheme(
535
653
  buildAskLastParams(questions, fullText),
536
654
  undefined,
537
655
  ctx,
@@ -572,6 +690,7 @@ export default function (pi: ExtensionAPI) {
572
690
  "multi-select",
573
691
  "freeform",
574
692
  "questionnaire",
693
+ "long-question",
575
694
  "mermaid",
576
695
  ]);
577
696
  if (!mode) return;
@@ -579,7 +698,7 @@ export default function (pi: ExtensionAPI) {
579
698
  const params = buildDebugParams(mode);
580
699
  if (!params) return;
581
700
 
582
- const result = await askUserHandler(params, undefined, ctx);
701
+ const result = await runAskUserWithTheme(params, undefined, ctx);
583
702
  const textContent = result.content[0];
584
703
  const text =
585
704
  textContent.type === "text" ? textContent.text : "No response";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexleekt/pi-ask-user-glimpse",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension that replaces ask_user with rich native WebView dialogs via glimpseui and shadcn/ui",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "files": [
32
32
  "index.ts",
33
+ "constants",
33
34
  "tool",
34
35
  "fallback",
35
36
  "shared",
@@ -49,7 +50,8 @@
49
50
  "prepack": "npm run build",
50
51
  "dev:webview": "npx vite --config ./webview/vite.config.ts",
51
52
  "validate": "npx tsx scripts/validate.ts",
52
- "validate:gui": "npx tsx scripts/validate.ts --gui"
53
+ "validate:gui": "npx tsx scripts/validate.ts --gui",
54
+ "test:with-context": "npx tsx scripts/test-with-context.ts"
53
55
  },
54
56
  "dependencies": {
55
57
  "@alexleekt/pi-shared": "^0.1.0",
@@ -18,6 +18,9 @@ export interface Question {
18
18
  allowMultiple?: boolean;
19
19
  }
20
20
 
21
+ export type ThemeMode = "light" | "dark" | "system";
22
+ export type AnimationLevel = "none" | "minimal" | "all";
23
+
21
24
  export interface AskUserPayload {
22
25
  type: "single-select" | "multi-select" | "questionnaire" | "freeform";
23
26
  question: string;
@@ -28,6 +31,9 @@ export interface AskUserPayload {
28
31
  allowFreeform: boolean;
29
32
  allowComment: boolean;
30
33
  allowSkip?: boolean;
34
+ sessionName?: string;
35
+ theme?: ThemeMode;
36
+ animationLevel?: AnimationLevel;
31
37
  }
32
38
 
33
39
  export interface QuestionnaireDetail {