@hank-warren/pi-ask-user-question 0.3.0 → 0.4.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 CHANGED
@@ -4,8 +4,9 @@ A structured questionnaire the model can put to you when it would otherwise
4
4
  guess. Instead of a free-form "which do you prefer?" in chat, you get a dialog
5
5
  with numbered options, digit hotkeys, a typed-answer escape, and Tab-to-comment.
6
6
 
7
- > **v0.2** ships 1-4 questions per call as cycleable tabs, single-select, no
8
- > preview pane. See [the spec](../../docs/specs/pi-ask-user-question.md) §14.
7
+ > **v0.3** ships 1-4 questions per call as cycleable tabs, single-select, with
8
+ > an optional preview pane. See
9
+ > [the spec](../../docs/specs/pi-ask-user-question.md) §14.
9
10
 
10
11
  ## Install
11
12
 
@@ -33,6 +34,15 @@ answering one jumps to the next unanswered question, and the call returns once
33
34
  every question has an answer. Cycling back and re-answering replaces that
34
35
  question's answer rather than recording a second one.
35
36
 
37
+ ## Previews
38
+
39
+ An option may carry a `preview` field — markdown shown in a pane below the
40
+ options while that option is highlighted. Use it for concrete artifacts worth
41
+ comparing (ASCII mockups, code snippets, configuration variations), not for
42
+ simple preference questions. The pane is stacked rather than side-by-side, and
43
+ long previews are clipped with a `… N more lines` marker so the options always
44
+ stay visible.
45
+
36
46
  **Why `n` and not `Tab` for notes?** Tab cycles questions here, matching
37
47
  `@juicesharp/rpiv-ask-user-question`. `pi-auto-permissions` approval prompts
38
48
  keep `Tab` for notes — they are single-question and have no tabs to cycle.
@@ -6,7 +6,7 @@
6
6
  * package replays cleanly. Only one of the two may be installed at a time.
7
7
  */
8
8
 
