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 +23 -1
- package/package.json +1 -1
- package/src/ask-inline-editor-cursor.ts +20 -0
- package/src/ask-inline-note.ts +30 -3
- package/src/ask-inline-ui.ts +60 -3
- package/src/ask-logic.ts +1 -0
- package/src/ask-tabs-ui.ts +63 -3
- package/src/ask-text-wrap.ts +33 -0
- package/src/index.ts +37 -7
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
|
@@ -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
|
+
}
|
package/src/ask-inline-note.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
2
2
|
|
|
3
3
|
const INLINE_NOTE_SEPARATOR = " — note: ";
|
|
4
|
-
const
|
|
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 ?
|
|
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(
|
|
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))
|
package/src/ask-inline-ui.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
package/src/ask-tabs-ui.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
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,
|