@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi9/ask",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Preview-rich, replayable Pi questions with multi-select and comments.",
5
5
  "license": "MIT",
6
6
  "author": "Chase Cummings <chaseecummings@gmail.com>",
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 renderWidth = safeWidth(width);
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 hasPreviews = this.questionnaireState.rows.some(
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: string[] = [];
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 = this.renderPreview(preview, paneLayout.rightWidth);
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 (hasPreviews) {
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
- this.renderOptions(lines, renderWidth);
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, renderWidth - visibleWidth(inputPrefix));
269
+ const inputWidth = Math.max(1, optionWidth - visibleWidth(inputPrefix));
257
270
  const editorLines = this.editor.render(inputWidth).filter(line => visibleWidth(line) > 0);
258
- const editorStart = lines.length;
259
- for (const [index, line] of editorLines.entries()) {
260
- add(`${index === 0 ? inputPrefix : continuationPrefix}${line}`);
261
- }
262
- const editorEnd = lines.length;
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
- return visibleRows.map(({ value: row, overflow }) => {
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(lines: string[], width: number): FocusRange | undefined {
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 check = this.questionnaireState.config.allowMultiple
340
- ? `${this.config.theme.fg(checked ? "success" : "muted", checked ? CHECKED_BOX : EMPTY_BOX)} `
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("", `${marker}${check}${source.label}${comment}`, selected ? "accent" : "text");
346
- if (source.description) addPrefixed(" ", source.description, "muted");
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 checked = this.questionnaireState.freeformChecked;
353
- const check = this.questionnaireState.config.allowMultiple
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("", `${marker}${check}${this.config.theme.fg(selected ? "accent" : "text", `Type a response…${suffix}`)}`);
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" : "dim";
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("dim", `╰ ${mode}options:${optionCount}${timeout}`)}`;
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
- function renderAnsweredOptions(args: AskParams, answer: AskAnswer, theme: Theme): string {
54
- const selections = new Map(answer.selections.map(selection => [selection.label, selection]));
55
- const checkedGlyph = args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
56
- const emptyGlyph = args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
57
- const lines = args.options.map(option => {
58
- const label = option.label.trim();
59
- const selection = selections.get(label);
60
- const comment = selection?.comment ? ` (${selection.comment})` : "";
61
- const text = `${label}${comment}`;
62
- return selection
63
- ? `${theme.fg("success", checkedGlyph)} ${theme.fg("text", text)}`
64
- : theme.fg("dim", `${emptyGlyph} ${text}`);
65
- });
66
- if (answer.freeform) lines.push(`${theme.fg("success", checkedGlyph)} ${theme.fg("text", answer.freeform)}`);
67
- return lines.map((line, index) => `${index === 0 ? `${theme.fg("dim", "╰")} ` : " "}${line}`).join("\n");
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 Text(renderAnsweredOptions(context.args, answer, theme), 0, 0);
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"