@alexleekt/pi-ask-user-glimpse 0.2.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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared types between the server-side payload construction
3
+ * (tool/ask-user.ts) and the webview React app (webview/src/).
4
+ *
5
+ * Keep this file free of runtime dependencies so both NodeNext
6
+ * and bundler moduleResolution can consume it cleanly.
7
+ */
8
+
9
+ export interface QuestionOption {
10
+ title: string;
11
+ description?: string;
12
+ }
13
+
14
+ export interface Question {
15
+ title: string;
16
+ description?: string;
17
+ options?: QuestionOption[];
18
+ allowMultiple?: boolean;
19
+ }
20
+
21
+ export interface AskUserPayload {
22
+ type: "single-select" | "multi-select" | "questionnaire" | "freeform";
23
+ question: string;
24
+ context?: string;
25
+ options: QuestionOption[];
26
+ questions?: Question[];
27
+ allowMultiple: boolean;
28
+ allowFreeform: boolean;
29
+ allowComment: boolean;
30
+ allowSkip?: boolean;
31
+ }
32
+
33
+ export interface QuestionnaireDetail {
34
+ question: string;
35
+ answer: string;
36
+ kind: "selection" | "freeform";
37
+ comment?: string;
38
+ }
@@ -0,0 +1,201 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { prompt } from "glimpseui";
3
+ import { readFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { terminalPrompt } from "../fallback/terminal-prompt.js";
8
+ import { formatResponse } from "./response-formatter.js";
9
+ import type { AskUserPayload, Question } from "../shared/ask-user.js";
10
+
11
+ const _require = createRequire(import.meta.url);
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+
14
+ /** ~100 common English stopwords for title extraction. */
15
+ const STOPWORDS = new Set([
16
+ "a","an","the","is","are","was","were","be","been","being",
17
+ "have","has","had","do","does","did","will","would","could","should",
18
+ "may","might","must","shall","can","need","ought","used",
19
+ "to","of","in","for","on","with","at","by","from","as","into",
20
+ "through","during","before","after","above","below","between","under",
21
+ "again","further","then","once","here","there","when","where","why","how",
22
+ "all","each","few","more","most","other","some","such","no","nor","not",
23
+ "only","own","same","so","than","too","very","just","and","but","if","or",
24
+ "because","until","while","which","what","who","whom","this","that",
25
+ "these","those","am","it","its","we","our","you","your","they","their",
26
+ "them","he","him","his","she","her","i","me","my","mine","us",
27
+ "any","both","either","neither","one","two","first","last","another","every",
28
+ "many","much","several",
29
+ "let","new","use","using",
30
+ "make","made","get","got","go","going","want","wanted","like","liked",
31
+ "know","knew","known","think","thought","see","saw","seen","come","came",
32
+ "give","gave","given","take","took","taken","find","found","say","said",
33
+ "tell","told","ask","asked","work","worked","seem","seemed","feel","felt",
34
+ "try","tried","leave","left","call","called","good","well","better","best",
35
+ "bad","worse","worst","old","long","great","little","right","left","big",
36
+ "high","different","important","same","able","next","early","young",
37
+ "public","free","real","easy","clear","recent","local","social","full",
38
+ "small","large","possible","particular","available","special","certain",
39
+ "personal","open","general","enough","probably","actually","especially",
40
+ "finally","usually","perhaps","almost","simply","quickly","recently",
41
+ "already","eventually","suddenly","certainly","definitely","absolutely",
42
+ "completely","totally","entirely","exactly","specifically","particularly",
43
+ "especially","mainly","mostly","partly","fully","nearly","quite","rather",
44
+ "pretty","fairly","really","even","still","yet","ever","never","always",
45
+ "sometimes","often","usually","frequently","rarely","generally",
46
+ "typically","normally","largely","potentially","theoretically",
47
+ "practically","basically","essentially","fundamentally","primarily",
48
+ "chiefly","principally","partially","half","quarter","double","single",
49
+ "multiple","various","hundred","thousand","million","billion",
50
+ ]);
51
+
52
+ /** Extract a short title from a question by removing stopwords.
53
+ * Falls back to first 5 words if nothing meaningful remains.
54
+ */
55
+ function summarizeTitle(question: string, maxWords = 3): string {
56
+ const contentWords = question
57
+ .toLowerCase()
58
+ .replace(/[^\w\s]/g, "")
59
+ .split(/\s+/)
60
+ .filter((w) => w.length > 0 && !STOPWORDS.has(w));
61
+
62
+ if (contentWords.length === 0) {
63
+ // Nothing but stopwords — fall back to raw truncation
64
+ const words = question.trim().split(/\s+/);
65
+ return words.slice(0, 5).join(" ") + (words.length > 5 ? "…" : "");
66
+ }
67
+
68
+ const result = contentWords
69
+ .slice(0, maxWords)
70
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
71
+ .join(" ");
72
+
73
+ return contentWords.length > maxWords ? result + "…" : result;
74
+ }
75
+
76
+ function resolveWebviewHtml(): string {
77
+ const distPath = join(__dirname, "..", "dist", "index.html");
78
+ try {
79
+ return readFileSync(distPath, "utf-8");
80
+ } catch {
81
+ // Fallback for development: resolve from package root
82
+ const pkgRoot = dirname(_require.resolve("../package.json"));
83
+ const fallbackPath = join(pkgRoot, "dist", "index.html");
84
+ try {
85
+ return readFileSync(fallbackPath, "utf-8");
86
+ } catch (err) {
87
+ throw new Error(
88
+ `Could not find webview bundle. Tried:\n` +
89
+ ` 1. ${distPath}\n` +
90
+ ` 2. ${fallbackPath}\n` +
91
+ `Run 'npm run build' first to generate dist/index.html.`,
92
+ { cause: err },
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ export interface AskUserParams {
99
+ question: string;
100
+ context?: string;
101
+ options?: (string | { title: string; description?: string })[];
102
+ questions?: Question[];
103
+ allowMultiple?: boolean;
104
+ allowFreeform?: boolean;
105
+ allowComment?: boolean;
106
+ allowSkip?: boolean;
107
+ displayMode?: string;
108
+ followCursor?: boolean;
109
+ }
110
+
111
+ export async function askUserHandler(
112
+ params: AskUserParams,
113
+ signal: AbortSignal | undefined,
114
+ ctx: ExtensionContext,
115
+ ) {
116
+ if (signal?.aborted) {
117
+ return {
118
+ content: [{ type: "text" as const, text: "Cancelled" }],
119
+ details: { question: params.question, options: [], response: null, cancelled: true },
120
+ };
121
+ }
122
+
123
+ const normalizedOptions = (params.options ?? []).map((opt) => {
124
+ if (typeof opt === "string") return { title: opt };
125
+ return { title: opt.title, description: opt.description };
126
+ });
127
+
128
+ const hasOptions = normalizedOptions.length > 0;
129
+ const hasQuestions = params.questions && params.questions.length > 0;
130
+ const allowMultiple = params.allowMultiple ?? false;
131
+ const allowFreeform = params.allowFreeform ?? true;
132
+ const allowComment = params.allowComment ?? false;
133
+
134
+ let payloadType: AskUserPayload["type"];
135
+ if (hasQuestions) {
136
+ payloadType = "questionnaire";
137
+ } else if (!hasOptions) {
138
+ payloadType = "freeform";
139
+ } else if (allowMultiple) {
140
+ payloadType = "multi-select";
141
+ } else {
142
+ payloadType = "single-select";
143
+ }
144
+
145
+ const payload: AskUserPayload = {
146
+ type: payloadType,
147
+ question: params.question,
148
+ context: params.context,
149
+ options: normalizedOptions,
150
+ questions: params.questions,
151
+ allowMultiple,
152
+ allowFreeform,
153
+ allowComment,
154
+ allowSkip: params.allowSkip,
155
+ };
156
+
157
+ let result: Record<string, unknown> | null = null;
158
+ let cancelled = false;
159
+ let error: string | undefined;
160
+
161
+ try {
162
+ const baseHtml = resolveWebviewHtml();
163
+ const html = baseHtml.replace(
164
+ "/*ASK_USER_PAYLOAD*/",
165
+ JSON.stringify(payload)
166
+ .replace(/</g, "\\u003c")
167
+ .replace(/>/g, "\\u003e")
168
+ .replace(/&/g, "\\u0026"),
169
+ );
170
+
171
+ const options: Record<string, unknown> = {
172
+ width: 1200,
173
+ height: 900,
174
+ title: summarizeTitle(params.question),
175
+ };
176
+
177
+ if (params.followCursor) {
178
+ options.followCursor = true;
179
+ }
180
+
181
+ result = (await prompt(html, options)) as Record<string, unknown> | null;
182
+ if (result === null || result?.__cancelled === true) {
183
+ cancelled = true;
184
+ result = null;
185
+ }
186
+ } catch (err) {
187
+ // Glimpse unavailable — fall back to terminal prompt
188
+ const fallbackResult = await terminalPrompt(
189
+ payload,
190
+ ctx.hasUI ? ctx.ui : undefined,
191
+ );
192
+ if (fallbackResult === null) {
193
+ cancelled = true;
194
+ } else {
195
+ result = fallbackResult;
196
+ }
197
+ error = err instanceof Error ? err.message : String(err);
198
+ }
199
+
200
+ return formatResponse(params.question, normalizedOptions, result, cancelled, error);
201
+ }
@@ -0,0 +1,102 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface AskResponse {
4
+ kind: "selection" | "freeform" | "questionnaire";
5
+ selections?: string[];
6
+ comment?: string;
7
+ text?: string;
8
+ questionnaireDetails?: { question: string; answer: string; kind: "selection" | "freeform"; comment?: string }[];
9
+ }
10
+
11
+ export interface AskToolDetails {
12
+ question: string;
13
+ context?: string;
14
+ options: { title: string; description?: string }[];
15
+ response: AskResponse | null;
16
+ cancelled: boolean;
17
+ error?: string;
18
+ }
19
+
20
+ function normalizeKind(raw: unknown): AskResponse["kind"] {
21
+ if (raw === "freeform" || raw === "questionnaire") return raw;
22
+ return "selection";
23
+ }
24
+
25
+ function buildResponse(result: Record<string, unknown>, kind: AskResponse["kind"]): AskResponse {
26
+ if (kind === "freeform") {
27
+ return { kind, text: String(result.text ?? "").trim() };
28
+ }
29
+
30
+ if (kind === "questionnaire") {
31
+ return {
32
+ kind,
33
+ selections: Array.isArray(result.selections) ? result.selections.map(String) : [],
34
+ questionnaireDetails: Array.isArray(result.questionnaireDetails)
35
+ ? result.questionnaireDetails.map((d: unknown) => {
36
+ const entry = d as Record<string, unknown>;
37
+ return {
38
+ question: String(entry.question ?? ""),
39
+ answer: String(entry.answer ?? ""),
40
+ kind: entry.kind === "freeform" ? "freeform" : "selection",
41
+ comment: entry.comment ? String(entry.comment) : undefined,
42
+ };
43
+ })
44
+ : [],
45
+ };
46
+ }
47
+
48
+ const selections = Array.isArray(result.selections)
49
+ ? result.selections.map(String)
50
+ : result.selection
51
+ ? [String(result.selection)]
52
+ : [];
53
+
54
+ return {
55
+ kind,
56
+ selections,
57
+ comment: result.comment ? String(result.comment) : undefined,
58
+ };
59
+ }
60
+
61
+ function responseToText(response: AskResponse): string {
62
+ if (response.kind === "freeform") {
63
+ return response.text ?? "";
64
+ }
65
+ const selections = response.selections ?? [];
66
+ let text = selections.join(", ");
67
+ if (response.comment) {
68
+ text += `\n\nComment: ${response.comment}`;
69
+ }
70
+ return text;
71
+ }
72
+
73
+ export function formatResponse(
74
+ question: string,
75
+ options: { title: string; description?: string }[],
76
+ result: Record<string, unknown> | null,
77
+ cancelled: boolean,
78
+ error?: string,
79
+ ): AgentToolResult<AskToolDetails> {
80
+ if (cancelled) {
81
+ return {
82
+ content: [{ type: "text", text: "Cancelled" }],
83
+ details: { question, options, response: null, cancelled: true, error },
84
+ };
85
+ }
86
+
87
+ if (!result) {
88
+ return {
89
+ content: [{ type: "text", text: "No response" }],
90
+ details: { question, options, response: null, cancelled: false, error },
91
+ };
92
+ }
93
+
94
+ const kind = normalizeKind(result.kind);
95
+ const response = buildResponse(result, kind);
96
+ const text = responseToText(response);
97
+
98
+ return {
99
+ content: [{ type: "text", text }],
100
+ details: { question, options, response, cancelled: false, error },
101
+ };
102
+ }
@@ -0,0 +1,57 @@
1
+ declare module "glimpseui" {
2
+ export interface GlimpseWindowOptions {
3
+ width?: number;
4
+ height?: number;
5
+ title?: string;
6
+ frameless?: boolean;
7
+ floating?: boolean;
8
+ transparent?: boolean;
9
+ clickThrough?: boolean;
10
+ noDock?: boolean;
11
+ hidden?: boolean;
12
+ autoClose?: boolean;
13
+ openLinks?: boolean;
14
+ openLinksApp?: string;
15
+ followCursor?: boolean;
16
+ x?: number;
17
+ y?: number;
18
+ cursorOffset?: { x?: number; y?: number };
19
+ cursorAnchor?: string;
20
+ followMode?: "snap" | "spring";
21
+ timeout?: number;
22
+ }
23
+
24
+ export interface GlimpseWindow {
25
+ on(event: "message", handler: (data: unknown) => void): void;
26
+ on(event: "closed", handler: () => void): void;
27
+ on(event: "error", handler: (err: Error) => void): void;
28
+ send(js: string): void;
29
+ setHTML(html: string): void;
30
+ show(options?: { title?: string }): void;
31
+ close(): void;
32
+ loadFile(path: string): void;
33
+ getInfo(): unknown;
34
+ followCursor(enabled: boolean, anchor?: string, mode?: string): void;
35
+ readonly info: unknown;
36
+ }
37
+
38
+ export function open(html: string, options?: GlimpseWindowOptions): GlimpseWindow;
39
+ export function prompt(
40
+ html: string,
41
+ options?: GlimpseWindowOptions,
42
+ ): Promise<unknown | null>;
43
+ export function statusItem(
44
+ html: string,
45
+ options?: GlimpseWindowOptions,
46
+ ): GlimpseWindow;
47
+ export function getNativeHostInfo(): {
48
+ path: string;
49
+ platform: string;
50
+ buildHint: string;
51
+ };
52
+ export function supportsFollowCursor(): boolean;
53
+ export function getFollowCursorSupport(): {
54
+ supported: boolean;
55
+ reason?: string;
56
+ };
57
+ }