@pi-archimedes/ask 1.3.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/README.md +28 -0
- package/package.json +31 -0
- package/src/cursor.ts +20 -0
- package/src/dialog.ts +664 -0
- package/src/index.ts +442 -0
- package/src/note.ts +102 -0
- package/src/picker.ts +308 -0
- package/src/selection.ts +99 -0
- package/src/wrap.ts +33 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
4
|
+
import { connect } from "node:net";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { OTHER_OPTION, type AskQuestion, type AskSelection } from "./selection";
|
|
7
|
+
import { askSingleQuestionWithInlineNote } from "./picker";
|
|
8
|
+
import { askQuestionsWithTabs } from "./dialog";
|
|
9
|
+
|
|
10
|
+
const OptionItemSchema = Type.Object({
|
|
11
|
+
label: Type.String({ description: "Display label" }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const QuestionItemSchema = Type.Object({
|
|
15
|
+
id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
|
|
16
|
+
question: Type.String({ description: "Question text" }),
|
|
17
|
+
description: Type.Optional(
|
|
18
|
+
Type.String({
|
|
19
|
+
description:
|
|
20
|
+
"Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
options: Type.Array(OptionItemSchema, {
|
|
24
|
+
description: "Available options. Do not include 'Other'.",
|
|
25
|
+
minItems: 1,
|
|
26
|
+
}),
|
|
27
|
+
multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
|
|
28
|
+
recommended: Type.Optional(
|
|
29
|
+
Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const AskParamsSchema = Type.Object({
|
|
34
|
+
questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
type AskParams = Static<typeof AskParamsSchema>;
|
|
38
|
+
|
|
39
|
+
interface QuestionResult {
|
|
40
|
+
id: string;
|
|
41
|
+
question: string;
|
|
42
|
+
description: string | undefined;
|
|
43
|
+
options: string[];
|
|
44
|
+
multi: boolean;
|
|
45
|
+
selectedOptions: string[];
|
|
46
|
+
customInput: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AskToolDetails {
|
|
50
|
+
id?: string;
|
|
51
|
+
question?: string;
|
|
52
|
+
description: string | undefined;
|
|
53
|
+
options?: string[];
|
|
54
|
+
multi?: boolean;
|
|
55
|
+
selectedOptions?: string[];
|
|
56
|
+
customInput: string | undefined;
|
|
57
|
+
results?: QuestionResult[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sanitizeForSessionText(value: string): string {
|
|
61
|
+
return value
|
|
62
|
+
.replace(/[\r\n\t]/g, " ")
|
|
63
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
64
|
+
.replace(/\s{2,}/g, " ")
|
|
65
|
+
.trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sanitizeMultilineForSessionText(value: string): string {
|
|
69
|
+
return value
|
|
70
|
+
.replace(/\r\n/g, "\n")
|
|
71
|
+
.replace(/\r/g, "\n")
|
|
72
|
+
.split("\n")
|
|
73
|
+
.map((line) => sanitizeForSessionText(line))
|
|
74
|
+
.join("\n")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sanitizeOptionForSessionText(option: string): string {
|
|
79
|
+
const sanitizedOption = sanitizeForSessionText(option);
|
|
80
|
+
return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
|
|
84
|
+
const selectedOptions = result.selectedOptions
|
|
85
|
+
.map((selectedOption) => sanitizeForSessionText(selectedOption))
|
|
86
|
+
.filter((selectedOption) => selectedOption.length > 0);
|
|
87
|
+
|
|
88
|
+
const rawDescription = result.description;
|
|
89
|
+
const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
|
|
90
|
+
const rawCustomInput = result.customInput;
|
|
91
|
+
const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
id: sanitizeForSessionText(result.id) || "(unknown)",
|
|
95
|
+
question: sanitizeForSessionText(result.question) || "(empty question)",
|
|
96
|
+
description: description && description.length > 0 ? description : undefined,
|
|
97
|
+
options: result.options.map(sanitizeOptionForSessionText),
|
|
98
|
+
multi: result.multi,
|
|
99
|
+
selectedOptions,
|
|
100
|
+
customInput: customInput && customInput.length > 0 ? customInput : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatSelectionForSummary(result: QuestionResult): string {
|
|
105
|
+
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
106
|
+
const hasCustomInput = Boolean(result.customInput);
|
|
107
|
+
|
|
108
|
+
if (!hasSelectedOptions && !hasCustomInput) {
|
|
109
|
+
return "(cancelled)";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (hasSelectedOptions && hasCustomInput) {
|
|
113
|
+
const selectedPart = result.multi
|
|
114
|
+
? `[${result.selectedOptions.join(", ")}]`
|
|
115
|
+
: result.selectedOptions[0] ?? "";
|
|
116
|
+
return `${selectedPart} + Other: "${result.customInput}"`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (hasCustomInput) {
|
|
120
|
+
return `"${result.customInput}"`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (result.multi) {
|
|
124
|
+
return `[${result.selectedOptions.join(", ")}]`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return result.selectedOptions[0] ?? "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatQuestionResult(result: QuestionResult): string {
|
|
131
|
+
return `${result.id}: ${formatSelectionForSummary(result)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
|
|
135
|
+
const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
|
|
136
|
+
|
|
137
|
+
if (result.description) {
|
|
138
|
+
lines.push("Context:");
|
|
139
|
+
for (const descriptionLine of result.description.split("\n")) {
|
|
140
|
+
lines.push(` ${descriptionLine}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push("Options:");
|
|
145
|
+
lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
|
|
146
|
+
lines.push("Response:");
|
|
147
|
+
|
|
148
|
+
const hasSelectedOptions = result.selectedOptions.length > 0;
|
|
149
|
+
const hasCustomInput = Boolean(result.customInput);
|
|
150
|
+
|
|
151
|
+
if (!hasSelectedOptions && !hasCustomInput) {
|
|
152
|
+
lines.push(" Selected: (cancelled)");
|
|
153
|
+
return lines.join("\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (hasSelectedOptions) {
|
|
157
|
+
const selectedText = result.multi
|
|
158
|
+
? `[${result.selectedOptions.join(", ")}]`
|
|
159
|
+
: result.selectedOptions[0];
|
|
160
|
+
lines.push(` Selected: ${selectedText}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (hasCustomInput) {
|
|
164
|
+
if (!hasSelectedOptions) {
|
|
165
|
+
lines.push(` Selected: ${OTHER_OPTION}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push(` Custom input: ${result.customInput}`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildAskSessionContent(results: QuestionResult[]): string {
|
|
174
|
+
const safeResults = results.map(toSessionSafeQuestionResult);
|
|
175
|
+
const summaryLines = safeResults.map(formatQuestionResult).join("\n");
|
|
176
|
+
const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
|
|
177
|
+
return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ASK_TOOL_DESCRIPTION = `
|
|
181
|
+
Ask the user for clarification when a choice materially affects the outcome.
|
|
182
|
+
|
|
183
|
+
- Use when multiple valid approaches have different trade-offs.
|
|
184
|
+
- Prefer 2-5 concise options.
|
|
185
|
+
- Use multi=true when multiple answers are valid.
|
|
186
|
+
- Use recommended=<index> (0-indexed) to mark the default option.
|
|
187
|
+
- Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
|
|
188
|
+
- You can ask multiple related questions in one call using questions[].
|
|
189
|
+
- Do NOT include an 'Other' option; UI adds it automatically.
|
|
190
|
+
`.trim();
|
|
191
|
+
|
|
192
|
+
export function registerAsk(pi: ExtensionAPI) {
|
|
193
|
+
const unsubscribes: Array<() => void> = [];
|
|
194
|
+
let currentCtx: ExtensionContext | undefined;
|
|
195
|
+
|
|
196
|
+
// Listen for ask requests from subagents via the bus — show UI immediately
|
|
197
|
+
const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
|
|
198
|
+
const data = payload as {
|
|
199
|
+
source: string;
|
|
200
|
+
requestId: string;
|
|
201
|
+
questions: AskQuestion[];
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (!data.questions || data.questions.length === 0) return;
|
|
205
|
+
|
|
206
|
+
// Defer to next tick so TUI can process current state
|
|
207
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
208
|
+
await handleAskRequest(data);
|
|
209
|
+
});
|
|
210
|
+
unsubscribes.push(unsubAskRequest);
|
|
211
|
+
|
|
212
|
+
// Keep ctx reference fresh
|
|
213
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
214
|
+
currentCtx = ctx;
|
|
215
|
+
});
|
|
216
|
+
pi.on("turn_start", (_event, ctx: ExtensionContext) => {
|
|
217
|
+
currentCtx = ctx;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Clean up on shutdown
|
|
221
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
222
|
+
unsubscribes.forEach((unsub) => unsub());
|
|
223
|
+
unsubscribes.length = 0;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
async function handleAskRequest(data: {
|
|
227
|
+
requestId: string;
|
|
228
|
+
questions: AskQuestion[];
|
|
229
|
+
}) {
|
|
230
|
+
if (!currentCtx?.ui) return;
|
|
231
|
+
|
|
232
|
+
const questions = data.questions;
|
|
233
|
+
let cancelled = true;
|
|
234
|
+
let selections: AskSelection[] = [];
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
if (questions.length === 1) {
|
|
238
|
+
const q = questions[0]!;
|
|
239
|
+
if (q.multi) {
|
|
240
|
+
// Multi-select single question routes to the tabs dialog (picker doesn't support multi)
|
|
241
|
+
const res = await askQuestionsWithTabs(currentCtx.ui, questions);
|
|
242
|
+
cancelled = res.cancelled;
|
|
243
|
+
selections = res.selections;
|
|
244
|
+
} else {
|
|
245
|
+
const input: { question: string; description?: string; options: typeof q.options; recommended?: number } = {
|
|
246
|
+
question: q.question,
|
|
247
|
+
options: q.options,
|
|
248
|
+
};
|
|
249
|
+
if (q.description && q.description.trim().length > 0) input.description = q.description;
|
|
250
|
+
if (q.recommended != null) input.recommended = q.recommended;
|
|
251
|
+
const sel = await askSingleQuestionWithInlineNote(currentCtx.ui, input);
|
|
252
|
+
selections = [sel];
|
|
253
|
+
cancelled = sel.selectedOptions.length === 0 && !sel.customInput;
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
const res = await askQuestionsWithTabs(currentCtx.ui, questions);
|
|
257
|
+
cancelled = res.cancelled;
|
|
258
|
+
selections = res.selections;
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
cancelled = true;
|
|
262
|
+
selections = questions.map(() => ({ selectedOptions: [] }));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Build results matching the request's questions
|
|
266
|
+
const results = questions.map((q, i) => ({
|
|
267
|
+
id: q.id,
|
|
268
|
+
selectedOptions: selections[i]?.selectedOptions ?? [],
|
|
269
|
+
customInput: selections[i]?.customInput,
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
// Emit on bus — parent's streamEvents writes to child socket
|
|
273
|
+
getBus().emit(Events.ASK_RESPONSE, {
|
|
274
|
+
requestId: data.requestId,
|
|
275
|
+
cancelled,
|
|
276
|
+
results,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
pi.registerTool({
|
|
281
|
+
name: "ask",
|
|
282
|
+
label: "Ask",
|
|
283
|
+
description: ASK_TOOL_DESCRIPTION,
|
|
284
|
+
parameters: AskParamsSchema,
|
|
285
|
+
|
|
286
|
+
async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
|
|
287
|
+
// Headless mode (subagent) — send question to parent via socket, await response on same socket
|
|
288
|
+
if (!ctx.hasUI) {
|
|
289
|
+
const requestId = randomUUID();
|
|
290
|
+
const socketPath = process.env.PI_SUBAGENT_SOCKET;
|
|
291
|
+
|
|
292
|
+
const response = await new Promise<{
|
|
293
|
+
cancelled: boolean;
|
|
294
|
+
results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
|
|
295
|
+
}>((resolve) => {
|
|
296
|
+
if (!socketPath) {
|
|
297
|
+
resolve({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let resolved = false;
|
|
302
|
+
const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
|
|
303
|
+
if (resolved) return;
|
|
304
|
+
resolved = true;
|
|
305
|
+
clearTimeout(timer);
|
|
306
|
+
resolve(r);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const socket = connect(socketPath, () => {
|
|
310
|
+
// Connected — send the question to the parent
|
|
311
|
+
socket.write(JSON.stringify({
|
|
312
|
+
type: "ask_request",
|
|
313
|
+
requestId,
|
|
314
|
+
questions: params.questions,
|
|
315
|
+
}) + "\n");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
let buffer = "";
|
|
319
|
+
socket.on("data", (data: Buffer) => {
|
|
320
|
+
buffer += data.toString();
|
|
321
|
+
const lines = buffer.split("\n");
|
|
322
|
+
buffer = lines.pop() ?? "";
|
|
323
|
+
for (const line of lines) {
|
|
324
|
+
try {
|
|
325
|
+
const msg = JSON.parse(line);
|
|
326
|
+
if (msg.type === "ask_response" && msg.requestId === requestId) {
|
|
327
|
+
socket.end();
|
|
328
|
+
finish({ cancelled: msg.cancelled, results: msg.results });
|
|
329
|
+
}
|
|
330
|
+
} catch { /* ignore */ }
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
socket.on("error", () => {
|
|
335
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
socket.on("close", () => {
|
|
339
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Timeout after 5 minutes
|
|
343
|
+
const timer = setTimeout(() => {
|
|
344
|
+
socket.end();
|
|
345
|
+
finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
|
|
346
|
+
}, 5 * 60 * 1000);
|
|
347
|
+
timer.unref();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Build results from response
|
|
351
|
+
const results: QuestionResult[] = params.questions.map((q, i) => {
|
|
352
|
+
const r = response.results[i];
|
|
353
|
+
return {
|
|
354
|
+
id: q.id,
|
|
355
|
+
question: q.question,
|
|
356
|
+
description: q.description && q.description.trim().length > 0 ? q.description : undefined,
|
|
357
|
+
options: q.options.map((o) => o.label),
|
|
358
|
+
multi: q.multi ?? false,
|
|
359
|
+
selectedOptions: r?.selectedOptions ?? [],
|
|
360
|
+
customInput: r?.customInput ?? undefined,
|
|
361
|
+
};
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
|
|
365
|
+
return {
|
|
366
|
+
content: [{ type: "text", text: "User cancelled the question." }],
|
|
367
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
373
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (params.questions.length === 0) {
|
|
378
|
+
return {
|
|
379
|
+
content: [{ type: "text", text: "Error: questions must not be empty" }],
|
|
380
|
+
details: {},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (params.questions.length === 1) {
|
|
385
|
+
const q = params.questions[0]!;
|
|
386
|
+
const selection = q.multi
|
|
387
|
+
? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
|
|
388
|
+
: await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
|
|
389
|
+
const optionLabels = q.options.map((option) => option.label);
|
|
390
|
+
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
391
|
+
|
|
392
|
+
const result: QuestionResult = {
|
|
393
|
+
id: q.id,
|
|
394
|
+
question: q.question,
|
|
395
|
+
description: desc,
|
|
396
|
+
options: optionLabels,
|
|
397
|
+
multi: q.multi ?? false,
|
|
398
|
+
selectedOptions: selection.selectedOptions,
|
|
399
|
+
customInput: selection.customInput,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const details: AskToolDetails = {
|
|
403
|
+
id: q.id,
|
|
404
|
+
question: q.question,
|
|
405
|
+
description: desc,
|
|
406
|
+
options: optionLabels,
|
|
407
|
+
multi: q.multi ?? false,
|
|
408
|
+
selectedOptions: selection.selectedOptions,
|
|
409
|
+
customInput: selection.customInput,
|
|
410
|
+
results: [result],
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: "text", text: buildAskSessionContent([result]) }],
|
|
415
|
+
details,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const results: QuestionResult[] = [];
|
|
420
|
+
const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
|
|
421
|
+
for (let i = 0; i < params.questions.length; i++) {
|
|
422
|
+
const q = params.questions[i]!;
|
|
423
|
+
const selection = tabResult.selections[i] ?? { selectedOptions: [] };
|
|
424
|
+
const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
|
|
425
|
+
results.push({
|
|
426
|
+
id: q.id,
|
|
427
|
+
question: q.question,
|
|
428
|
+
description: desc,
|
|
429
|
+
options: q.options.map((option) => option.label),
|
|
430
|
+
multi: q.multi ?? false,
|
|
431
|
+
selectedOptions: selection.selectedOptions,
|
|
432
|
+
customInput: selection.customInput,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
content: [{ type: "text", text: buildAskSessionContent(results) }],
|
|
438
|
+
details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
|
|
439
|
+
};
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
}
|
package/src/note.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CURSOR_MARKER, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const INLINE_NOTE_SEPARATOR = " — note: ";
|
|
4
|
+
const INLINE_EDIT_CURSOR_INVERT_ON = "\x1b[7m";
|
|
5
|
+
const INLINE_EDIT_CURSOR_INVERT_OFF = "\x1b[27m";
|
|
6
|
+
|
|
7
|
+
export const INLINE_NOTE_WRAP_PADDING = 2;
|
|
8
|
+
|
|
9
|
+
function sanitizeNoteForInlineDisplay(rawNote: string): string {
|
|
10
|
+
return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function clampCursorIndex(index: number, rawTextLength: number): number {
|
|
14
|
+
if (!Number.isFinite(index)) return rawTextLength;
|
|
15
|
+
if (index < 0) return 0;
|
|
16
|
+
if (index > rawTextLength) return rawTextLength;
|
|
17
|
+
return Math.floor(index);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildEditingInlineNote(
|
|
21
|
+
rawNote: string,
|
|
22
|
+
editingCursorIndex?: number,
|
|
23
|
+
includeHardwareCursorMarker = false,
|
|
24
|
+
): string {
|
|
25
|
+
const cursorIndex = clampCursorIndex(editingCursorIndex ?? rawNote.length, rawNote.length);
|
|
26
|
+
const beforeCursor = sanitizeNoteForInlineDisplay(rawNote.slice(0, cursorIndex));
|
|
27
|
+
const rawCharAtCursor = rawNote.slice(cursorIndex, cursorIndex + 1);
|
|
28
|
+
const charAtCursor = sanitizeNoteForInlineDisplay(rawCharAtCursor) || " ";
|
|
29
|
+
const afterCursorStartIndex = rawCharAtCursor.length > 0 ? cursorIndex + 1 : cursorIndex;
|
|
30
|
+
const afterCursor = sanitizeNoteForInlineDisplay(rawNote.slice(afterCursorStartIndex));
|
|
31
|
+
const cursorMarker = includeHardwareCursorMarker ? CURSOR_MARKER : "";
|
|
32
|
+
const cursorCell = `${cursorMarker}${INLINE_EDIT_CURSOR_INVERT_ON}${charAtCursor}${INLINE_EDIT_CURSOR_INVERT_OFF}`;
|
|
33
|
+
return `${beforeCursor}${cursorCell}${afterCursor}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateTextKeepingTail(text: string, maxLength: number): string {
|
|
37
|
+
if (maxLength <= 0) return "";
|
|
38
|
+
if (text.length <= maxLength) return text;
|
|
39
|
+
if (maxLength === 1) return "…";
|
|
40
|
+
return `…${text.slice(-(maxLength - 1))}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncateTextKeepingHead(text: string, maxLength: number): string {
|
|
44
|
+
if (maxLength <= 0) return "";
|
|
45
|
+
if (text.length <= maxLength) return text;
|
|
46
|
+
if (maxLength === 1) return "…";
|
|
47
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function buildOptionLabelWithInlineNote(
|
|
51
|
+
baseOptionLabel: string,
|
|
52
|
+
rawNote: string,
|
|
53
|
+
isEditingNote: boolean,
|
|
54
|
+
maxInlineLabelLength?: number,
|
|
55
|
+
editingCursorIndex?: number,
|
|
56
|
+
includeHardwareCursorMarker = false,
|
|
57
|
+
): string {
|
|
58
|
+
const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
|
|
59
|
+
if (!isEditingNote && sanitizedNote.trim().length === 0) {
|
|
60
|
+
return baseOptionLabel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
|
|
64
|
+
const inlineNote = isEditingNote
|
|
65
|
+
? buildEditingInlineNote(rawNote, editingCursorIndex, includeHardwareCursorMarker)
|
|
66
|
+
: sanitizedNote.trim();
|
|
67
|
+
const inlineLabel = `${labelPrefix}${inlineNote}`;
|
|
68
|
+
|
|
69
|
+
if (maxInlineLabelLength == null) {
|
|
70
|
+
return inlineLabel;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return isEditingNote
|
|
74
|
+
? truncateTextKeepingTail(inlineLabel, maxInlineLabelLength)
|
|
75
|
+
: truncateTextKeepingHead(inlineLabel, maxInlineLabelLength);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildWrappedOptionLabelWithInlineNote(
|
|
79
|
+
baseOptionLabel: string,
|
|
80
|
+
rawNote: string,
|
|
81
|
+
isEditingNote: boolean,
|
|
82
|
+
maxInlineLabelLength: number,
|
|
83
|
+
wrapPadding = INLINE_NOTE_WRAP_PADDING,
|
|
84
|
+
editingCursorIndex?: number,
|
|
85
|
+
includeHardwareCursorMarker = false,
|
|
86
|
+
): string[] {
|
|
87
|
+
const inlineLabel = buildOptionLabelWithInlineNote(
|
|
88
|
+
baseOptionLabel,
|
|
89
|
+
rawNote,
|
|
90
|
+
isEditingNote,
|
|
91
|
+
undefined,
|
|
92
|
+
editingCursorIndex,
|
|
93
|
+
includeHardwareCursorMarker,
|
|
94
|
+
);
|
|
95
|
+
const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
|
|
96
|
+
const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
|
|
97
|
+
? Math.max(1, Math.floor(maxInlineLabelLength))
|
|
98
|
+
: 1;
|
|
99
|
+
const wrapWidth = Math.max(1, sanitizedMaxInlineLabelLength - sanitizedWrapPadding);
|
|
100
|
+
const wrappedLines = wrapTextWithAnsi(inlineLabel, wrapWidth);
|
|
101
|
+
return wrappedLines.length > 0 ? wrappedLines : [""];
|
|
102
|
+
}
|