@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/CHANGELOG.md +67 -0
- package/CONTRIBUTING.md +13 -4
- package/README.md +22 -14
- package/constants/abbreviations.ts +6 -0
- package/constants/stopwords.ts +42 -0
- package/dist/index.html +413 -413
- package/index.ts +171 -52
- package/package.json +4 -2
- package/shared/ask-user.ts +6 -0
- package/tool/ask-user.ts +39 -291
- package/tool/response-formatter.ts +13 -9
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
|
-
|
|
75
|
-
const
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 <
|
|
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
|
|
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: "
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
+
"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",
|
package/shared/ask-user.ts
CHANGED
|
@@ -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 {
|