9
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
10
  import {
11
11
  ASK_USER_BLOCKED_EVENT,
12
12
  ASK_USER_PROMPT_EVENT,
@@ -41,6 +41,9 @@ const DESCRIPTION = `Ask the user one or more structured questions during execut
41
41
  3. Get decisions on implementation choices as you work
42
42
  4. Offer choices to the user about what direction to take
43
43
 
44
+ Preview feature:
45
+ Use the optional \`preview\` field on an option when presenting a concrete artifact the user needs to compare visually: an ASCII mockup, a code snippet, a diagram or configuration variation. It renders as markdown in a pane below the options while that option is highlighted. Do not use it for simple preference questions where the label and description already say enough.
46
+
44
47
  Usage notes:
45
48
  - The user can pick an option with the number keys, type a custom answer via the automatically appended "Type something." row, attach a note to their choice by pressing n, or press Esc to decline. Do NOT author "Other" or "Type something." labels yourself — reserved labels are rejected at runtime.
46
49
  - If you recommend a specific option, make it the first option and add "(Recommended)" at the end of the label.
@@ -96,6 +99,9 @@ export function registerTool(pi: ExtensionAPI): void {
96
99
  new QuestionnaireDialog({
97
100
  session,
98
101
  theme,
102
+ // Reuse pi's own markdown theme so previews match the
103
+ // transcript rather than inventing a second code style.
104
+ markdownTheme: getMarkdownTheme(),
99
105
  done,
100
106
  requestRender: () => tui.requestRender(),
101
107
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-ask-user-question",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Structured questionnaire tool for Pi with numbered options, digit hotkeys and Tab-to-comment, composed from the shared permission-selector component.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -45,7 +45,7 @@
45
45
  "LICENSE"
46
46
  ],
47
47
  "dependencies": {
48
- "@hank-warren/pi-permission-selector": "^0.3.0"
48
+ "@hank-warren/pi-permission-selector": "^0.3.2"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@earendil-works/pi-coding-agent": "*",
package/questionnaire.ts CHANGED
@@ -28,6 +28,8 @@ export interface SelectableRow {
28
28
  value: string;
29
29
  label: string;
30
30
  description?: string;
31
+ /** Markdown shown in the preview pane while this row is highlighted. */
32
+ preview?: string;
31
33
  }
32
34
 
33
35
  export class QuestionnaireSession {
@@ -109,6 +111,7 @@ export class QuestionnaireSession {
109
111
  value: option.label,
110
112
  label: option.label,
111
113
  description: option.description,
114
+ ...(option.preview ? { preview: option.preview } : {}),
112
115
  }));
113
116
  rows.push({ value: CUSTOM_ANSWER_VALUE, label: CUSTOM_ANSWER_LABEL });
114
117
  return rows;
@@ -127,12 +130,17 @@ export class QuestionnaireSession {
127
130
  recordAnswer(answer: string, opts: { custom?: boolean; notes?: string } = {}): void {
128
131
  const question = this.current;
129
132
  if (!question) return;
133
+ // A custom typed answer never carries a preview; only a chosen option can.
134
+ const preview = opts.custom
135
+ ? undefined
136
+ : question.options.find((option) => option.label === answer)?.preview;
130
137
  this.answers[this.index] = {
131
138
  questionIndex: this.index,
132
139
  question: question.question,
133
140
  answer,
134
141
  custom: opts.custom === true,
135
142
  ...(opts.notes ? { notes: opts.notes } : {}),
143
+ ...(preview ? { preview } : {}),
136
144
  };
137
145
  }
138
146
 
package/tool/envelope.ts CHANGED
@@ -23,9 +23,14 @@ export function buildToolResult(text: string, details: QuestionnaireResult): Too
23
23
  return { content: [{ type: "text" as const, text }], details };
24
24
  }
25
25
 
26
- /** Format a single answer as `"question"="answer"`, with an optional note. */
26
+ /**
27
+ * Format a single answer as `"question"="answer"`, with the chosen option's
28
+ * preview and the user's note appended when present. Order and wording match
29
+ * rpiv's envelope and are pinned by tests.
30
+ */
27
31
  export function buildAnswerSegment(a: QuestionAnswer): string {
28
32
  const parts: string[] = [`"${a.question}"="${a.answer}"`];
33
+ if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
29
34
  if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
30
35
  return `${parts.join(". ")}.`;
31
36
  }
package/tool/schema.ts CHANGED
@@ -39,6 +39,12 @@ export const OptionSchema = Type.Object({
39
39
  description:
40
40
  "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.",
41
41
  }),
42
+ preview: Type.Optional(
43
+ Type.String({
44
+ description:
45
+ "Optional markdown shown in a pane below the options while this option is highlighted. Use for concrete artifacts the user needs to compare: ASCII mockups, code snippets, diagram or configuration variations. Do NOT use it for simple preference questions where the label and description already say enough.",
46
+ }),
47
+ ),
42
48
  });
43
49
 
44
50
  export const QuestionSchema = Type.Object({
@@ -70,6 +76,7 @@ export const QuestionParamsSchema = Type.Object({
70
76
  export interface OptionParams {
71
77
  label: string;
72
78
  description: string;
79
+ preview?: string;
73
80
  }
74
81
 
75
82
  export interface QuestionParams {
@@ -88,8 +95,10 @@ export interface QuestionAnswer {
88
95
  question: string;
89
96
  answer: string;
90
97
  custom: boolean;
91
- /** Trimmed Tab-to-comment note, when the user attached one. */
98
+ /** Trimmed note, when the user attached one. */
92
99
  notes?: string;
100
+ /** Preview text of the chosen option, when it carried one. */
101
+ preview?: string;
93
102
  }
94
103
 
95
104
  export interface QuestionnaireResult {
package/view/dialog.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  visibleWidth,
56
56
  wrapTextWithAnsi,
57
57
  } from "@earendil-works/pi-tui";
58
+ import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
58
59
  import type { QuestionnaireSession } from "../questionnaire.ts";
59
60
  import type { QuestionnaireResult } from "../tool/schema.ts";
60
61
 
@@ -75,6 +76,12 @@ export interface DialogOptions {
75
76
  * the transcript show through to its right.
76
77
  */
77
78
  maxWidth?: number;
79
+ /**
80
+ * Markdown theme for the preview pane. Hosts pass pi's `getMarkdownTheme()`;
81
+ * when omitted the preview renders as plain wrapped text, which keeps this
82
+ * component unit-testable without constructing a 14-function theme.
83
+ */
84
+ markdownTheme?: MarkdownTheme;
78
85
  }
79
86
 
80
87
  /** Left/right border plus one space of padding on each side. */
@@ -84,6 +91,8 @@ const FIELD_INDENT = " ";
84
91
  /** Opens the note editor. Tab is taken by question cycling. */
85
92
  const NOTE_KEY = "n";
86
93
  const isNoteKey = isCharKey(NOTE_KEY);
94
+ /** Maximum rows the preview pane may occupy before it is clipped. */
95
+ const PREVIEW_MAX_ROWS = 12;
87
96
 
88
97
  /** Per-question UI state, preserved while cycling between tabs. */
89
98
  interface TabState {
@@ -232,12 +241,63 @@ export class QuestionnaireDialog {
232
241
  }
233
242
 
234
243
  lines.push(...tab.selector.render(inner));
244
+ lines.push(...this.previewLines(inner));
235
245
  if (session.total > 1 && !tab.selector.isCommenting()) {
236
246
  lines.push(this.style("dim", " tab next · shift+tab prev question"));
237
247
  }
238
248
  return lines;
239
249
  }
240
250
 
251
+ /** Preview markdown for the highlighted row, if it carries any. */
252
+ private currentPreview(): string | undefined {
253
+ const selected = this.tab.selector.getSelected();
254
+ if (!selected) return undefined;
255
+ return this.session.rows().find((row) => row.value === selected.value)?.preview;
256
+ }
257
+
258
+ /**
259
+ * The preview pane: markdown between two rules, below the options.
260
+ *
261
+ * Stacked, not side-by-side. rpiv spent ~712 lines largely on making a
262
+ * two-column layout behave at narrow widths; stacking needs none of it
263
+ * (docs/specs/pi-ask-user-question.md §6.2).
264
+ */
265
+ private previewLines(inner: number): string[] {
266
+ const preview = this.currentPreview();
267
+ if (!preview) return [];
268
+
269
+ const rendered = this.renderMarkdown(preview, inner);
270
+
271
+ // Cap the pane so a long preview cannot push the options off screen.
272
+ const clipped = rendered.slice(0, PREVIEW_MAX_ROWS);
273
+ if (rendered.length > PREVIEW_MAX_ROWS) {
274
+ clipped.push(this.style("dim", `… ${rendered.length - PREVIEW_MAX_ROWS} more lines`));
275
+ }
276
+
277
+ const rule = this.style("dim", "─".repeat(Math.max(1, inner)));
278
+ return ["", rule, ...clipped, rule];
279
+ }
280
+
281
+ /**
282
+ * Render preview markdown, falling back to plain wrapped text.
283
+ *
284
+ * The fallback is not just for hosts that pass no theme. pi's
285
+ * `getMarkdownTheme()` returns lazily-bound functions that throw
286
+ * "Theme not initialized" until `initTheme()` has run, and a throw inside
287
+ * `render()` would take down the entire dialog rather than one pane. A
288
+ * preview is a nicety; it must never be able to do that.
289
+ */
290
+ private renderMarkdown(preview: string, inner: number): string[] {
291
+ if (this.opts.markdownTheme) {
292
+ try {
293
+ return new Markdown(preview, 0, 0, this.opts.markdownTheme).render(inner);
294
+ } catch {
295
+ // fall through to plain text
296
+ }
297
+ }
298
+ return preview.split("\n").flatMap((line) => wrapTextWithAnsi(line, inner));
299
+ }
300
+
241
301
  render(width: number): string[] {
242
302
  const cap = this.opts.maxWidth ?? width;
243
303
  const outer = Math.max(20, Math.min(width, cap));