@alexleekt/pi-ask-user-glimpse 0.3.2 → 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
  }
@@ -449,11 +562,12 @@ const askUserTool = defineTool({
449
562
  }),
450
563
 
451
564
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
452
- return askUserHandler(params, signal, ctx);
565
+ return runAskUserWithTheme(params, signal, ctx);
453
566
  },
454
567
  });
455
568
 
456
569
  export default function (pi: ExtensionAPI) {
570
+ _pi = pi;
457
571
  pi.registerTool(askUserTool);
458
572
 
459
573
  // ── Auto-detect question sessions and force ask_user usage ──
@@ -502,26 +616,19 @@ export default function (pi: ExtensionAPI) {
502
616
  },
503
617
  });
504
618
 
505
- pi.registerCommand("ask-last", {
619
+ pi.registerCommand("ask", {
506
620
  description:
507
621
  "Extract questions from the last assistant message and ask them via ask_user",
508
622
  handler: async (_args, ctx) => {
509
623
  if (!ctx.hasUI) {
510
624
  console.warn(
511
- "[pi-ask-user-glimpse] ask-last requires interactive mode",
625
+ "[pi-ask-user-glimpse] /ask requires interactive mode",
512
626
  );
513
627
  return;
514
628
  }
515
629
 
516
630
  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
- });
631
+ const lastAssistant = findLastAssistantEntry(entries);
525
632
 
526
633
  if (!lastAssistant) {
527
634
  ctx.ui.notify(
@@ -542,7 +649,7 @@ export default function (pi: ExtensionAPI) {
542
649
 
543
650
  const questions = extractQuestions(fullText);
544
651
 
545
- const result = await askUserHandler(
652
+ const result = await runAskUserWithTheme(
546
653
  buildAskLastParams(questions, fullText),
547
654
  undefined,
548
655
  ctx,
@@ -591,7 +698,7 @@ export default function (pi: ExtensionAPI) {
591
698
  const params = buildDebugParams(mode);
592
699
  if (!params) return;
593
700
 
594
- const result = await askUserHandler(params, undefined, ctx);
701
+ const result = await runAskUserWithTheme(params, undefined, ctx);
595
702
  const textContent = result.content[0];
596
703
  const text =
597
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.2",
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 {
package/tool/ask-user.ts CHANGED
@@ -5,299 +5,13 @@ import { fileURLToPath } from "node:url";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { prompt } from "glimpseui";
7
7
  import { terminalPrompt } from "../fallback/terminal-prompt.js";
8
- import type { AskUserPayload, Question } from "../shared/ask-user.js";
8
+ import type { AnimationLevel, AskUserPayload, Question, ThemeMode } from "../shared/ask-user.js";
9
9
  import { formatResponse } from "./response-formatter.js";
10
10
 
11
11
  const _require = createRequire(import.meta.url);
12
12
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
13
 
14
- /** ~100 common English stopwords for title extraction. */
15
- const STOPWORDS = new Set([
16
- "a",
17
- "an",
18
- "the",
19
- "is",
20
- "are",
21
- "was",
22
- "were",
23
- "be",
24
- "been",
25
- "being",
26
- "have",
27
- "has",
28
- "had",
29
- "do",
30
- "does",
31
- "did",
32
- "will",
33
- "would",
34
- "could",
35
- "should",
36
- "may",
37
- "might",
38
- "must",
39
- "shall",
40
- "can",
41
- "need",
42
- "ought",
43
- "used",
44
- "to",
45
- "of",
46
- "in",
47
- "for",
48
- "on",
49
- "with",
50
- "at",
51
- "by",
52
- "from",
53
- "as",
54
- "into",
55
- "through",
56
- "during",
57
- "before",
58
- "after",
59
- "above",
60
- "below",
61
- "between",
62
- "under",
63
- "again",
64
- "further",
65
- "then",
66
- "once",
67
- "here",
68
- "there",
69
- "when",
70
- "where",
71
- "why",
72
- "how",
73
- "all",
74
- "each",
75
- "few",
76
- "more",
77
- "most",
78
- "other",
79
- "some",
80
- "such",
81
- "no",
82
- "nor",
83
- "not",
84
- "only",
85
- "own",
86
- "same",
87
- "so",
88
- "than",
89
- "too",
90
- "very",
91
- "just",
92
- "and",
93
- "but",
94
- "if",
95
- "or",
96
- "because",
97
- "until",
98
- "while",
99
- "which",
100
- "what",
101
- "who",
102
- "whom",
103
- "this",
104
- "that",
105
- "these",
106
- "those",
107
- "am",
108
- "it",
109
- "its",
110
- "we",
111
- "our",
112
- "you",
113
- "your",
114
- "they",
115
- "their",
116
- "them",
117
- "he",
118
- "him",
119
- "his",
120
- "she",
121
- "her",
122
- "i",
123
- "me",
124
- "my",
125
- "mine",
126
- "us",
127
- "any",
128
- "both",
129
- "either",
130
- "neither",
131
- "one",
132
- "two",
133
- "first",
134
- "last",
135
- "another",
136
- "every",
137
- "many",
138
- "much",
139
- "several",
140
- "let",
141
- "new",
142
- "use",
143
- "using",
144
- "make",
145
- "made",
146
- "get",
147
- "got",
148
- "go",
149
- "going",
150
- "want",
151
- "wanted",
152
- "like",
153
- "liked",
154
- "know",
155
- "knew",
156
- "known",
157
- "think",
158
- "thought",
159
- "see",
160
- "saw",
161
- "seen",
162
- "come",
163
- "came",
164
- "give",
165
- "gave",
166
- "given",
167
- "take",
168
- "took",
169
- "taken",
170
- "find",
171
- "found",
172
- "say",
173
- "said",
174
- "tell",
175
- "told",
176
- "ask",
177
- "asked",
178
- "work",
179
- "worked",
180
- "seem",
181
- "seemed",
182
- "feel",
183
- "felt",
184
- "try",
185
- "tried",
186
- "leave",
187
- "left",
188
- "call",
189
- "called",
190
- "good",
191
- "well",
192
- "better",
193
- "best",
194
- "bad",
195
- "worse",
196
- "worst",
197
- "old",
198
- "long",
199
- "great",
200
- "little",
201
- "right",
202
- "left",
203
- "big",
204
- "high",
205
- "different",
206
- "important",
207
- "same",
208
- "able",
209
- "next",
210
- "early",
211
- "young",
212
- "public",
213
- "free",
214
- "real",
215
- "easy",
216
- "clear",
217
- "recent",
218
- "local",
219
- "social",
220
- "full",
221
- "small",
222
- "large",
223
- "possible",
224
- "particular",
225
- "available",
226
- "special",
227
- "certain",
228
- "personal",
229
- "open",
230
- "general",
231
- "enough",
232
- "probably",
233
- "actually",
234
- "especially",
235
- "finally",
236
- "usually",
237
- "perhaps",
238
- "almost",
239
- "simply",
240
- "quickly",
241
- "recently",
242
- "already",
243
- "eventually",
244
- "suddenly",
245
- "certainly",
246
- "definitely",
247
- "absolutely",
248
- "completely",
249
- "totally",
250
- "entirely",
251
- "exactly",
252
- "specifically",
253
- "particularly",
254
- "especially",
255
- "mainly",
256
- "mostly",
257
- "partly",
258
- "fully",
259
- "nearly",
260
- "quite",
261
- "rather",
262
- "pretty",
263
- "fairly",
264
- "really",
265
- "even",
266
- "still",
267
- "yet",
268
- "ever",
269
- "never",
270
- "always",
271
- "sometimes",
272
- "often",
273
- "usually",
274
- "frequently",
275
- "rarely",
276
- "generally",
277
- "typically",
278
- "normally",
279
- "largely",
280
- "potentially",
281
- "theoretically",
282
- "practically",
283
- "basically",
284
- "essentially",
285
- "fundamentally",
286
- "primarily",
287
- "chiefly",
288
- "principally",
289
- "partially",
290
- "half",
291
- "quarter",
292
- "double",
293
- "single",
294
- "multiple",
295
- "various",
296
- "hundred",
297
- "thousand",
298
- "million",
299
- "billion",
300
- ]);
14
+ import { STOPWORDS } from "../constants/stopwords.js";
301
15
 
302
16
  /** Extract a short title from a question by removing stopwords.
303
17
  * Falls back to first 5 words if nothing meaningful remains.
@@ -356,12 +70,20 @@ export interface AskUserParams {
356
70
  allowSkip?: boolean;
357
71
  displayMode?: string;
358
72
  followCursor?: boolean;
73
+ theme?: ThemeMode;
74
+ animationLevel?: AnimationLevel;
75
+ }
76
+
77
+ export interface AskUserMetadata {
78
+ theme?: string;
79
+ animationLevel?: string;
359
80
  }
360
81
 
361
82
  export async function askUserHandler(
362
83
  params: AskUserParams,
363
84
  signal: AbortSignal | undefined,
364
85
  ctx: ExtensionContext,
86
+ onMetadata?: (metadata: AskUserMetadata) => void,
365
87
  ) {
366
88
  if (signal?.aborted) {
367
89
  return {
@@ -419,6 +141,9 @@ export async function askUserHandler(
419
141
  allowFreeform,
420
142
  allowComment,
421
143
  allowSkip: params.allowSkip,
144
+ sessionName: ctx.sessionManager.getSessionName(),
145
+ theme: params.theme,
146
+ animationLevel: params.animationLevel,
422
147
  };
423
148
 
424
149
  let result: Record<string, unknown> | null = null;
@@ -435,10 +160,16 @@ export async function askUserHandler(
435
160
  .replace(/&/g, "\\u0026"),
436
161
  );
437
162
 
163
+ const sessionName = ctx.sessionManager.getSessionName();
164
+ const questionTitle = summarizeTitle(params.question);
165
+ const title = sessionName
166
+ ? `Pi · ${sessionName} · ${questionTitle}`
167
+ : `Pi · ${questionTitle}`;
168
+
438
169
  const options: Record<string, unknown> = {
439
170
  width: 1200,
440
171
  height: 900,
441
- title: summarizeTitle(params.question),
172
+ title: title.length > 60 ? `${title.slice(0, 57)}…` : title,
442
173
  };
443
174
 
444
175
  if (params.followCursor) {
@@ -452,6 +183,11 @@ export async function askUserHandler(
452
183
  if (result === null || result?.__cancelled === true) {
453
184
  cancelled = true;
454
185
  result = null;
186
+ } else if (result && onMetadata) {
187
+ onMetadata({
188
+ theme: result.__theme as string | undefined,
189
+ animationLevel: result.__animationLevel as string | undefined,
190
+ });
455
191
  }
456
192
  } catch (err) {
457
193
  // Glimpse unavailable — fall back to terminal prompt