pi-ask-tool-extension 0.2.2 → 0.2.4

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
@@ -24,6 +24,8 @@ This extension provides:
24
24
  - **Single + multi-select** in one tool
25
25
  - **Tab-based multi-question flow** with a final submit review tab
26
26
  - **Inline note editing** (no large UI pane shifts)
27
+ - **Question text auto-wrap** (avoids one-line `...` truncation)
28
+ - **Optional Markdown context** for longer explanations/structure diagrams
27
29
  - **Automatic `Other (type your own)` handling**
28
30
 
29
31
  ## Install
@@ -157,6 +159,24 @@ Response:
157
159
  Selected: Redis
158
160
  ```
159
161
 
162
+ ### Question with Markdown context (long guidance / structure)
163
+
164
+ ```ts
165
+ ask({
166
+ questions: [
167
+ {
168
+ id: "architecture",
169
+ question: "Which execution path should we prioritize?",
170
+ description: `# Background\n\n- Current bottleneck: network I/O\n- Goal: reduce response latency\n\n\`\`\`text\n[Client] -> [API] -> [Cache] -> [DB]\n\`\`\``,
171
+ options: [{ label: "Cache-first" }, { label: "DB-first" }],
172
+ recommended: 0
173
+ }
174
+ ]
175
+ })
176
+ ```
177
+
178
+ `description` accepts both Markdown and plain text, and is wrapped above options.
179
+
160
180
  ## Interaction Model
161
181
 
162
182
  | Flow | UI style | Submit behavior |
@@ -170,7 +190,7 @@ Response:
170
190
  Press `Tab` on any option to edit a note inline on that same row.
171
191
 
172
192
  - Display format: `Option — note: ...`
173
- - Editing cursor: `▍`
193
+ - Editing cursor: inverse block on the character under caret (or space at end)
174
194
  - Notes are sanitized for inline display (line breaks/control chars)
175
195
  - Narrow-width rendering keeps the edit cursor visible
176
196
 
@@ -192,6 +212,7 @@ For `Other`, a note is required to become valid.
192
212
  {
193
213
  id: string,
194
214
  question: string,
215
+ description?: string, // optional Markdown/plain context shown above options
195
216
  options: [{ label: string }],
196
217
  multi?: boolean,
197
218
  recommended?: number // 0-indexed
@@ -229,4 +250,5 @@ Coverage gate defaults (override via env vars in CI if needed):
229
250
  - `src/ask-inline-ui.ts` - single-question UI
230
251
  - `src/ask-tabs-ui.ts` - tabbed multi-question UI
231
252
  - `src/ask-inline-note.ts` - inline note rendering helper
253
+ - `src/ask-text-wrap.ts` - shared line-wrapping helper for long prompts
232
254
  - `test/*.test.ts` - logic + UI mapping + integration coverage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ask-tool-extension",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Ask tool extension for pi with tabbed questioning and inline note editing",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,20 @@
1
+ interface CursorReadableEditor {
2
+ getLines(): string[];
3
+ getCursor(): { line: number; col: number };
4
+ }
5
+
6
+ export function getLinearCursorIndexFromEditor(editor: CursorReadableEditor): number {
7
+ const lines = editor.getLines();
8
+ const cursor = editor.getCursor();
9
+ if (lines.length === 0) return 0;
10
+
11
+ const safeLineIndex = Math.max(0, Math.min(cursor.line, lines.length - 1));
12
+ const safeColumnIndex = Math.max(0, Math.min(cursor.col, lines[safeLineIndex]?.length ?? 0));
13
+ let linearCursorIndex = safeColumnIndex;
14
+
15
+ for (let lineIndex = 0; lineIndex < safeLineIndex; lineIndex++) {
16
+ linearCursorIndex += (lines[lineIndex]?.length ?? 0) + 1;
17
+ }
18
+
19
+ return linearCursorIndex;
20
+ }
@@ -1,7 +1,8 @@
1
1
  import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
2
 
3
3
  const INLINE_NOTE_SEPARATOR = " — note: ";
4
- const INLINE_EDIT_CURSOR = "";
4
+ const INLINE_EDIT_CURSOR_INVERT_ON = "\u001b[7m";
5
+ const INLINE_EDIT_CURSOR_INVERT_OFF = "\u001b[27m";
5
6
 
6
7
  export const INLINE_NOTE_WRAP_PADDING = 2;
7
8
 
@@ -9,6 +10,24 @@ function sanitizeNoteForInlineDisplay(rawNote: string): string {
9
10
  return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
10
11
  }
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(rawNote: string, editingCursorIndex?: number): string {
21
+ const cursorIndex = clampCursorIndex(editingCursorIndex ?? rawNote.length, rawNote.length);
22
+ const beforeCursor = sanitizeNoteForInlineDisplay(rawNote.slice(0, cursorIndex));
23
+ const rawCharAtCursor = rawNote.slice(cursorIndex, cursorIndex + 1);
24
+ const charAtCursor = sanitizeNoteForInlineDisplay(rawCharAtCursor) || " ";
25
+ const afterCursorStartIndex = rawCharAtCursor.length > 0 ? cursorIndex + 1 : cursorIndex;
26
+ const afterCursor = sanitizeNoteForInlineDisplay(rawNote.slice(afterCursorStartIndex));
27
+ const cursorCell = `${INLINE_EDIT_CURSOR_INVERT_ON}${charAtCursor}${INLINE_EDIT_CURSOR_INVERT_OFF}`;
28
+ return `${beforeCursor}${cursorCell}${afterCursor}`;
29
+ }
30
+
12
31
  function truncateTextKeepingTail(text: string, maxLength: number): string {
13
32
  if (maxLength <= 0) return "";
14
33
  if (text.length <= maxLength) return text;
@@ -28,6 +47,7 @@ export function buildOptionLabelWithInlineNote(
28
47
  rawNote: string,
29
48
  isEditingNote: boolean,
30
49
  maxInlineLabelLength?: number,
50
+ editingCursorIndex?: number,
31
51
  ): string {
32
52
  const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
33
53
  if (!isEditingNote && sanitizedNote.trim().length === 0) {
@@ -35,7 +55,7 @@ export function buildOptionLabelWithInlineNote(
35
55
  }
36
56
 
37
57
  const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
38
- const inlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
58
+ const inlineNote = isEditingNote ? buildEditingInlineNote(rawNote, editingCursorIndex) : sanitizedNote.trim();
39
59
  const inlineLabel = `${labelPrefix}${inlineNote}`;
40
60
 
41
61
  if (maxInlineLabelLength == null) {
@@ -53,8 +73,15 @@ export function buildWrappedOptionLabelWithInlineNote(
53
73
  isEditingNote: boolean,
54
74
  maxInlineLabelLength: number,
55
75
  wrapPadding = INLINE_NOTE_WRAP_PADDING,
76
+ editingCursorIndex?: number,
56
77
  ): string[] {
57
- const inlineLabel = buildOptionLabelWithInlineNote(baseOptionLabel, rawNote, isEditingNote);
78
+ const inlineLabel = buildOptionLabelWithInlineNote(
79
+ baseOptionLabel,
80
+ rawNote,
81
+ isEditingNote,
82
+ undefined,
83
+ editingCursorIndex,
84
+ );
58
85
  const sanitizedWrapPadding = Number.isFinite(wrapPadding) ? Math.max(0, Math.floor(wrapPadding)) : 0;
59
86
  const sanitizedMaxInlineLabelLength = Number.isFinite(maxInlineLabelLength)
60
87
  ? Math.max(1, Math.floor(maxInlineLabelLength))
@@ -1,5 +1,14 @@
1
1
  import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
- import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import {
3
+ Editor,
4
+ Markdown,
5
+ type EditorTheme,
6
+ type MarkdownTheme,
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@mariozechner/pi-tui";
3
12
  import {
4
13
  OTHER_OPTION,
5
14
  appendRecommendedTagToOptionLabels,
@@ -7,10 +16,13 @@ import {
7
16
  type AskOption,
8
17
  type AskSelection,
9
18
  } from "./ask-logic";
19
+ import { getLinearCursorIndexFromEditor } from "./ask-inline-editor-cursor";
10
20
  import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
21
+ import { appendWrappedTextLines } from "./ask-text-wrap";
11
22
 
12
23
  interface SingleQuestionInput {
13
24
  question: string;
25
+ description?: string;
14
26
  options: AskOption[];
15
27
  recommended?: number;
16
28
  }
@@ -49,6 +61,7 @@ export async function askSingleQuestionWithInlineNote(
49
61
  let cursorOptionIndex = initialCursorIndex;
50
62
  let isNoteEditorOpen = false;
51
63
  let cachedRenderedLines: string[] | undefined;
64
+ let cachedRenderedWidth: number | undefined;
52
65
  const noteByOptionIndex = new Map<number, string>();
53
66
 
54
67
  const editorTheme: EditorTheme = {
@@ -62,9 +75,32 @@ export async function askSingleQuestionWithInlineNote(
62
75
  },
63
76
  };
64
77
  const noteEditor = new Editor(tui, editorTheme);
78
+ const markdownTheme: MarkdownTheme = {
79
+ heading: (text) => theme.fg("mdHeading", text),
80
+ link: (text) => theme.fg("mdLink", text),
81
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
82
+ code: (text) => theme.fg("mdCode", text),
83
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
84
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
85
+ quote: (text) => theme.fg("mdQuote", text),
86
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
87
+ hr: (text) => theme.fg("mdHr", text),
88
+ listBullet: (text) => theme.fg("mdListBullet", text),
89
+ bold: (text) => theme.bold(text),
90
+ italic: (text) => theme.italic(text),
91
+ strikethrough: (text) => theme.strikethrough(text),
92
+ underline: (text) => theme.underline(text),
93
+ };
94
+ const questionDescriptionMarkdown =
95
+ questionInput.description && questionInput.description.trim().length > 0
96
+ ? new Markdown(questionInput.description, 0, 0, markdownTheme, {
97
+ color: (text) => theme.fg("muted", text),
98
+ })
99
+ : undefined;
65
100
 
66
101
  const requestUiRerender = () => {
67
102
  cachedRenderedLines = undefined;
103
+ cachedRenderedWidth = undefined;
68
104
  tui.requestRender();
69
105
  };
70
106
 
@@ -106,15 +142,28 @@ export async function askSingleQuestionWithInlineNote(
106
142
  };
107
143
 
108
144
  const render = (width: number): string[] => {
109
- if (cachedRenderedLines) return cachedRenderedLines;
145
+ if (cachedRenderedLines && cachedRenderedWidth === width) return cachedRenderedLines;
110
146
 
111
147
  const renderedLines: string[] = [];
112
148
  const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
113
149
 
114
150
  addLine(theme.fg("accent", "─".repeat(width)));
115
- addLine(theme.fg("text", ` ${questionInput.question}`));
151
+ appendWrappedTextLines(renderedLines, questionInput.question, width, {
152
+ indent: 1,
153
+ formatLine: (line) => theme.fg("text", line),
154
+ });
155
+ if (questionDescriptionMarkdown) {
156
+ renderedLines.push("");
157
+ const descriptionLines = questionDescriptionMarkdown.render(Math.max(1, width - 1));
158
+ for (const descriptionLine of descriptionLines) {
159
+ addLine(` ${descriptionLine}`);
160
+ }
161
+ }
116
162
  renderedLines.push("");
117
163
 
164
+ const activeEditingCursorIndex = isNoteEditorOpen
165
+ ? getLinearCursorIndexFromEditor(noteEditor)
166
+ : undefined;
118
167
  for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
119
168
  const optionLabel = selectableOptionLabels[optionIndex];
120
169
  const isCursorOption = optionIndex === cursorOptionIndex;
@@ -131,6 +180,7 @@ export async function askSingleQuestionWithInlineNote(
131
180
  isEditingThisOption,
132
181
  Math.max(1, width - prefixWidth),
133
182
  INLINE_NOTE_WRAP_PADDING,
183
+ isEditingThisOption ? activeEditingCursorIndex : undefined,
134
184
  );
135
185
  const continuationPrefix = " ".repeat(prefixWidth);
136
186
  addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
@@ -151,10 +201,16 @@ export async function askSingleQuestionWithInlineNote(
151
201
 
152
202
  addLine(theme.fg("accent", "─".repeat(width)));
153
203
  cachedRenderedLines = renderedLines;
204
+ cachedRenderedWidth = width;
154
205
  return renderedLines;
155
206
  };
156
207
 
157
208
  const handleInput = (data: string) => {
209
+ if (matchesKey(data, Key.ctrl("c"))) {
210
+ done({ cancelled: true });
211
+ return;
212
+ }
213
+
158
214
  if (isNoteEditorOpen) {
159
215
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
160
216
  isNoteEditorOpen = false;
@@ -208,6 +264,7 @@ export async function askSingleQuestionWithInlineNote(
208
264
  render,
209
265
  invalidate: () => {
210
266
  cachedRenderedLines = undefined;
267
+ cachedRenderedWidth = undefined;
211
268
  },
212
269
  handleInput,
213
270
  };
package/src/ask-logic.ts CHANGED
@@ -8,6 +8,7 @@ export interface AskOption {
8
8
  export interface AskQuestion {
9
9
  id: string;
10
10
  question: string;
11
+ description?: string;
11
12
  options: AskOption[];
12
13
  multi?: boolean;
13
14
  recommended?: number;
@@ -1,5 +1,14 @@
1
1
  import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
- import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2
+ import {
3
+ Editor,
4
+ Markdown,
5
+ type EditorTheme,
6
+ type MarkdownTheme,
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@mariozechner/pi-tui";
3
12
  import {
4
13
  OTHER_OPTION,
5
14
  appendRecommendedTagToOptionLabels,
@@ -8,11 +17,14 @@ import {
8
17
  type AskQuestion,
9
18
  type AskSelection,
10
19
  } from "./ask-logic";
20
+ import { getLinearCursorIndexFromEditor } from "./ask-inline-editor-cursor";
11
21
  import { INLINE_NOTE_WRAP_PADDING, buildWrappedOptionLabelWithInlineNote } from "./ask-inline-note";
22
+ import { appendWrappedTextLines } from "./ask-text-wrap";
12
23
 
13
24
  interface PreparedQuestion {
14
25
  id: string;
15
26
  question: string;
27
+ description?: string;
16
28
  options: string[];
17
29
  tabLabel: string;
18
30
  multi: boolean;
@@ -120,6 +132,7 @@ export async function askQuestionsWithTabs(
120
132
  return {
121
133
  id: question.id,
122
134
  question: question.question,
135
+ description: question.description,
123
136
  options: optionLabels,
124
137
  tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`),
125
138
  multi: question.multi === true,
@@ -135,6 +148,7 @@ export async function askQuestionsWithTabs(
135
148
  let activeTabIndex = 0;
136
149
  let isNoteEditorOpen = false;
137
150
  let cachedRenderedLines: string[] | undefined;
151
+ let cachedRenderedWidth: number | undefined;
138
152
  const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion];
139
153
  const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]);
140
154
  const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) =>
@@ -152,11 +166,35 @@ export async function askQuestionsWithTabs(
152
166
  },
153
167
  };
154
168
  const noteEditor = new Editor(tui, editorTheme);
169
+ const markdownTheme: MarkdownTheme = {
170
+ heading: (text) => theme.fg("mdHeading", text),
171
+ link: (text) => theme.fg("mdLink", text),
172
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
173
+ code: (text) => theme.fg("mdCode", text),
174
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
175
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
176
+ quote: (text) => theme.fg("mdQuote", text),
177
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
178
+ hr: (text) => theme.fg("mdHr", text),
179
+ listBullet: (text) => theme.fg("mdListBullet", text),
180
+ bold: (text) => theme.bold(text),
181
+ italic: (text) => theme.italic(text),
182
+ strikethrough: (text) => theme.strikethrough(text),
183
+ underline: (text) => theme.underline(text),
184
+ };
185
+ const descriptionMarkdownByQuestion = preparedQuestions.map((preparedQuestion) =>
186
+ preparedQuestion.description && preparedQuestion.description.trim().length > 0
187
+ ? new Markdown(preparedQuestion.description, 0, 0, markdownTheme, {
188
+ color: (text) => theme.fg("muted", text),
189
+ })
190
+ : undefined,
191
+ );
155
192
 
156
193
  const submitTabIndex = preparedQuestions.length;
157
194
 
158
195
  const requestUiRerender = () => {
159
196
  cachedRenderedLines = undefined;
197
+ cachedRenderedWidth = undefined;
160
198
  tui.requestRender();
161
199
  };
162
200
 
@@ -314,9 +352,23 @@ export async function askQuestionsWithTabs(
314
352
  const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
315
353
  const selectedOptionIndexes = selectedOptionIndexesByQuestion[questionIndex];
316
354
 
317
- addLine(theme.fg("text", ` ${preparedQuestion.question}`));
355
+ appendWrappedTextLines(renderedLines, preparedQuestion.question, width, {
356
+ indent: 1,
357
+ formatLine: (line) => theme.fg("text", line),
358
+ });
359
+ const questionDescriptionMarkdown = descriptionMarkdownByQuestion[questionIndex];
360
+ if (questionDescriptionMarkdown) {
361
+ renderedLines.push("");
362
+ const descriptionLines = questionDescriptionMarkdown.render(Math.max(1, width - 1));
363
+ for (const descriptionLine of descriptionLines) {
364
+ addLine(` ${descriptionLine}`);
365
+ }
366
+ }
318
367
  renderedLines.push("");
319
368
 
369
+ const activeEditingCursorIndex = isNoteEditorOpen
370
+ ? getLinearCursorIndexFromEditor(noteEditor)
371
+ : undefined;
320
372
  for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
321
373
  const optionLabel = preparedQuestion.options[optionIndex];
322
374
  const isCursorOption = optionIndex === cursorOptionIndex;
@@ -335,6 +387,7 @@ export async function askQuestionsWithTabs(
335
387
  isEditingThisOption,
336
388
  Math.max(1, width - prefixWidth),
337
389
  INLINE_NOTE_WRAP_PADDING,
390
+ isEditingThisOption ? activeEditingCursorIndex : undefined,
338
391
  );
339
392
  const continuationPrefix = " ".repeat(prefixWidth);
340
393
  addLine(`${cursorPrefix}${theme.fg(optionColor, `${markerText}${wrappedInlineLabelLines[0] ?? ""}`)}`);
@@ -363,7 +416,7 @@ export async function askQuestionsWithTabs(
363
416
  };
364
417
 
365
418
  const render = (width: number): string[] => {
366
- if (cachedRenderedLines) return cachedRenderedLines;
419
+ if (cachedRenderedLines && cachedRenderedWidth === width) return cachedRenderedLines;
367
420
 
368
421
  const renderedLines: string[] = [];
369
422
  const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
@@ -380,10 +433,16 @@ export async function askQuestionsWithTabs(
380
433
 
381
434
  addLine(theme.fg("accent", "─".repeat(width)));
382
435
  cachedRenderedLines = renderedLines;
436
+ cachedRenderedWidth = width;
383
437
  return renderedLines;
384
438
  };
385
439
 
386
440
  const handleInput = (data: string) => {
441
+ if (matchesKey(data, Key.ctrl("c"))) {
442
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
443
+ return;
444
+ }
445
+
387
446
  if (isNoteEditorOpen) {
388
447
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
389
448
  isNoteEditorOpen = false;
@@ -488,6 +547,7 @@ export async function askQuestionsWithTabs(
488
547
  render,
489
548
  invalidate: () => {
490
549
  cachedRenderedLines = undefined;
550
+ cachedRenderedWidth = undefined;
491
551
  },
492
552
  handleInput,
493
553
  };
@@ -0,0 +1,33 @@
1
+ import { truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
2
+
3
+ interface AppendWrappedTextOptions {
4
+ indent?: number;
5
+ formatLine?: (line: string) => string;
6
+ }
7
+
8
+ function normalizeMultilineText(text: string): string[] {
9
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
10
+ const lines = normalized.split("\n");
11
+ return lines.length > 0 ? lines : [""];
12
+ }
13
+
14
+ export function appendWrappedTextLines(
15
+ renderedLines: string[],
16
+ text: string,
17
+ width: number,
18
+ options: AppendWrappedTextOptions = {},
19
+ ): void {
20
+ const safeWidth = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
21
+ const indent = Number.isFinite(options.indent) ? Math.max(0, Math.floor(options.indent ?? 0)) : 0;
22
+ const prefix = " ".repeat(indent);
23
+ const wrapWidth = Math.max(1, safeWidth - indent);
24
+ const formatLine = options.formatLine ?? ((line: string) => line);
25
+
26
+ for (const sourceLine of normalizeMultilineText(text)) {
27
+ const wrappedLines = wrapTextWithAnsi(sourceLine, wrapWidth);
28
+ const safeWrappedLines = wrappedLines.length > 0 ? wrappedLines : [""];
29
+ for (const wrappedLine of safeWrappedLines) {
30
+ renderedLines.push(truncateToWidth(`${prefix}${formatLine(wrappedLine)}`, safeWidth));
31
+ }
32
+ }
33
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,12 @@ const OptionItemSchema = Type.Object({
11
11
  const QuestionItemSchema = Type.Object({
12
12
  id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
13
13
  question: Type.String({ description: "Question text" }),
14
+ description: Type.Optional(
15
+ Type.String({
16
+ description:
17
+ "Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
18
+ }),
19
+ ),
14
20
  options: Type.Array(OptionItemSchema, {
15
21
  description: "Available options. Do not include 'Other'.",
16
22
  minItems: 1,
@@ -30,6 +36,7 @@ type AskParams = Static<typeof AskParamsSchema>;
30
36
  interface QuestionResult {
31
37
  id: string;
32
38
  question: string;
39
+ description?: string;
33
40
  options: string[];
34
41
  multi: boolean;
35
42
  selectedOptions: string[];
@@ -39,6 +46,7 @@ interface QuestionResult {
39
46
  interface AskToolDetails {
40
47
  id?: string;
41
48
  question?: string;
49
+ description?: string;
42
50
  options?: string[];
43
51
  multi?: boolean;
44
52
  selectedOptions?: string[];
@@ -54,6 +62,16 @@ function sanitizeForSessionText(value: string): string {
54
62
  .trim();
55
63
  }
56
64
 
65
+ function sanitizeMultilineForSessionText(value: string): string {
66
+ return value
67
+ .replace(/\r\n/g, "\n")
68
+ .replace(/\r/g, "\n")
69
+ .split("\n")
70
+ .map((line) => sanitizeForSessionText(line))
71
+ .join("\n")
72
+ .trim();
73
+ }
74
+
57
75
  function sanitizeOptionForSessionText(option: string): string {
58
76
  const sanitizedOption = sanitizeForSessionText(option);
59
77
  return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
@@ -64,12 +82,15 @@ function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
64
82
  .map((selectedOption) => sanitizeForSessionText(selectedOption))
65
83
  .filter((selectedOption) => selectedOption.length > 0);
66
84
 
85
+ const rawDescription = result.description;
86
+ const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
67
87
  const rawCustomInput = result.customInput;
68
88
  const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
69
89
 
70
90
  return {
71
91
  id: sanitizeForSessionText(result.id) || "(unknown)",
72
92
  question: sanitizeForSessionText(result.question) || "(empty question)",
93
+ description: description && description.length > 0 ? description : undefined,
73
94
  options: result.options.map(sanitizeOptionForSessionText),
74
95
  multi: result.multi,
75
96
  selectedOptions,
@@ -108,13 +129,18 @@ function formatQuestionResult(result: QuestionResult): string {
108
129
  }
109
130
 
110
131
  function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
111
- const lines: string[] = [
112
- `Question ${questionIndex + 1} (${result.id})`,
113
- `Prompt: ${result.question}`,
114
- "Options:",
115
- ...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`),
116
- "Response:",
117
- ];
132
+ const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
133
+
134
+ if (result.description) {
135
+ lines.push("Context:");
136
+ for (const descriptionLine of result.description.split("\n")) {
137
+ lines.push(` ${descriptionLine}`);
138
+ }
139
+ }
140
+
141
+ lines.push("Options:");
142
+ lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
143
+ lines.push("Response:");
118
144
 
119
145
  const hasSelectedOptions = result.selectedOptions.length > 0;
120
146
  const hasCustomInput = Boolean(result.customInput);
@@ -155,6 +181,7 @@ Ask the user for clarification when a choice materially affects the outcome.
155
181
  - Prefer 2-5 concise options.
156
182
  - Use multi=true when multiple answers are valid.
157
183
  - Use recommended=<index> (0-indexed) to mark the default option.
184
+ - Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
158
185
  - You can ask multiple related questions in one call using questions[].
159
186
  - Do NOT include an 'Other' option; UI adds it automatically.
160
187
  `.trim();
@@ -191,6 +218,7 @@ export default function askExtension(pi: ExtensionAPI) {
191
218
  const result: QuestionResult = {
192
219
  id: q.id,
193
220
  question: q.question,
221
+ ...(q.description && q.description.trim().length > 0 ? { description: q.description } : {}),
194
222
  options: optionLabels,
195
223
  multi: q.multi ?? false,
196
224
  selectedOptions: selection.selectedOptions,
@@ -200,6 +228,7 @@ export default function askExtension(pi: ExtensionAPI) {
200
228
  const details: AskToolDetails = {
201
229
  id: q.id,
202
230
  question: q.question,
231
+ ...(q.description && q.description.trim().length > 0 ? { description: q.description } : {}),
203
232
  options: optionLabels,
204
233
  multi: q.multi ?? false,
205
234
  selectedOptions: selection.selectedOptions,
@@ -221,6 +250,7 @@ export default function askExtension(pi: ExtensionAPI) {
221
250
  results.push({
222
251
  id: q.id,
223
252
  question: q.question,
253
+ ...(q.description && q.description.trim().length > 0 ? { description: q.description } : {}),
224
254
  options: q.options.map((option) => option.label),
225
255
  multi: q.multi ?? false,
226
256
  selectedOptions: selection.selectedOptions,