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 +183 -0
- package/ask-extension.ts +1 -0
- package/package.json +35 -0
- package/src/ask-inline-note.ts +44 -0
- package/src/ask-inline-ui.ts +214 -0
- package/src/ask-logic.ts +98 -0
- package/src/ask-tabs-ui.ts +510 -0
- package/src/index.ts +148 -0
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
|
package/ask-extension.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ask-logic.ts
ADDED
|
@@ -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
|
+
}
|