pi-ask-tool-extension 0.1.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 ADDED
@@ -0,0 +1,183 @@
1
+ # Pi Ask Tool Extension
2
+
3
+ An extension for the [Pi coding agent](https://github.com/badlogic/pi-mono/) that adds a structured `ask` tool with interactive, tab-based questioning and inline note editing.
4
+
5
+ ```ts
6
+ ask({
7
+ questions: [
8
+ {
9
+ id: "auth",
10
+ question: "Which authentication model should we use?",
11
+ options: [{ label: "JWT" }, { label: "Session" }],
12
+ recommended: 1
13
+ }
14
+ ]
15
+ })
16
+ ```
17
+
18
+ ## Why
19
+
20
+ When an agent needs a decision from you, free-form prompts are slow and inconsistent.
21
+ This extension provides:
22
+
23
+ - **Structured options** with clear IDs and deterministic outputs
24
+ - **Single + multi-select** in one tool
25
+ - **Tab-based multi-question flow** with a final submit review tab
26
+ - **Inline note editing** (no large UI pane shifts)
27
+ - **Automatic `Other (type your own)` handling**
28
+
29
+ ## Install
30
+
31
+ ### From npm
32
+
33
+ ```bash
34
+ pi install npm:pi-ask-tool-extension
35
+ ```
36
+
37
+ ### From git
38
+
39
+ ```bash
40
+ pi install git:github.com/devkade/pi-ask-tool@main
41
+ # or pin a tag
42
+ pi install git:github.com/devkade/pi-ask-tool@v0.1.0
43
+ ```
44
+
45
+ ### Local development run
46
+
47
+ ```bash
48
+ pi -e ./ask-extension.ts
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ ### Single question (single-select)
54
+
55
+ ```ts
56
+ ask({
57
+ questions: [
58
+ {
59
+ id: "auth",
60
+ question: "Which auth approach?",
61
+ options: [{ label: "JWT" }, { label: "Session" }],
62
+ recommended: 1
63
+ }
64
+ ]
65
+ })
66
+ ```
67
+
68
+ Result example:
69
+
70
+ ```txt
71
+ User selected: Session
72
+ ```
73
+
74
+ ### Single question (multi-select)
75
+
76
+ ```ts
77
+ ask({
78
+ questions: [
79
+ {
80
+ id: "features",
81
+ question: "Which features should be enabled?",
82
+ options: [{ label: "Logging" }, { label: "Metrics" }, { label: "Tracing" }],
83
+ multi: true
84
+ }
85
+ ]
86
+ })
87
+ ```
88
+
89
+ Result example:
90
+
91
+ ```txt
92
+ User selected: Logging, Metrics
93
+ ```
94
+
95
+ ### Multi-question (tab flow)
96
+
97
+ ```ts
98
+ ask({
99
+ questions: [
100
+ {
101
+ id: "auth",
102
+ question: "Which auth approach?",
103
+ options: [{ label: "JWT" }, { label: "Session" }]
104
+ },
105
+ {
106
+ id: "cache",
107
+ question: "Which cache strategy?",
108
+ options: [{ label: "Redis" }, { label: "None" }]
109
+ }
110
+ ]
111
+ })
112
+ ```
113
+
114
+ Result example:
115
+
116
+ ```txt
117
+ User answers:
118
+ auth: Session
119
+ cache: Redis
120
+ ```
121
+
122
+ ## Interaction Model
123
+
124
+ | Flow | UI style | Submit behavior |
125
+ |---|---|---|
126
+ | Single + `multi: false` | one-question picker | Enter submits immediately |
127
+ | Single + `multi: true` | tab UI (`Question` + `Submit`) | Submit tab confirms |
128
+ | Multiple questions (mixed allowed) | tab UI (`Q1..Qn` + `Submit`) | Submit tab confirms all |
129
+
130
+ ## Inline Notes (Minimal UI Transitions)
131
+
132
+ Press `Tab` on any option to edit a note inline on that same row.
133
+
134
+ - Display format: `Option — note: ...`
135
+ - Editing cursor: `▍`
136
+ - Notes are sanitized for inline display (line breaks/control chars)
137
+ - Narrow-width rendering keeps the edit cursor visible
138
+
139
+ For `Other`, a note is required to become valid.
140
+
141
+ ## Keyboard Shortcuts
142
+
143
+ - `↑ / ↓`: move between options
144
+ - `← / →`: switch question tabs
145
+ - `Enter`: select/toggle or submit (on Submit tab)
146
+ - `Tab`: start/stop inline note editing
147
+ - `Esc`: cancel flow
148
+
149
+ ## Tool Schema
150
+
151
+ ```ts
152
+ {
153
+ questions: [
154
+ {
155
+ id: string,
156
+ question: string,
157
+ options: [{ label: string }],
158
+ multi?: boolean,
159
+ recommended?: number // 0-indexed
160
+ }
161
+ ]
162
+ }
163
+ ```
164
+
165
+ > Do **not** include an `Other` option in `options`. The UI injects it automatically.
166
+
167
+ ## Development
168
+
169
+ ```bash
170
+ npm install
171
+ npm test
172
+ npm run typecheck
173
+ ```
174
+
175
+ ## Project Structure
176
+
177
+ - `ask-extension.ts` - extension entrypoint
178
+ - `src/index.ts` - tool registration and orchestration
179
+ - `src/ask-logic.ts` - selection/result mapping helpers
180
+ - `src/ask-inline-ui.ts` - single-question UI
181
+ - `src/ask-tabs-ui.ts` - tabbed multi-question UI
182
+ - `src/ask-inline-note.ts` - inline note rendering helper
183
+ - `test/*.test.ts` - logic + UI mapping + integration coverage
@@ -0,0 +1 @@
1
+ export { default } from "./src/index";
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "pi-ask-tool-extension",
3
+ "version": "0.1.0",
4
+ "description": "Ask tool extension for pi with tabbed questioning and inline note editing",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "pi": {
10
+ "extensions": [
11
+ "./ask-extension.ts"
12
+ ]
13
+ },
14
+ "files": [
15
+ "ask-extension.ts",
16
+ "src",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "test": "bun test",
21
+ "typecheck": "tsc --noEmit"
22
+ },
23
+ "peerDependencies": {
24
+ "@mariozechner/pi-coding-agent": "*",
25
+ "@mariozechner/pi-tui": "*",
26
+ "@sinclair/typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@mariozechner/pi-coding-agent": "^0.52.12",
30
+ "@mariozechner/pi-tui": "^0.52.12",
31
+ "@sinclair/typebox": "^0.34.41",
32
+ "bun-types": "^1.3.9",
33
+ "typescript": "^5.9.3"
34
+ }
35
+ }
@@ -0,0 +1,44 @@
1
+ const INLINE_NOTE_SEPARATOR = " — note: ";
2
+ const INLINE_EDIT_CURSOR = "▍";
3
+
4
+ function sanitizeNoteForInlineDisplay(rawNote: string): string {
5
+ return rawNote.replace(/[\r\n\t]/g, " ").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "");
6
+ }
7
+
8
+ function truncateTextKeepingTail(text: string, maxLength: number): string {
9
+ if (maxLength <= 0) return "";
10
+ if (text.length <= maxLength) return text;
11
+ if (maxLength === 1) return "…";
12
+ return `…${text.slice(-(maxLength - 1))}`;
13
+ }
14
+
15
+ function truncateTextKeepingHead(text: string, maxLength: number): string {
16
+ if (maxLength <= 0) return "";
17
+ if (text.length <= maxLength) return text;
18
+ if (maxLength === 1) return "…";
19
+ return `${text.slice(0, maxLength - 1)}…`;
20
+ }
21
+
22
+ export function buildOptionLabelWithInlineNote(
23
+ baseOptionLabel: string,
24
+ rawNote: string,
25
+ isEditingNote: boolean,
26
+ maxInlineLabelLength?: number,
27
+ ): string {
28
+ const sanitizedNote = sanitizeNoteForInlineDisplay(rawNote);
29
+ if (!isEditingNote && sanitizedNote.trim().length === 0) {
30
+ return baseOptionLabel;
31
+ }
32
+
33
+ const labelPrefix = `${baseOptionLabel}${INLINE_NOTE_SEPARATOR}`;
34
+ const rawInlineNote = isEditingNote ? `${sanitizedNote}${INLINE_EDIT_CURSOR}` : sanitizedNote.trim();
35
+ const fullInlineLabel = `${labelPrefix}${rawInlineNote}`;
36
+
37
+ if (maxInlineLabelLength == null) {
38
+ return fullInlineLabel;
39
+ }
40
+
41
+ return isEditingNote
42
+ ? truncateTextKeepingTail(fullInlineLabel, maxInlineLabelLength)
43
+ : truncateTextKeepingHead(fullInlineLabel, maxInlineLabelLength);
44
+ }
@@ -0,0 +1,214 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import {
4
+ OTHER_OPTION,
5
+ appendRecommendedTagToOptionLabels,
6
+ buildSingleSelectionResult,
7
+ type AskOption,
8
+ type AskSelection,
9
+ } from "./ask-logic";
10
+ import { buildOptionLabelWithInlineNote } from "./ask-inline-note";
11
+
12
+ interface SingleQuestionInput {
13
+ question: string;
14
+ options: AskOption[];
15
+ recommended?: number;
16
+ }
17
+
18
+ interface InlineSelectionResult {
19
+ cancelled: boolean;
20
+ selectedOption?: string;
21
+ note?: string;
22
+ }
23
+
24
+ function resolveInitialCursorIndexFromRecommendedOption(
25
+ recommendedOptionIndex: number | undefined,
26
+ optionCount: number,
27
+ ): number {
28
+ if (recommendedOptionIndex == null) return 0;
29
+ if (recommendedOptionIndex < 0 || recommendedOptionIndex >= optionCount) return 0;
30
+ return recommendedOptionIndex;
31
+ }
32
+
33
+ export async function askSingleQuestionWithInlineNote(
34
+ ui: ExtensionUIContext,
35
+ questionInput: SingleQuestionInput,
36
+ ): Promise<AskSelection> {
37
+ const baseOptionLabels = questionInput.options.map((option) => option.label);
38
+ const optionLabelsWithRecommendedTag = appendRecommendedTagToOptionLabels(
39
+ baseOptionLabels,
40
+ questionInput.recommended,
41
+ );
42
+ const selectableOptionLabels = [...optionLabelsWithRecommendedTag, OTHER_OPTION];
43
+ const initialCursorIndex = resolveInitialCursorIndexFromRecommendedOption(
44
+ questionInput.recommended,
45
+ optionLabelsWithRecommendedTag.length,
46
+ );
47
+
48
+ const result = await ui.custom<InlineSelectionResult>((tui, theme, _keybindings, done) => {
49
+ let cursorOptionIndex = initialCursorIndex;
50
+ let isNoteEditorOpen = false;
51
+ let cachedRenderedLines: string[] | undefined;
52
+ const noteByOptionIndex = new Map<number, string>();
53
+
54
+ const editorTheme: EditorTheme = {
55
+ borderColor: (text) => theme.fg("accent", text),
56
+ selectList: {
57
+ selectedPrefix: (text) => theme.fg("accent", text),
58
+ selectedText: (text) => theme.fg("accent", text),
59
+ description: (text) => theme.fg("muted", text),
60
+ scrollInfo: (text) => theme.fg("dim", text),
61
+ noMatch: (text) => theme.fg("warning", text),
62
+ },
63
+ };
64
+ const noteEditor = new Editor(tui, editorTheme);
65
+
66
+ const requestUiRerender = () => {
67
+ cachedRenderedLines = undefined;
68
+ tui.requestRender();
69
+ };
70
+
71
+ const getRawNoteForOption = (optionIndex: number): string => noteByOptionIndex.get(optionIndex) ?? "";
72
+ const getTrimmedNoteForOption = (optionIndex: number): string => getRawNoteForOption(optionIndex).trim();
73
+
74
+ const loadCurrentNoteIntoEditor = () => {
75
+ noteEditor.setText(getRawNoteForOption(cursorOptionIndex));
76
+ };
77
+
78
+ const saveCurrentNoteFromEditor = (value: string) => {
79
+ noteByOptionIndex.set(cursorOptionIndex, value);
80
+ };
81
+
82
+ const submitCurrentSelection = (selectedOptionLabel: string, note: string) => {
83
+ done({
84
+ cancelled: false,
85
+ selectedOption: selectedOptionLabel,
86
+ note,
87
+ });
88
+ };
89
+
90
+ noteEditor.onChange = (value) => {
91
+ saveCurrentNoteFromEditor(value);
92
+ requestUiRerender();
93
+ };
94
+
95
+ noteEditor.onSubmit = (value) => {
96
+ saveCurrentNoteFromEditor(value);
97
+ const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
98
+ const trimmedNote = value.trim();
99
+
100
+ if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
101
+ requestUiRerender();
102
+ return;
103
+ }
104
+
105
+ submitCurrentSelection(selectedOptionLabel, trimmedNote);
106
+ };
107
+
108
+ const render = (width: number): string[] => {
109
+ if (cachedRenderedLines) return cachedRenderedLines;
110
+
111
+ const renderedLines: string[] = [];
112
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
113
+
114
+ addLine(theme.fg("accent", "─".repeat(width)));
115
+ addLine(theme.fg("text", ` ${questionInput.question}`));
116
+ renderedLines.push("");
117
+
118
+ const maxInlineLabelLength = Math.max(12, width - 6);
119
+ for (let optionIndex = 0; optionIndex < selectableOptionLabels.length; optionIndex++) {
120
+ const optionLabel = selectableOptionLabels[optionIndex];
121
+ const isCursorOption = optionIndex === cursorOptionIndex;
122
+ const isEditingThisOption = isNoteEditorOpen && isCursorOption;
123
+ const optionLabelWithInlineNote = buildOptionLabelWithInlineNote(
124
+ optionLabel,
125
+ getRawNoteForOption(optionIndex),
126
+ isEditingThisOption,
127
+ maxInlineLabelLength,
128
+ );
129
+ const cursorPrefix = isCursorOption ? theme.fg("accent", "→ ") : " ";
130
+ const bullet = isCursorOption ? "●" : "○";
131
+ const optionColor = isCursorOption ? "accent" : "text";
132
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${bullet} ${optionLabelWithInlineNote}`)}`);
133
+ }
134
+
135
+ renderedLines.push("");
136
+
137
+ if (isNoteEditorOpen) {
138
+ addLine(theme.fg("dim", " Typing note inline • Enter submit • Tab/Esc stop editing"));
139
+ } else if (getTrimmedNoteForOption(cursorOptionIndex).length > 0) {
140
+ addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab edit note • Esc cancel"));
141
+ } else {
142
+ addLine(theme.fg("dim", " ↑↓ move • Enter submit • Tab add note • Esc cancel"));
143
+ }
144
+
145
+ addLine(theme.fg("accent", "─".repeat(width)));
146
+ cachedRenderedLines = renderedLines;
147
+ return renderedLines;
148
+ };
149
+
150
+ const handleInput = (data: string) => {
151
+ if (isNoteEditorOpen) {
152
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
153
+ isNoteEditorOpen = false;
154
+ requestUiRerender();
155
+ return;
156
+ }
157
+ noteEditor.handleInput(data);
158
+ requestUiRerender();
159
+ return;
160
+ }
161
+
162
+ if (matchesKey(data, Key.up)) {
163
+ cursorOptionIndex = Math.max(0, cursorOptionIndex - 1);
164
+ requestUiRerender();
165
+ return;
166
+ }
167
+ if (matchesKey(data, Key.down)) {
168
+ cursorOptionIndex = Math.min(selectableOptionLabels.length - 1, cursorOptionIndex + 1);
169
+ requestUiRerender();
170
+ return;
171
+ }
172
+
173
+ if (matchesKey(data, Key.tab)) {
174
+ isNoteEditorOpen = true;
175
+ loadCurrentNoteIntoEditor();
176
+ requestUiRerender();
177
+ return;
178
+ }
179
+
180
+ if (matchesKey(data, Key.enter)) {
181
+ const selectedOptionLabel = selectableOptionLabels[cursorOptionIndex];
182
+ const trimmedNote = getTrimmedNoteForOption(cursorOptionIndex);
183
+
184
+ if (selectedOptionLabel === OTHER_OPTION && !trimmedNote) {
185
+ isNoteEditorOpen = true;
186
+ loadCurrentNoteIntoEditor();
187
+ requestUiRerender();
188
+ return;
189
+ }
190
+
191
+ submitCurrentSelection(selectedOptionLabel, trimmedNote);
192
+ return;
193
+ }
194
+
195
+ if (matchesKey(data, Key.escape)) {
196
+ done({ cancelled: true });
197
+ }
198
+ };
199
+
200
+ return {
201
+ render,
202
+ invalidate: () => {
203
+ cachedRenderedLines = undefined;
204
+ },
205
+ handleInput,
206
+ };
207
+ });
208
+
209
+ if (result.cancelled || !result.selectedOption) {
210
+ return { selectedOptions: [] };
211
+ }
212
+
213
+ return buildSingleSelectionResult(result.selectedOption, result.note);
214
+ }
@@ -0,0 +1,98 @@
1
+ export const OTHER_OPTION = "Other (type your own)";
2
+ const RECOMMENDED_OPTION_TAG = " (Recommended)";
3
+
4
+ export interface AskOption {
5
+ label: string;
6
+ }
7
+
8
+ export interface AskQuestion {
9
+ id: string;
10
+ question: string;
11
+ options: AskOption[];
12
+ multi?: boolean;
13
+ recommended?: number;
14
+ }
15
+
16
+ export interface AskSelection {
17
+ selectedOptions: string[];
18
+ customInput?: string;
19
+ }
20
+
21
+ export function appendRecommendedTagToOptionLabels(
22
+ optionLabels: string[],
23
+ recommendedOptionIndex?: number,
24
+ ): string[] {
25
+ if (
26
+ recommendedOptionIndex == null ||
27
+ recommendedOptionIndex < 0 ||
28
+ recommendedOptionIndex >= optionLabels.length
29
+ ) {
30
+ return optionLabels;
31
+ }
32
+
33
+ return optionLabels.map((optionLabel, optionIndex) => {
34
+ if (optionIndex !== recommendedOptionIndex) return optionLabel;
35
+ if (optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) return optionLabel;
36
+ return `${optionLabel}${RECOMMENDED_OPTION_TAG}`;
37
+ });
38
+ }
39
+
40
+ function removeRecommendedTagFromOptionLabel(optionLabel: string): string {
41
+ if (!optionLabel.endsWith(RECOMMENDED_OPTION_TAG)) {
42
+ return optionLabel;
43
+ }
44
+ return optionLabel.slice(0, -RECOMMENDED_OPTION_TAG.length);
45
+ }
46
+
47
+ export function buildSingleSelectionResult(selectedOptionLabel: string, note?: string): AskSelection {
48
+ const normalizedSelectedOption = removeRecommendedTagFromOptionLabel(selectedOptionLabel);
49
+ const normalizedNote = note?.trim();
50
+
51
+ if (normalizedSelectedOption === OTHER_OPTION) {
52
+ if (normalizedNote) {
53
+ return { selectedOptions: [], customInput: normalizedNote };
54
+ }
55
+ return { selectedOptions: [] };
56
+ }
57
+
58
+ if (normalizedNote) {
59
+ return { selectedOptions: [`${normalizedSelectedOption} - ${normalizedNote}`] };
60
+ }
61
+
62
+ return { selectedOptions: [normalizedSelectedOption] };
63
+ }
64
+
65
+ export function buildMultiSelectionResult(
66
+ optionLabels: string[],
67
+ selectedOptionIndexes: number[],
68
+ optionNotes: string[],
69
+ otherOptionIndex: number,
70
+ ): AskSelection {
71
+ const selectedOptionSet = new Set(selectedOptionIndexes);
72
+ const selectedOptions: string[] = [];
73
+ let customInput: string | undefined;
74
+
75
+ for (let optionIndex = 0; optionIndex < optionLabels.length; optionIndex++) {
76
+ if (!selectedOptionSet.has(optionIndex)) continue;
77
+
78
+ const optionLabel = removeRecommendedTagFromOptionLabel(optionLabels[optionIndex]);
79
+ const optionNote = optionNotes[optionIndex]?.trim();
80
+
81
+ if (optionIndex === otherOptionIndex) {
82
+ if (optionNote) customInput = optionNote;
83
+ continue;
84
+ }
85
+
86
+ if (optionNote) {
87
+ selectedOptions.push(`${optionLabel} - ${optionNote}`);
88
+ } else {
89
+ selectedOptions.push(optionLabel);
90
+ }
91
+ }
92
+
93
+ if (customInput) {
94
+ return { selectedOptions, customInput };
95
+ }
96
+
97
+ return { selectedOptions };
98
+ }
@@ -0,0 +1,510 @@
1
+ import type { ExtensionUIContext } from "@mariozechner/pi-coding-agent";
2
+ import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
+ import {
4
+ OTHER_OPTION,
5
+ appendRecommendedTagToOptionLabels,
6
+ buildMultiSelectionResult,
7
+ buildSingleSelectionResult,
8
+ type AskQuestion,
9
+ type AskSelection,
10
+ } from "./ask-logic";
11
+ import { buildOptionLabelWithInlineNote } from "./ask-inline-note";
12
+
13
+ interface PreparedQuestion {
14
+ id: string;
15
+ question: string;
16
+ options: string[];
17
+ tabLabel: string;
18
+ multi: boolean;
19
+ otherOptionIndex: number;
20
+ }
21
+
22
+ interface TabsUIState {
23
+ cancelled: boolean;
24
+ selectedOptionIndexesByQuestion: number[][];
25
+ noteByQuestionByOption: string[][];
26
+ }
27
+
28
+ export function formatSelectionForSubmitReview(selection: AskSelection, isMulti: boolean): string {
29
+ const hasSelectedOptions = selection.selectedOptions.length > 0;
30
+ const hasCustomInput = Boolean(selection.customInput);
31
+
32
+ if (hasSelectedOptions && hasCustomInput) {
33
+ const selectedPart = isMulti
34
+ ? `[${selection.selectedOptions.join(", ")}]`
35
+ : selection.selectedOptions[0];
36
+ return `${selectedPart} + Other: ${selection.customInput}`;
37
+ }
38
+
39
+ if (hasCustomInput) {
40
+ return `Other: ${selection.customInput}`;
41
+ }
42
+
43
+ if (hasSelectedOptions) {
44
+ return isMulti ? `[${selection.selectedOptions.join(", ")}]` : selection.selectedOptions[0];
45
+ }
46
+
47
+ return "(not answered)";
48
+ }
49
+
50
+ function clampIndex(index: number | undefined, maxExclusive: number): number {
51
+ if (index == null || Number.isNaN(index) || maxExclusive <= 0) return 0;
52
+ if (index < 0) return 0;
53
+ if (index >= maxExclusive) return maxExclusive - 1;
54
+ return index;
55
+ }
56
+
57
+ function normalizeTabLabel(id: string, fallback: string): string {
58
+ const normalized = id.trim().replace(/[_-]+/g, " ");
59
+ return normalized.length > 0 ? normalized : fallback;
60
+ }
61
+
62
+ function buildSelectionForQuestion(
63
+ question: PreparedQuestion,
64
+ selectedOptionIndexes: number[],
65
+ noteByOptionIndex: string[],
66
+ ): AskSelection {
67
+ if (selectedOptionIndexes.length === 0) {
68
+ return { selectedOptions: [] };
69
+ }
70
+
71
+ if (question.multi) {
72
+ return buildMultiSelectionResult(question.options, selectedOptionIndexes, noteByOptionIndex, question.otherOptionIndex);
73
+ }
74
+
75
+ const selectedOptionIndex = selectedOptionIndexes[0];
76
+ const selectedOptionLabel = question.options[selectedOptionIndex] ?? OTHER_OPTION;
77
+ const note = noteByOptionIndex[selectedOptionIndex] ?? "";
78
+ return buildSingleSelectionResult(selectedOptionLabel, note);
79
+ }
80
+
81
+ function isQuestionSelectionValid(
82
+ question: PreparedQuestion,
83
+ selectedOptionIndexes: number[],
84
+ noteByOptionIndex: string[],
85
+ ): boolean {
86
+ if (selectedOptionIndexes.length === 0) return false;
87
+ if (!selectedOptionIndexes.includes(question.otherOptionIndex)) return true;
88
+ const otherNote = noteByOptionIndex[question.otherOptionIndex]?.trim() ?? "";
89
+ return otherNote.length > 0;
90
+ }
91
+
92
+ function createTabsUiStateSnapshot(
93
+ cancelled: boolean,
94
+ selectedOptionIndexesByQuestion: number[][],
95
+ noteByQuestionByOption: string[][],
96
+ ): TabsUIState {
97
+ return {
98
+ cancelled,
99
+ selectedOptionIndexesByQuestion: selectedOptionIndexesByQuestion.map((indexes) => [...indexes]),
100
+ noteByQuestionByOption: noteByQuestionByOption.map((notes) => [...notes]),
101
+ };
102
+ }
103
+
104
+ function addIndexToSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
105
+ if (selectedOptionIndexes.includes(optionIndex)) return selectedOptionIndexes;
106
+ return [...selectedOptionIndexes, optionIndex].sort((a, b) => a - b);
107
+ }
108
+
109
+ function removeIndexFromSelection(selectedOptionIndexes: number[], optionIndex: number): number[] {
110
+ return selectedOptionIndexes.filter((index) => index !== optionIndex);
111
+ }
112
+
113
+ export async function askQuestionsWithTabs(
114
+ ui: ExtensionUIContext,
115
+ questions: AskQuestion[],
116
+ ): Promise<{ cancelled: boolean; selections: AskSelection[] }> {
117
+ const preparedQuestions: PreparedQuestion[] = questions.map((question, questionIndex) => {
118
+ const baseOptionLabels = question.options.map((option) => option.label);
119
+ const optionLabels = [...appendRecommendedTagToOptionLabels(baseOptionLabels, question.recommended), OTHER_OPTION];
120
+ return {
121
+ id: question.id,
122
+ question: question.question,
123
+ options: optionLabels,
124
+ tabLabel: normalizeTabLabel(question.id, `Q${questionIndex + 1}`),
125
+ multi: question.multi === true,
126
+ otherOptionIndex: optionLabels.length - 1,
127
+ };
128
+ });
129
+
130
+ const initialCursorOptionIndexByQuestion = preparedQuestions.map((preparedQuestion, questionIndex) =>
131
+ clampIndex(questions[questionIndex].recommended, preparedQuestion.options.length),
132
+ );
133
+
134
+ const result = await ui.custom<TabsUIState>((tui, theme, _keybindings, done) => {
135
+ let activeTabIndex = 0;
136
+ let isNoteEditorOpen = false;
137
+ let cachedRenderedLines: string[] | undefined;
138
+ const cursorOptionIndexByQuestion = [...initialCursorOptionIndexByQuestion];
139
+ const selectedOptionIndexesByQuestion = preparedQuestions.map(() => [] as number[]);
140
+ const noteByQuestionByOption = preparedQuestions.map((preparedQuestion) =>
141
+ Array(preparedQuestion.options.length).fill("") as string[],
142
+ );
143
+
144
+ const editorTheme: EditorTheme = {
145
+ borderColor: (text) => theme.fg("accent", text),
146
+ selectList: {
147
+ selectedPrefix: (text) => theme.fg("accent", text),
148
+ selectedText: (text) => theme.fg("accent", text),
149
+ description: (text) => theme.fg("muted", text),
150
+ scrollInfo: (text) => theme.fg("dim", text),
151
+ noMatch: (text) => theme.fg("warning", text),
152
+ },
153
+ };
154
+ const noteEditor = new Editor(tui, editorTheme);
155
+
156
+ const submitTabIndex = preparedQuestions.length;
157
+
158
+ const requestUiRerender = () => {
159
+ cachedRenderedLines = undefined;
160
+ tui.requestRender();
161
+ };
162
+
163
+ const getActiveQuestionIndex = (): number | null => {
164
+ if (activeTabIndex >= preparedQuestions.length) return null;
165
+ return activeTabIndex;
166
+ };
167
+
168
+ const getQuestionNote = (questionIndex: number, optionIndex: number): string =>
169
+ noteByQuestionByOption[questionIndex]?.[optionIndex] ?? "";
170
+
171
+ const getTrimmedQuestionNote = (questionIndex: number, optionIndex: number): string =>
172
+ getQuestionNote(questionIndex, optionIndex).trim();
173
+
174
+ const isAllQuestionSelectionsValid = (): boolean =>
175
+ preparedQuestions.every((preparedQuestion, questionIndex) =>
176
+ isQuestionSelectionValid(
177
+ preparedQuestion,
178
+ selectedOptionIndexesByQuestion[questionIndex],
179
+ noteByQuestionByOption[questionIndex],
180
+ ),
181
+ );
182
+
183
+ const openNoteEditorForActiveOption = () => {
184
+ const questionIndex = getActiveQuestionIndex();
185
+ if (questionIndex == null) return;
186
+
187
+ isNoteEditorOpen = true;
188
+ const optionIndex = cursorOptionIndexByQuestion[questionIndex];
189
+ noteEditor.setText(getQuestionNote(questionIndex, optionIndex));
190
+ requestUiRerender();
191
+ };
192
+
193
+ const advanceToNextTabOrSubmit = () => {
194
+ activeTabIndex = Math.min(submitTabIndex, activeTabIndex + 1);
195
+ };
196
+
197
+ noteEditor.onChange = (value) => {
198
+ const questionIndex = getActiveQuestionIndex();
199
+ if (questionIndex == null) return;
200
+ const optionIndex = cursorOptionIndexByQuestion[questionIndex];
201
+ noteByQuestionByOption[questionIndex][optionIndex] = value;
202
+ requestUiRerender();
203
+ };
204
+
205
+ noteEditor.onSubmit = (value) => {
206
+ const questionIndex = getActiveQuestionIndex();
207
+ if (questionIndex == null) return;
208
+
209
+ const preparedQuestion = preparedQuestions[questionIndex];
210
+ const optionIndex = cursorOptionIndexByQuestion[questionIndex];
211
+ noteByQuestionByOption[questionIndex][optionIndex] = value;
212
+ const trimmedNote = value.trim();
213
+
214
+ if (preparedQuestion.multi) {
215
+ if (trimmedNote.length > 0) {
216
+ selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(
217
+ selectedOptionIndexesByQuestion[questionIndex],
218
+ optionIndex,
219
+ );
220
+ }
221
+ if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
222
+ requestUiRerender();
223
+ return;
224
+ }
225
+ isNoteEditorOpen = false;
226
+ requestUiRerender();
227
+ return;
228
+ }
229
+
230
+ selectedOptionIndexesByQuestion[questionIndex] = [optionIndex];
231
+ if (optionIndex === preparedQuestion.otherOptionIndex && trimmedNote.length === 0) {
232
+ requestUiRerender();
233
+ return;
234
+ }
235
+
236
+ isNoteEditorOpen = false;
237
+ advanceToNextTabOrSubmit();
238
+ requestUiRerender();
239
+ };
240
+
241
+ const renderTabs = (): string => {
242
+ const tabParts: string[] = ["← "];
243
+ for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
244
+ const preparedQuestion = preparedQuestions[questionIndex];
245
+ const isActiveTab = questionIndex === activeTabIndex;
246
+ const isQuestionValid = isQuestionSelectionValid(
247
+ preparedQuestion,
248
+ selectedOptionIndexesByQuestion[questionIndex],
249
+ noteByQuestionByOption[questionIndex],
250
+ );
251
+ const statusIcon = isQuestionValid ? "■" : "□";
252
+ const tabLabel = ` ${statusIcon} ${preparedQuestion.tabLabel} `;
253
+ const styledTabLabel = isActiveTab
254
+ ? theme.bg("selectedBg", theme.fg("text", tabLabel))
255
+ : theme.fg(isQuestionValid ? "success" : "muted", tabLabel);
256
+ tabParts.push(`${styledTabLabel} `);
257
+ }
258
+
259
+ const isSubmitTabActive = activeTabIndex === submitTabIndex;
260
+ const canSubmit = isAllQuestionSelectionsValid();
261
+ const submitLabel = " ✓ Submit ";
262
+ const styledSubmitLabel = isSubmitTabActive
263
+ ? theme.bg("selectedBg", theme.fg("text", submitLabel))
264
+ : theme.fg(canSubmit ? "success" : "dim", submitLabel);
265
+ tabParts.push(`${styledSubmitLabel} →`);
266
+ return tabParts.join("");
267
+ };
268
+
269
+ const renderSubmitTab = (width: number, renderedLines: string[]): void => {
270
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
271
+
272
+ addLine(theme.fg("accent", theme.bold(" Review answers")));
273
+ renderedLines.push("");
274
+
275
+ for (let questionIndex = 0; questionIndex < preparedQuestions.length; questionIndex++) {
276
+ const preparedQuestion = preparedQuestions[questionIndex];
277
+ const selection = buildSelectionForQuestion(
278
+ preparedQuestion,
279
+ selectedOptionIndexesByQuestion[questionIndex],
280
+ noteByQuestionByOption[questionIndex],
281
+ );
282
+ const value = formatSelectionForSubmitReview(selection, preparedQuestion.multi);
283
+ const isValid = isQuestionSelectionValid(
284
+ preparedQuestion,
285
+ selectedOptionIndexesByQuestion[questionIndex],
286
+ noteByQuestionByOption[questionIndex],
287
+ );
288
+ const statusIcon = isValid ? theme.fg("success", "●") : theme.fg("warning", "○");
289
+ addLine(` ${statusIcon} ${theme.fg("muted", `${preparedQuestion.tabLabel}:`)} ${theme.fg("text", value)}`);
290
+ }
291
+
292
+ renderedLines.push("");
293
+ if (isAllQuestionSelectionsValid()) {
294
+ addLine(theme.fg("success", " Press Enter to submit"));
295
+ } else {
296
+ const missingQuestions = preparedQuestions
297
+ .filter((preparedQuestion, questionIndex) =>
298
+ !isQuestionSelectionValid(
299
+ preparedQuestion,
300
+ selectedOptionIndexesByQuestion[questionIndex],
301
+ noteByQuestionByOption[questionIndex],
302
+ ),
303
+ )
304
+ .map((preparedQuestion) => preparedQuestion.tabLabel)
305
+ .join(", ");
306
+ addLine(theme.fg("warning", ` Complete required answers: ${missingQuestions}`));
307
+ }
308
+ addLine(theme.fg("dim", " ←/→ switch tabs • Esc cancel"));
309
+ };
310
+
311
+ const renderQuestionTab = (width: number, renderedLines: string[], questionIndex: number): void => {
312
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
313
+ const preparedQuestion = preparedQuestions[questionIndex];
314
+ const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
315
+ const selectedOptionIndexes = selectedOptionIndexesByQuestion[questionIndex];
316
+
317
+ addLine(theme.fg("text", ` ${preparedQuestion.question}`));
318
+ renderedLines.push("");
319
+
320
+ const maxInlineLabelLength = Math.max(12, width - 8);
321
+ for (let optionIndex = 0; optionIndex < preparedQuestion.options.length; optionIndex++) {
322
+ const optionLabel = preparedQuestion.options[optionIndex];
323
+ const isCursorOption = optionIndex === cursorOptionIndex;
324
+ const isOptionSelected = selectedOptionIndexes.includes(optionIndex);
325
+ const isEditingThisOption = isNoteEditorOpen && isCursorOption;
326
+ const optionLabelWithInlineNote = buildOptionLabelWithInlineNote(
327
+ optionLabel,
328
+ getQuestionNote(questionIndex, optionIndex),
329
+ isEditingThisOption,
330
+ maxInlineLabelLength,
331
+ );
332
+ const cursorPrefix = isCursorOption ? theme.fg("accent", "→ ") : " ";
333
+ if (preparedQuestion.multi) {
334
+ const checkbox = isOptionSelected ? "[x]" : "[ ]";
335
+ const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
336
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${checkbox} ${optionLabelWithInlineNote}`)}`);
337
+ } else {
338
+ const bullet = isOptionSelected ? "●" : "○";
339
+ const optionColor = isCursorOption ? "accent" : isOptionSelected ? "success" : "text";
340
+ addLine(`${cursorPrefix}${theme.fg(optionColor, `${bullet} ${optionLabelWithInlineNote}`)}`);
341
+ }
342
+ }
343
+
344
+ renderedLines.push("");
345
+ if (isNoteEditorOpen) {
346
+ addLine(theme.fg("dim", " Typing note inline • Enter save note • Tab/Esc stop editing"));
347
+ } else {
348
+ if (preparedQuestion.multi) {
349
+ addLine(
350
+ theme.fg(
351
+ "dim",
352
+ " ↑↓ move • Enter toggle/select • Tab add note • ←/→ switch tabs • Esc cancel",
353
+ ),
354
+ );
355
+ } else {
356
+ addLine(
357
+ theme.fg("dim", " ↑↓ move • Enter select • Tab add note • ←/→ switch tabs • Esc cancel"),
358
+ );
359
+ }
360
+ }
361
+ };
362
+
363
+ const render = (width: number): string[] => {
364
+ if (cachedRenderedLines) return cachedRenderedLines;
365
+
366
+ const renderedLines: string[] = [];
367
+ const addLine = (line: string) => renderedLines.push(truncateToWidth(line, width));
368
+
369
+ addLine(theme.fg("accent", "─".repeat(width)));
370
+ addLine(` ${renderTabs()}`);
371
+ renderedLines.push("");
372
+
373
+ if (activeTabIndex === submitTabIndex) {
374
+ renderSubmitTab(width, renderedLines);
375
+ } else {
376
+ renderQuestionTab(width, renderedLines, activeTabIndex);
377
+ }
378
+
379
+ addLine(theme.fg("accent", "─".repeat(width)));
380
+ cachedRenderedLines = renderedLines;
381
+ return renderedLines;
382
+ };
383
+
384
+ const handleInput = (data: string) => {
385
+ if (isNoteEditorOpen) {
386
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.escape)) {
387
+ isNoteEditorOpen = false;
388
+ requestUiRerender();
389
+ return;
390
+ }
391
+ noteEditor.handleInput(data);
392
+ requestUiRerender();
393
+ return;
394
+ }
395
+
396
+ if (matchesKey(data, Key.left)) {
397
+ activeTabIndex = (activeTabIndex - 1 + preparedQuestions.length + 1) % (preparedQuestions.length + 1);
398
+ requestUiRerender();
399
+ return;
400
+ }
401
+
402
+ if (matchesKey(data, Key.right)) {
403
+ activeTabIndex = (activeTabIndex + 1) % (preparedQuestions.length + 1);
404
+ requestUiRerender();
405
+ return;
406
+ }
407
+
408
+ if (activeTabIndex === submitTabIndex) {
409
+ if (matchesKey(data, Key.enter) && isAllQuestionSelectionsValid()) {
410
+ done(createTabsUiStateSnapshot(false, selectedOptionIndexesByQuestion, noteByQuestionByOption));
411
+ return;
412
+ }
413
+ if (matchesKey(data, Key.escape)) {
414
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
415
+ }
416
+ return;
417
+ }
418
+
419
+ const questionIndex = activeTabIndex;
420
+ const preparedQuestion = preparedQuestions[questionIndex];
421
+
422
+ if (matchesKey(data, Key.up)) {
423
+ cursorOptionIndexByQuestion[questionIndex] = Math.max(0, cursorOptionIndexByQuestion[questionIndex] - 1);
424
+ requestUiRerender();
425
+ return;
426
+ }
427
+
428
+ if (matchesKey(data, Key.down)) {
429
+ cursorOptionIndexByQuestion[questionIndex] = Math.min(
430
+ preparedQuestion.options.length - 1,
431
+ cursorOptionIndexByQuestion[questionIndex] + 1,
432
+ );
433
+ requestUiRerender();
434
+ return;
435
+ }
436
+
437
+ if (matchesKey(data, Key.tab)) {
438
+ openNoteEditorForActiveOption();
439
+ return;
440
+ }
441
+
442
+ if (matchesKey(data, Key.enter)) {
443
+ const cursorOptionIndex = cursorOptionIndexByQuestion[questionIndex];
444
+
445
+ if (preparedQuestion.multi) {
446
+ const currentlySelected = selectedOptionIndexesByQuestion[questionIndex];
447
+ if (currentlySelected.includes(cursorOptionIndex)) {
448
+ selectedOptionIndexesByQuestion[questionIndex] = removeIndexFromSelection(currentlySelected, cursorOptionIndex);
449
+ } else {
450
+ selectedOptionIndexesByQuestion[questionIndex] = addIndexToSelection(currentlySelected, cursorOptionIndex);
451
+ }
452
+
453
+ if (
454
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
455
+ selectedOptionIndexesByQuestion[questionIndex].includes(cursorOptionIndex) &&
456
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
457
+ ) {
458
+ openNoteEditorForActiveOption();
459
+ return;
460
+ }
461
+
462
+ requestUiRerender();
463
+ return;
464
+ }
465
+
466
+ selectedOptionIndexesByQuestion[questionIndex] = [cursorOptionIndex];
467
+ if (
468
+ cursorOptionIndex === preparedQuestion.otherOptionIndex &&
469
+ getTrimmedQuestionNote(questionIndex, cursorOptionIndex).length === 0
470
+ ) {
471
+ openNoteEditorForActiveOption();
472
+ return;
473
+ }
474
+
475
+ advanceToNextTabOrSubmit();
476
+ requestUiRerender();
477
+ return;
478
+ }
479
+
480
+ if (matchesKey(data, Key.escape)) {
481
+ done(createTabsUiStateSnapshot(true, selectedOptionIndexesByQuestion, noteByQuestionByOption));
482
+ }
483
+ };
484
+
485
+ return {
486
+ render,
487
+ invalidate: () => {
488
+ cachedRenderedLines = undefined;
489
+ },
490
+ handleInput,
491
+ };
492
+ });
493
+
494
+ if (result.cancelled) {
495
+ return {
496
+ cancelled: true,
497
+ selections: preparedQuestions.map(() => ({ selectedOptions: [] } satisfies AskSelection)),
498
+ };
499
+ }
500
+
501
+ const selections = preparedQuestions.map((preparedQuestion, questionIndex) =>
502
+ buildSelectionForQuestion(
503
+ preparedQuestion,
504
+ result.selectedOptionIndexesByQuestion[questionIndex] ?? [],
505
+ result.noteByQuestionByOption[questionIndex] ?? Array(preparedQuestion.options.length).fill(""),
506
+ ),
507
+ );
508
+
509
+ return { cancelled: result.cancelled, selections };
510
+ }
package/src/index.ts ADDED
@@ -0,0 +1,148 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { Type, type Static } from "@sinclair/typebox";
3
+ import type { AskQuestion } from "./ask-logic";
4
+ import { askSingleQuestionWithInlineNote } from "./ask-inline-ui";
5
+ import { askQuestionsWithTabs } from "./ask-tabs-ui";
6
+
7
+ const OptionItemSchema = Type.Object({
8
+ label: Type.String({ description: "Display label" }),
9
+ });
10
+
11
+ const QuestionItemSchema = Type.Object({
12
+ id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
13
+ question: Type.String({ description: "Question text" }),
14
+ options: Type.Array(OptionItemSchema, {
15
+ description: "Available options. Do not include 'Other'.",
16
+ minItems: 1,
17
+ }),
18
+ multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
19
+ recommended: Type.Optional(
20
+ Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
21
+ ),
22
+ });
23
+
24
+ const AskParamsSchema = Type.Object({
25
+ questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
26
+ });
27
+
28
+ type AskParams = Static<typeof AskParamsSchema>;
29
+
30
+ interface QuestionResult {
31
+ id: string;
32
+ question: string;
33
+ options: string[];
34
+ multi: boolean;
35
+ selectedOptions: string[];
36
+ customInput?: string;
37
+ }
38
+
39
+ interface AskToolDetails {
40
+ question?: string;
41
+ options?: string[];
42
+ multi?: boolean;
43
+ selectedOptions?: string[];
44
+ customInput?: string;
45
+ results?: QuestionResult[];
46
+ }
47
+
48
+ function formatQuestionResult(result: QuestionResult): string {
49
+ if (result.customInput) {
50
+ return `${result.id}: \"${result.customInput}\"`;
51
+ }
52
+ if (result.selectedOptions.length > 0) {
53
+ return result.multi
54
+ ? `${result.id}: [${result.selectedOptions.join(", ")}]`
55
+ : `${result.id}: ${result.selectedOptions[0]}`;
56
+ }
57
+ return `${result.id}: (cancelled)`;
58
+ }
59
+
60
+ const ASK_TOOL_DESCRIPTION = `
61
+ Ask the user for clarification when a choice materially affects the outcome.
62
+
63
+ - Use when multiple valid approaches have different trade-offs.
64
+ - Prefer 2-5 concise options.
65
+ - Use multi=true when multiple answers are valid.
66
+ - Use recommended=<index> (0-indexed) to mark the default option.
67
+ - You can ask multiple related questions in one call using questions[].
68
+ - Do NOT include an 'Other' option; UI adds it automatically.
69
+ `.trim();
70
+
71
+ export default function askExtension(pi: ExtensionAPI) {
72
+ pi.registerTool({
73
+ name: "ask",
74
+ label: "Ask",
75
+ description: ASK_TOOL_DESCRIPTION,
76
+ parameters: AskParamsSchema,
77
+
78
+ async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
79
+ if (!ctx.hasUI) {
80
+ return {
81
+ content: [{ type: "text", text: "Error: ask tool requires interactive mode" }],
82
+ details: {},
83
+ };
84
+ }
85
+
86
+ if (params.questions.length === 0) {
87
+ return {
88
+ content: [{ type: "text", text: "Error: questions must not be empty" }],
89
+ details: {},
90
+ };
91
+ }
92
+
93
+ if (params.questions.length === 1) {
94
+ const [q] = params.questions;
95
+ const selection = q.multi
96
+ ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
97
+ : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
98
+ const optionLabels = q.options.map((option) => option.label);
99
+ const details: AskToolDetails = {
100
+ question: q.question,
101
+ options: optionLabels,
102
+ multi: q.multi ?? false,
103
+ selectedOptions: selection.selectedOptions,
104
+ customInput: selection.customInput,
105
+ };
106
+
107
+ if (selection.customInput) {
108
+ return {
109
+ content: [{ type: "text", text: `User provided custom input: ${selection.customInput}` }],
110
+ details,
111
+ };
112
+ }
113
+
114
+ if (selection.selectedOptions.length > 0) {
115
+ return {
116
+ content: [{ type: "text", text: `User selected: ${selection.selectedOptions.join(", ")}` }],
117
+ details,
118
+ };
119
+ }
120
+
121
+ return {
122
+ content: [{ type: "text", text: "User cancelled the selection" }],
123
+ details,
124
+ };
125
+ }
126
+
127
+ const results: QuestionResult[] = [];
128
+ const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
129
+ for (let i = 0; i < params.questions.length; i++) {
130
+ const q = params.questions[i];
131
+ const selection = tabResult.selections[i] ?? { selectedOptions: [] };
132
+ results.push({
133
+ id: q.id,
134
+ question: q.question,
135
+ options: q.options.map((option) => option.label),
136
+ multi: q.multi ?? false,
137
+ selectedOptions: selection.selectedOptions,
138
+ customInput: selection.customInput,
139
+ });
140
+ }
141
+
142
+ return {
143
+ content: [{ type: "text", text: `User answers:\n${results.map(formatQuestionResult).join("\n")}` }],
144
+ details: { results } satisfies AskToolDetails,
145
+ };
146
+ },
147
+ });
148
+ }