@pi9/ask 0.1.0 → 0.2.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/package.json +1 -1
- package/src/component.ts +86 -33
- package/src/index.ts +49 -19
package/package.json
CHANGED
package/src/component.ts
CHANGED
|
@@ -19,9 +19,9 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
19
19
|
import {
|
|
20
20
|
createQuestionnaireState,
|
|
21
21
|
transitionQuestionnaire,
|
|
22
|
+
type QuestionnaireRow,
|
|
22
23
|
type QuestionnaireState,
|
|
23
24
|
} from "./state.js";
|
|
24
|
-
import { CHECKED_BOX, EMPTY_BOX } from "./glyphs.js";
|
|
25
25
|
import {
|
|
26
26
|
composePreviewRow,
|
|
27
27
|
getPreviewPaneLayout,
|
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
} from "./viewport.js";
|
|
36
36
|
import type { AskAnswer, ValidatedAskParams } from "./types.js";
|
|
37
37
|
|
|
38
|
+
const FRAME_WIDE_PREVIEW = true; // Set to false to compare the same layout without its outer frame.
|
|
39
|
+
|
|
38
40
|
type AskComponentOptions = ValidatedAskParams & {
|
|
39
41
|
tui: TUI;
|
|
40
42
|
theme: Theme;
|
|
@@ -166,7 +168,14 @@ export class AskComponent implements Component, Focusable {
|
|
|
166
168
|
}
|
|
167
169
|
|
|
168
170
|
render(width: number): string[] {
|
|
169
|
-
const
|
|
171
|
+
const availableWidth = safeWidth(width);
|
|
172
|
+
const hasAuthoredPreviews = this.questionnaireState.rows.some(
|
|
173
|
+
row => row.kind === "option" && row.option.preview?.trim(),
|
|
174
|
+
);
|
|
175
|
+
const framed = FRAME_WIDE_PREVIEW
|
|
176
|
+
&& hasAuthoredPreviews
|
|
177
|
+
&& getPreviewPaneLayout(availableWidth - 4) !== undefined;
|
|
178
|
+
const renderWidth = framed ? availableWidth - 4 : availableWidth;
|
|
170
179
|
const lines: string[] = [];
|
|
171
180
|
const add = (line: string) => lines.push(fit(line, renderWidth));
|
|
172
181
|
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
@@ -176,11 +185,8 @@ export class AskComponent implements Component, Focusable {
|
|
|
176
185
|
|
|
177
186
|
add(this.config.theme.fg("border", "─".repeat(renderWidth)));
|
|
178
187
|
|
|
179
|
-
if (this.config.context) {
|
|
180
|
-
addPrefixed(" ", this.config.context, "muted");
|
|
181
|
-
add("");
|
|
182
|
-
}
|
|
183
188
|
addPrefixed(" ", this.config.theme.bold(this.config.question), "text");
|
|
189
|
+
if (this.config.context) addPrefixed(" ", this.config.context, "muted");
|
|
184
190
|
add("");
|
|
185
191
|
|
|
186
192
|
let focus: FocusRange | undefined;
|
|
@@ -198,16 +204,18 @@ export class AskComponent implements Component, Focusable {
|
|
|
198
204
|
};
|
|
199
205
|
if (this.questionnaireState.editor.kind === "select") {
|
|
200
206
|
const preview = this.highlightedPreview();
|
|
201
|
-
const
|
|
202
|
-
row => row.kind === "option" && row.option.preview?.trim(),
|
|
203
|
-
);
|
|
204
|
-
const paneLayout = hasPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
207
|
+
const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
205
208
|
if (paneLayout) {
|
|
206
|
-
const optionLines
|
|
209
|
+
const optionLines = [this.config.theme.fg("dim", " OPTIONS"), ""];
|
|
207
210
|
const optionFocus = this.renderOptions(optionLines, paneLayout.leftWidth);
|
|
208
211
|
const submitFocus = this.renderSubmit(optionLines, paneLayout.leftWidth);
|
|
209
212
|
const sectionStart = lines.length;
|
|
210
|
-
const previewLines =
|
|
213
|
+
const previewLines = [
|
|
214
|
+
this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
|
|
215
|
+
this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
|
|
216
|
+
"",
|
|
217
|
+
...this.renderPreview(preview, paneLayout.rightWidth - 1).map(line => ` ${line}`),
|
|
218
|
+
];
|
|
211
219
|
const bodyHeight = Math.max(optionLines.length, previewLines.length);
|
|
212
220
|
lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
|
|
213
221
|
wideBody = {
|
|
@@ -226,7 +234,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
226
234
|
} else {
|
|
227
235
|
const optionFocus = this.renderOptions(lines, renderWidth);
|
|
228
236
|
focus = optionFocus;
|
|
229
|
-
if (
|
|
237
|
+
if (hasAuthoredPreviews) {
|
|
230
238
|
const previewStart = lines.length;
|
|
231
239
|
const previewLines = this.renderPreview(preview, renderWidth);
|
|
232
240
|
lines.push(...previewLines);
|
|
@@ -247,28 +255,57 @@ export class AskComponent implements Component, Focusable {
|
|
|
247
255
|
footerStart = lines.length;
|
|
248
256
|
addFooter(" ", this.config.theme.fg("dim", this.helpText()));
|
|
249
257
|
} else {
|
|
258
|
+
const activeEditor = this.questionnaireState.editor;
|
|
250
259
|
// Keep the options visible while editing so the comment remains tied to
|
|
251
260
|
// the option it belongs to, and so freeform editing does not feel like a
|
|
252
261
|
// separate prompt.
|
|
253
|
-
|
|
262
|
+
const paneLayout = hasAuthoredPreviews ? getPreviewPaneLayout(renderWidth) : undefined;
|
|
263
|
+
const optionLines = paneLayout
|
|
264
|
+
? [this.config.theme.fg("dim", " OPTIONS"), ""]
|
|
265
|
+
: lines;
|
|
266
|
+
const optionWidth = paneLayout?.leftWidth ?? renderWidth;
|
|
254
267
|
const inputPrefix = ` ${this.config.theme.fg("accent", "↳")} `;
|
|
255
268
|
const continuationPrefix = " ".repeat(visibleWidth(inputPrefix));
|
|
256
|
-
const inputWidth = Math.max(1,
|
|
269
|
+
const inputWidth = Math.max(1, optionWidth - visibleWidth(inputPrefix));
|
|
257
270
|
const editorLines = this.editor.render(inputWidth).filter(line => visibleWidth(line) > 0);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
271
|
+
let editorStart = -1;
|
|
272
|
+
let editorEnd = -1;
|
|
273
|
+
this.renderOptions(optionLines, optionWidth, (row) => {
|
|
274
|
+
if (row !== activeEditor.target) return;
|
|
275
|
+
editorStart = optionLines.length;
|
|
276
|
+
for (const [index, line] of editorLines.entries()) {
|
|
277
|
+
optionLines.push(fit(`${index === 0 ? inputPrefix : continuationPrefix}${line}`, optionWidth));
|
|
278
|
+
}
|
|
279
|
+
editorEnd = optionLines.length;
|
|
280
|
+
});
|
|
263
281
|
if (editorEnd > editorStart) {
|
|
264
282
|
const cursorLine = editorLines.findIndex(line => line.includes(CURSOR_MARKER));
|
|
265
283
|
const focusedRow = cursorLine >= 0 ? editorStart + cursorLine : editorEnd - 1;
|
|
266
284
|
focus = { start: focusedRow, end: focusedRow + 1 };
|
|
267
285
|
}
|
|
268
286
|
|
|
287
|
+
this.renderSubmit(optionLines, optionWidth);
|
|
288
|
+
if (paneLayout) {
|
|
289
|
+
const sectionStart = lines.length;
|
|
290
|
+
const previewLines = [
|
|
291
|
+
this.config.theme.fg("dim", " PREVIEW · FOCUSED OPTION"),
|
|
292
|
+
this.config.theme.fg("dim", "─".repeat(paneLayout.rightWidth)),
|
|
293
|
+
"",
|
|
294
|
+
...this.renderPreview(undefined, paneLayout.rightWidth - 1).map(line => ` ${line}`),
|
|
295
|
+
];
|
|
296
|
+
const bodyHeight = Math.max(optionLines.length, previewLines.length);
|
|
297
|
+
lines.push(...optionLines, ...Array.from({ length: bodyHeight - optionLines.length }, () => ""));
|
|
298
|
+
wideBody = {
|
|
299
|
+
start: sectionStart,
|
|
300
|
+
end: sectionStart + bodyHeight,
|
|
301
|
+
previewLines,
|
|
302
|
+
layout: paneLayout,
|
|
303
|
+
};
|
|
304
|
+
if (focus) focus = { start: sectionStart + focus.start, end: sectionStart + focus.end };
|
|
305
|
+
}
|
|
306
|
+
|
|
269
307
|
// The help line is the footer. Keep it adjacent to the bottom border so
|
|
270
308
|
// fitViewport can keep both rows fixed while the editor is focused.
|
|
271
|
-
this.renderSubmit(lines, renderWidth);
|
|
272
309
|
footerStart = lines.length;
|
|
273
310
|
addFooter(continuationPrefix, this.config.theme.fg("dim", this.editorHelpText()));
|
|
274
311
|
}
|
|
@@ -287,7 +324,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
287
324
|
const visibleRows = fitViewport(rows, focus, maxRows, 1, lines.length - footerStart);
|
|
288
325
|
let previewIndex = 0;
|
|
289
326
|
|
|
290
|
-
|
|
327
|
+
const projected = visibleRows.map(({ value: row, overflow }) => {
|
|
291
328
|
if (row.kind === "full") {
|
|
292
329
|
return projectFullRow(row.line, overflow, renderWidth);
|
|
293
330
|
}
|
|
@@ -301,6 +338,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
301
338
|
this.config.theme.fg("dim", "│"),
|
|
302
339
|
);
|
|
303
340
|
});
|
|
341
|
+
return framed ? frameDialog(projected, availableWidth, this.config.theme) : projected;
|
|
304
342
|
}
|
|
305
343
|
|
|
306
344
|
/** Submit the current multi-select answer programmatically. */
|
|
@@ -320,7 +358,11 @@ export class AskComponent implements Component, Focusable {
|
|
|
320
358
|
this.config.onCancel?.();
|
|
321
359
|
}
|
|
322
360
|
|
|
323
|
-
private renderOptions(
|
|
361
|
+
private renderOptions(
|
|
362
|
+
lines: string[],
|
|
363
|
+
width: number,
|
|
364
|
+
afterRow?: (row: QuestionnaireRow) => void,
|
|
365
|
+
): FocusRange | undefined {
|
|
324
366
|
const addPrefixed = (prefix: string, text: string, color?: "text" | "muted" | "dim" | "accent") => {
|
|
325
367
|
const styled = color ? this.config.theme.fg(color, text) : text;
|
|
326
368
|
addWrappedWithPrefix(lines, prefix, styled, width);
|
|
@@ -331,34 +373,34 @@ export class AskComponent implements Component, Focusable {
|
|
|
331
373
|
if (row.kind === "submit") continue;
|
|
332
374
|
const selected = this.questionnaireState.highlightedRow === index;
|
|
333
375
|
const start = lines.length;
|
|
334
|
-
const marker = selected ? this.config.theme.fg("accent", "
|
|
376
|
+
const marker = selected ? this.config.theme.fg("accent", "┃ ") : " ";
|
|
335
377
|
|
|
336
378
|
if (row.kind === "option") {
|
|
337
379
|
const source = row.option;
|
|
338
380
|
const checked = this.questionnaireState.checked.has(source.label);
|
|
339
|
-
const
|
|
340
|
-
?
|
|
381
|
+
const badge = this.questionnaireState.config.allowMultiple && checked
|
|
382
|
+
? this.config.theme.fg("success", " [selected]")
|
|
341
383
|
: "";
|
|
342
384
|
const comment = this.questionnaireState.comments.has(source.label)
|
|
343
385
|
? this.config.theme.fg("warning", " ✎")
|
|
344
386
|
: "";
|
|
345
|
-
addPrefixed(
|
|
346
|
-
if (source.description) addPrefixed("
|
|
387
|
+
addPrefixed(marker, `${source.label}${badge}${comment}`, selected ? "accent" : "text");
|
|
388
|
+
if (source.description) addPrefixed(" ", source.description, "muted");
|
|
347
389
|
if (selected) {
|
|
348
390
|
const commentText = this.questionnaireState.comments.get(source.label);
|
|
349
391
|
if (commentText) addPrefixed(" ", `✎ ${commentText}`, "dim");
|
|
350
392
|
}
|
|
351
393
|
} else {
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
|
|
394
|
+
const badge = this.questionnaireState.config.allowMultiple && this.questionnaireState.freeformChecked
|
|
395
|
+
? this.config.theme.fg("success", " [selected]")
|
|
355
396
|
: "";
|
|
356
397
|
const suffix = this.questionnaireState.freeformDraft
|
|
357
398
|
? ` — ${this.questionnaireState.freeformDraft}`
|
|
358
399
|
: "";
|
|
359
|
-
addPrefixed(
|
|
400
|
+
addPrefixed(marker, `Type a response…${suffix}${badge}`, selected ? "accent" : "text");
|
|
360
401
|
}
|
|
361
402
|
if (selected) focus = { start, end: lines.length };
|
|
403
|
+
afterRow?.(row);
|
|
362
404
|
}
|
|
363
405
|
return focus;
|
|
364
406
|
}
|
|
@@ -367,7 +409,7 @@ export class AskComponent implements Component, Focusable {
|
|
|
367
409
|
const submitIndex = this.questionnaireState.rows.findIndex(row => row.kind === "submit");
|
|
368
410
|
if (submitIndex < 0) return undefined;
|
|
369
411
|
const selected = this.questionnaireState.highlightedRow === submitIndex;
|
|
370
|
-
const marker = selected ? this.config.theme.fg("accent", "
|
|
412
|
+
const marker = selected ? this.config.theme.fg("accent", "┃ ") : " ";
|
|
371
413
|
lines.push("");
|
|
372
414
|
const start = lines.length;
|
|
373
415
|
addWrappedWithPrefix(
|
|
@@ -530,6 +572,17 @@ function overflowPrefix(overflow: ViewportOverflow | undefined): string {
|
|
|
530
572
|
return "";
|
|
531
573
|
}
|
|
532
574
|
|
|
575
|
+
function frameDialog(lines: readonly string[], width: number, theme: Theme): string[] {
|
|
576
|
+
const border = (text: string) => theme.fg("border", text);
|
|
577
|
+
const innerWidth = width - 4;
|
|
578
|
+
return lines.map((line, index) => {
|
|
579
|
+
if (index === 0) return border(`╭${"─".repeat(width - 2)}╮`);
|
|
580
|
+
if (index === lines.length - 1) return border(`╰${"─".repeat(width - 2)}╯`);
|
|
581
|
+
const content = fit(line, innerWidth);
|
|
582
|
+
return `${border("│")} ${content}${" ".repeat(innerWidth - visibleWidth(content))} ${border("│")}`;
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
533
586
|
function safeWidth(width: number): number {
|
|
534
587
|
return Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
|
|
535
588
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Text, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
import { rewriteAskContext } from "./context.js";
|
|
5
5
|
import { resolveTimeoutMs } from "./config.js";
|
|
@@ -32,7 +32,7 @@ type AskReplayState =
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState): string {
|
|
35
|
-
const questionColor = state.status === "answered" ? "text" : "
|
|
35
|
+
const questionColor = state.status === "answered" ? "text" : "muted";
|
|
36
36
|
const title = `${theme.fg("toolTitle", "ask")} ${theme.fg(questionColor, args.question)}`;
|
|
37
37
|
if (state.status) return title;
|
|
38
38
|
|
|
@@ -41,7 +41,7 @@ function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState):
|
|
|
41
41
|
const timeout = args.timeout !== undefined && args.timeout > 0
|
|
42
42
|
? ` · timeout:${formatTimeout(args.timeout)}`
|
|
43
43
|
: "";
|
|
44
|
-
return `${title}\n${theme.fg("
|
|
44
|
+
return `${title}\n${theme.fg("muted", `╰ ${mode}options:${optionCount}${timeout}`)}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function formatTimeout(timeoutMs: number): string {
|
|
@@ -50,21 +50,51 @@ function formatTimeout(timeoutMs: number): string {
|
|
|
50
50
|
return `${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(2))}s`;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
53
|
+
class AnsweredOptions implements Component {
|
|
54
|
+
constructor(
|
|
55
|
+
private readonly args: AskParams,
|
|
56
|
+
private readonly answer: AskAnswer,
|
|
57
|
+
private readonly theme: Theme,
|
|
58
|
+
) {}
|
|
59
|
+
|
|
60
|
+
invalidate(): void {}
|
|
61
|
+
|
|
62
|
+
render(width: number): string[] {
|
|
63
|
+
const selections = new Map(this.answer.selections.map(selection => [selection.label, selection]));
|
|
64
|
+
const checkedGlyph = this.args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
|
|
65
|
+
const emptyGlyph = this.args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
|
|
66
|
+
const rows = this.args.options.map(option => {
|
|
67
|
+
const label = option.label.trim();
|
|
68
|
+
const selection = selections.get(label);
|
|
69
|
+
const comment = selection?.comment ? ` (${selection.comment})` : "";
|
|
70
|
+
return {
|
|
71
|
+
marker: this.theme.fg(selection ? "success" : "muted", selection ? checkedGlyph : emptyGlyph),
|
|
72
|
+
text: this.theme.fg(selection ? "text" : "muted", `${label}${comment}`),
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
if (this.answer.freeform) {
|
|
76
|
+
rows.push({
|
|
77
|
+
marker: this.theme.fg("success", checkedGlyph),
|
|
78
|
+
text: this.theme.fg("text", this.answer.freeform),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return rows.flatMap((row, index) => wrapAnsweredRow(
|
|
83
|
+
`${index === 0 ? `${this.theme.fg("muted", "╰")} ` : " "}${row.marker} `,
|
|
84
|
+
row.text,
|
|
85
|
+
width,
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function wrapAnsweredRow(prefix: string, text: string, width: number): string[] {
|
|
91
|
+
const safeWidth = Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
|
|
92
|
+
const prefixWidth = visibleWidth(prefix);
|
|
93
|
+
if (prefixWidth >= safeWidth) return wrapTextWithAnsi(`${prefix}${text}`, safeWidth);
|
|
94
|
+
|
|
95
|
+
const wrapped = wrapTextWithAnsi(text, safeWidth - prefixWidth);
|
|
96
|
+
const continuation = " ".repeat(prefixWidth);
|
|
97
|
+
return wrapped.map((line, index) => `${index === 0 ? prefix : continuation}${line}`);
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
export default function askExtension(pi: ExtensionAPI) {
|
|
@@ -220,7 +250,7 @@ export default function askExtension(pi: ExtensionAPI) {
|
|
|
220
250
|
?? (result.details?.status === "answered" ? result.details.answer : undefined);
|
|
221
251
|
context.state.status = answer === undefined ? "settled" : "answered";
|
|
222
252
|
context.state.callComponent?.setText(renderAskCall(context.args, theme, context.state));
|
|
223
|
-
if (answer) return new
|
|
253
|
+
if (answer) return new AnsweredOptions(context.args, answer, theme);
|
|
224
254
|
const text = result.content.find((item) => item.type === "text")?.text ?? "Ask completed.";
|
|
225
255
|
const color = result.details?.status === "cancelled" || result.details?.status === "unanswered"
|
|
226
256
|
? "muted"
|