pi-ask-lite 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/LICENSE +21 -0
- package/README.md +116 -0
- package/package.json +51 -0
- package/src/index.ts +53 -0
- package/src/parse.ts +186 -0
- package/src/state.ts +141 -0
- package/src/types.ts +44 -0
- package/src/ui.ts +308 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gerhard Schwanzer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# pi-ask-lite
|
|
2
|
+
|
|
3
|
+
A tiny, pleasant Markdown ask tool for Pi.
|
|
4
|
+
|
|
5
|
+
`pi-ask-lite` registers one tool, `ask`. The agent passes one complete Markdown prompt; the user selects, edits, or writes a free-text answer; the tool returns the final answer as Markdown/text.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:pi-ask-lite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Tool
|
|
14
|
+
|
|
15
|
+
Tool name: `ask`
|
|
16
|
+
|
|
17
|
+
Parameter:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
{
|
|
21
|
+
markdown: string
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Example
|
|
26
|
+
|
|
27
|
+
Ask with this Markdown:
|
|
28
|
+
|
|
29
|
+
```md
|
|
30
|
+
Choose what to do next:
|
|
31
|
+
|
|
32
|
+
* The implementation is complete.
|
|
33
|
+
* Checks passed locally.
|
|
34
|
+
|
|
35
|
+
- Release now
|
|
36
|
+
- Run one more smoke test
|
|
37
|
+
- Make a small polish pass
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The user sees a TUI-native choice prompt and the agent receives the selected answer as Markdown/text.
|
|
41
|
+
|
|
42
|
+
## Markdown interaction syntax
|
|
43
|
+
|
|
44
|
+
Use normal Markdown for explanation and a few lightweight conventions for choices.
|
|
45
|
+
|
|
46
|
+
Non-interactive context bullets use `*`:
|
|
47
|
+
|
|
48
|
+
```md
|
|
49
|
+
Given:
|
|
50
|
+
|
|
51
|
+
* non-interactive context bullet
|
|
52
|
+
* another context fact
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Single choice uses `- option`:
|
|
56
|
+
|
|
57
|
+
```md
|
|
58
|
+
- Option A
|
|
59
|
+
- Option B
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Multi choice uses `- [ ] option`:
|
|
63
|
+
|
|
64
|
+
```md
|
|
65
|
+
- [ ] Option A
|
|
66
|
+
- [ ] Option B
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Independent required groups can be separated with headings:
|
|
70
|
+
|
|
71
|
+
```md
|
|
72
|
+
## Color
|
|
73
|
+
- Red
|
|
74
|
+
- Blue
|
|
75
|
+
|
|
76
|
+
## Size
|
|
77
|
+
- Small
|
|
78
|
+
- Large
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Dependent subchoices are expressed by indentation:
|
|
82
|
+
|
|
83
|
+
```md
|
|
84
|
+
- [ ] Parent
|
|
85
|
+
- Child A
|
|
86
|
+
- Child B
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Do not preselect options with `- [x]` in prompts. Express recommendations in normal Markdown text or emphasis instead.
|
|
90
|
+
|
|
91
|
+
## User interaction
|
|
92
|
+
|
|
93
|
+
The extension uses a focused TUI overlay during the tool call:
|
|
94
|
+
|
|
95
|
+
- `Space` selects/toggles the focused option.
|
|
96
|
+
- `Shift+Space` selects/toggles and marks the answer for draft editing.
|
|
97
|
+
- `Enter` sends a valid selection, or opens the selected Markdown as an editable draft when draft mode is active.
|
|
98
|
+
- `Esc` / `Ctrl+C` opens an empty free-text editor.
|
|
99
|
+
|
|
100
|
+
Direct selections return only the selected Markdown structure. Free-text and edited drafts are returned unchanged.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
Run locally:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pi --extension ./src/index.ts
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Checks:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run check
|
|
114
|
+
npm test
|
|
115
|
+
npm run pack:dry
|
|
116
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-ask-lite",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A tiny, pleasant Markdown ask tool for Pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-coding-agent",
|
|
11
|
+
"extension",
|
|
12
|
+
"ask-lite"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/geri1701/pi-ask-lite.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/geri1701/pi-ask-lite#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/geri1701/pi-ask-lite/issues"
|
|
21
|
+
},
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "tsc --noEmit",
|
|
34
|
+
"test": "node --test --import tsx test/*.test.ts",
|
|
35
|
+
"pack:dry": "npm pack --dry-run"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-ai": "*",
|
|
39
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
40
|
+
"@earendil-works/pi-tui": "*",
|
|
41
|
+
"typebox": "*"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@earendil-works/pi-ai": "^0.74.0",
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
46
|
+
"@earendil-works/pi-tui": "^0.74.0",
|
|
47
|
+
"tsx": "^4.22.0",
|
|
48
|
+
"typebox": "^1.1.33",
|
|
49
|
+
"typescript": "^6.0.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
import { parseAskMarkdown } from "./parse.js";
|
|
5
|
+
import { runAskUi } from "./ui.js";
|
|
6
|
+
|
|
7
|
+
const AskParams = Type.Object({
|
|
8
|
+
markdown: Type.String({
|
|
9
|
+
description: "Complete Markdown prompt to show to the user. The tool returns the final user answer as Markdown/text.",
|
|
10
|
+
}),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
type AskDetails = {
|
|
14
|
+
answer: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const askDescription = `Ask the user with a Markdown prompt.
|
|
18
|
+
|
|
19
|
+
Use exactly:
|
|
20
|
+
- \`- option\` for single choice.
|
|
21
|
+
- \`- [ ] option\` for multi choice.
|
|
22
|
+
- \`* item\` for non-interactive context bullets.
|
|
23
|
+
- Headings split independent required groups.
|
|
24
|
+
- Indented options create dependent subchoices.
|
|
25
|
+
- Never use \`- [x]\` in prompts; express recommendations in normal Markdown.
|
|
26
|
+
|
|
27
|
+
The tool returns the user's final answer as Markdown/text.`;
|
|
28
|
+
|
|
29
|
+
export default function askUserLite(pi: ExtensionAPI) {
|
|
30
|
+
pi.registerTool({
|
|
31
|
+
name: "ask",
|
|
32
|
+
label: "Ask User",
|
|
33
|
+
description: askDescription,
|
|
34
|
+
promptSnippet: "Ask the user with one complete Markdown prompt; returns the user's final Markdown/text answer.",
|
|
35
|
+
parameters: AskParams,
|
|
36
|
+
|
|
37
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
38
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
39
|
+
if (!ctx.hasUI) throw new Error("Interactive UI is not available for ask.");
|
|
40
|
+
|
|
41
|
+
const document = parseAskMarkdown(params.markdown);
|
|
42
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
43
|
+
|
|
44
|
+
const answer = await runAskUi(ctx, document);
|
|
45
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: "text", text: `User answer:\n\n${answer}` }],
|
|
49
|
+
details: { answer } satisfies AskDetails,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AskDocument, AskOption, AskSection, GroupMode, OptionGroup, TextBlock } from "./types.js";
|
|
2
|
+
|
|
3
|
+
interface GroupContext {
|
|
4
|
+
indent: number;
|
|
5
|
+
group: OptionGroup;
|
|
6
|
+
lastOption?: AskOption;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface ParsedOptionLine {
|
|
10
|
+
indent: number;
|
|
11
|
+
mode: GroupMode;
|
|
12
|
+
label: string;
|
|
13
|
+
rawLabel: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const headingPattern = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
17
|
+
const optionPattern = /^([ \t]*)-\s+(?:\[([ xX])\](?:\s+(.*)|\s*)|(.*))$/;
|
|
18
|
+
const fencePattern = /^\s*(```|~~~)/;
|
|
19
|
+
|
|
20
|
+
export function parseAskMarkdown(markdown: string): AskDocument {
|
|
21
|
+
const sections: AskSection[] = [{ blocks: [] }];
|
|
22
|
+
let groupStack: GroupContext[] = [];
|
|
23
|
+
let optionCounter = 0;
|
|
24
|
+
let inFence = false;
|
|
25
|
+
|
|
26
|
+
const currentSection = () => sections[sections.length - 1];
|
|
27
|
+
|
|
28
|
+
const resetOptionContext = () => {
|
|
29
|
+
groupStack = [];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const appendTextLine = (line: string) => {
|
|
33
|
+
const section = currentSection();
|
|
34
|
+
const lastBlock = section.blocks[section.blocks.length - 1];
|
|
35
|
+
if (lastBlock?.type === "text") {
|
|
36
|
+
lastBlock.lines.push(line);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const block: TextBlock = { type: "text", lines: [line] };
|
|
41
|
+
section.blocks.push(block);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const startSection = (level: number, text: string) => {
|
|
45
|
+
const section = currentSection();
|
|
46
|
+
resetOptionContext();
|
|
47
|
+
|
|
48
|
+
if (!section.heading && section.blocks.length === 0) {
|
|
49
|
+
section.heading = { level, text };
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
sections.push({ heading: { level, text }, blocks: [] });
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const addOption = (parsed: ParsedOptionLine, lineNumber: number) => {
|
|
57
|
+
const option: AskOption = {
|
|
58
|
+
id: `opt-${++optionCounter}`,
|
|
59
|
+
label: parsed.label.trim(),
|
|
60
|
+
rawLabel: parsed.rawLabel,
|
|
61
|
+
indent: parsed.indent,
|
|
62
|
+
line: lineNumber,
|
|
63
|
+
children: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (!option.label) {
|
|
67
|
+
throw invalidAskMarkdown(
|
|
68
|
+
"Interactive options must not be empty.",
|
|
69
|
+
"Write a visible label after `-` or `- [ ]`.",
|
|
70
|
+
lineNumber,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
while (groupStack.length > 0 && parsed.indent < groupStack[groupStack.length - 1].indent) {
|
|
75
|
+
groupStack.pop();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const existingGroup = groupStack[groupStack.length - 1]?.indent === parsed.indent
|
|
79
|
+
? groupStack[groupStack.length - 1]
|
|
80
|
+
: undefined;
|
|
81
|
+
|
|
82
|
+
if (existingGroup) {
|
|
83
|
+
ensureSameMode(existingGroup.group, parsed.mode, lineNumber);
|
|
84
|
+
existingGroup.group.options.push(option);
|
|
85
|
+
existingGroup.lastOption = option;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parentGroup = groupStack[groupStack.length - 1];
|
|
90
|
+
const group: OptionGroup = {
|
|
91
|
+
type: "group",
|
|
92
|
+
mode: parsed.mode,
|
|
93
|
+
options: [option],
|
|
94
|
+
required: true,
|
|
95
|
+
indent: parsed.indent,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (parentGroup && parsed.indent > parentGroup.indent && parentGroup.lastOption) {
|
|
99
|
+
parentGroup.lastOption.children.push(group);
|
|
100
|
+
} else {
|
|
101
|
+
currentSection().blocks.push(group);
|
|
102
|
+
groupStack = [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
groupStack.push({ indent: parsed.indent, group, lastOption: option });
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
markdown.split(/\r?\n/).forEach((line, index) => {
|
|
109
|
+
const lineNumber = index + 1;
|
|
110
|
+
|
|
111
|
+
if (fencePattern.test(line)) {
|
|
112
|
+
inFence = !inFence;
|
|
113
|
+
resetOptionContext();
|
|
114
|
+
appendTextLine(line);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (inFence) {
|
|
119
|
+
appendTextLine(line);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const headingMatch = line.match(headingPattern);
|
|
124
|
+
if (headingMatch) {
|
|
125
|
+
startSection(headingMatch[1].length, headingMatch[2].trim());
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parsedOption = parseOptionLine(line, lineNumber);
|
|
130
|
+
if (parsedOption) {
|
|
131
|
+
addOption(parsedOption, lineNumber);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
resetOptionContext();
|
|
136
|
+
appendTextLine(line);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return { sections };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseOptionLine(line: string, lineNumber: number): ParsedOptionLine | undefined {
|
|
143
|
+
const match = line.match(optionPattern);
|
|
144
|
+
if (!match) return undefined;
|
|
145
|
+
|
|
146
|
+
const checkbox = match[2];
|
|
147
|
+
if (checkbox && checkbox.toLowerCase() === "x") {
|
|
148
|
+
throw invalidAskMarkdown(
|
|
149
|
+
"Preselected options are not allowed.",
|
|
150
|
+
"Use `- [ ] option` for multi choice and describe recommendations in normal Markdown text.",
|
|
151
|
+
lineNumber,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rawLabel = checkbox ? (match[3] ?? "") : match[4];
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
indent: indentationWidth(match[1]),
|
|
159
|
+
mode: checkbox === " " ? "multi" : "single",
|
|
160
|
+
label: rawLabel.trim(),
|
|
161
|
+
rawLabel,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ensureSameMode(group: OptionGroup, mode: GroupMode, lineNumber: number) {
|
|
166
|
+
if (group.mode === mode) return;
|
|
167
|
+
|
|
168
|
+
throw invalidAskMarkdown(
|
|
169
|
+
"Mixed selection styles in the same option group.",
|
|
170
|
+
"Use either `- option` for single choice or `- [ ] option` for multi choice within one sibling group.",
|
|
171
|
+
lineNumber,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function indentationWidth(indent: string): number {
|
|
176
|
+
let width = 0;
|
|
177
|
+
for (const character of indent) {
|
|
178
|
+
width += character === "\t" ? 4 : 1;
|
|
179
|
+
}
|
|
180
|
+
return width;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function invalidAskMarkdown(reason: string, fix: string, lineNumber?: number): Error {
|
|
184
|
+
const location = typeof lineNumber === "number" ? `\nLine: ${lineNumber}` : "";
|
|
185
|
+
return new Error(`Invalid ask markdown.\n\nReason: ${reason}${location}\nFix: ${fix}`);
|
|
186
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AskBlock, AskDocument, AskOption, AskSection, AskState, OptionGroup } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function createAskState(): AskState {
|
|
4
|
+
return {
|
|
5
|
+
selected: new Set<string>(),
|
|
6
|
+
draftMode: false,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function selectOption(document: AskDocument, state: AskState, optionId: string, asDraft = false): void {
|
|
11
|
+
const location = findOption(document, optionId);
|
|
12
|
+
if (!location || !isOptionActive(document, state, optionId)) return;
|
|
13
|
+
|
|
14
|
+
if (asDraft) state.draftMode = true;
|
|
15
|
+
|
|
16
|
+
if (location.group.mode === "single") {
|
|
17
|
+
for (const option of location.group.options) {
|
|
18
|
+
if (option.id !== optionId) clearOptionAndChildren(state, option);
|
|
19
|
+
}
|
|
20
|
+
state.selected.add(optionId);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (state.selected.has(optionId)) {
|
|
25
|
+
clearOptionAndChildren(state, location.option);
|
|
26
|
+
} else {
|
|
27
|
+
state.selected.add(optionId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isSelectionValid(document: AskDocument, state: AskState): boolean {
|
|
32
|
+
return document.sections.every((section) => section.blocks.every((block) => {
|
|
33
|
+
if (block.type !== "group") return true;
|
|
34
|
+
return isGroupValid(block, state);
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildAnswerMarkdown(document: AskDocument, state: AskState): string {
|
|
39
|
+
const sections = document.sections
|
|
40
|
+
.map((section) => renderSection(section, state))
|
|
41
|
+
.filter((lines) => lines.length > 0);
|
|
42
|
+
|
|
43
|
+
return sections.map((lines) => lines.join("\n")).join("\n\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isOptionActive(document: AskDocument, state: AskState, optionId: string): boolean {
|
|
47
|
+
for (const section of document.sections) {
|
|
48
|
+
for (const block of section.blocks) {
|
|
49
|
+
if (block.type === "group" && optionActiveInGroup(block, state, optionId, true)) return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isGroupValid(group: OptionGroup, state: AskState): boolean {
|
|
57
|
+
const selectedOptions = group.options.filter((option) => state.selected.has(option.id));
|
|
58
|
+
|
|
59
|
+
if (group.mode === "single" && selectedOptions.length !== 1) return false;
|
|
60
|
+
if (group.mode === "multi" && selectedOptions.length < 1) return false;
|
|
61
|
+
|
|
62
|
+
return selectedOptions.every((option) => option.children.every((childGroup) => isGroupValid(childGroup, state)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderSection(section: AskSection, state: AskState): string[] {
|
|
66
|
+
const body = section.blocks.flatMap((block) => renderBlock(block, state, 0));
|
|
67
|
+
if (body.length === 0) return [];
|
|
68
|
+
|
|
69
|
+
if (!section.heading) return body;
|
|
70
|
+
|
|
71
|
+
return [`${"#".repeat(section.heading.level)} ${section.heading.text}`, ...body];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function renderBlock(block: AskBlock, state: AskState, depth: number): string[] {
|
|
75
|
+
if (block.type === "text") return [];
|
|
76
|
+
return renderGroup(block, state, depth);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderGroup(group: OptionGroup, state: AskState, depth: number): string[] {
|
|
80
|
+
return group.options.flatMap((option) => {
|
|
81
|
+
if (!state.selected.has(option.id)) return [];
|
|
82
|
+
|
|
83
|
+
const prefix = group.mode === "multi" ? "- [x]" : "-";
|
|
84
|
+
const indent = " ".repeat(depth);
|
|
85
|
+
const lines = [`${indent}${prefix} ${option.label}`];
|
|
86
|
+
|
|
87
|
+
for (const childGroup of option.children) {
|
|
88
|
+
lines.push(...renderGroup(childGroup, state, depth + 1));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return lines;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function optionActiveInGroup(group: OptionGroup, state: AskState, optionId: string, active: boolean): boolean {
|
|
96
|
+
for (const option of group.options) {
|
|
97
|
+
if (option.id === optionId) return active;
|
|
98
|
+
|
|
99
|
+
const childActive = active && state.selected.has(option.id);
|
|
100
|
+
for (const childGroup of option.children) {
|
|
101
|
+
if (optionActiveInGroup(childGroup, state, optionId, childActive)) return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findOption(document: AskDocument, optionId: string): { group: OptionGroup; option: AskOption } | undefined {
|
|
109
|
+
for (const section of document.sections) {
|
|
110
|
+
for (const block of section.blocks) {
|
|
111
|
+
if (block.type !== "group") continue;
|
|
112
|
+
const result = findOptionInGroup(block, optionId);
|
|
113
|
+
if (result) return result;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function findOptionInGroup(group: OptionGroup, optionId: string): { group: OptionGroup; option: AskOption } | undefined {
|
|
121
|
+
for (const option of group.options) {
|
|
122
|
+
if (option.id === optionId) return { group, option };
|
|
123
|
+
|
|
124
|
+
for (const childGroup of option.children) {
|
|
125
|
+
const result = findOptionInGroup(childGroup, optionId);
|
|
126
|
+
if (result) return result;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function clearOptionAndChildren(state: AskState, option: AskOption): void {
|
|
134
|
+
state.selected.delete(option.id);
|
|
135
|
+
|
|
136
|
+
for (const childGroup of option.children) {
|
|
137
|
+
for (const child of childGroup.options) {
|
|
138
|
+
clearOptionAndChildren(state, child);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type GroupMode = "single" | "multi";
|
|
2
|
+
|
|
3
|
+
export interface AskDocument {
|
|
4
|
+
sections: AskSection[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface AskSection {
|
|
8
|
+
heading?: AskHeading;
|
|
9
|
+
blocks: AskBlock[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AskHeading {
|
|
13
|
+
level: number;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AskBlock = TextBlock | OptionGroup;
|
|
18
|
+
|
|
19
|
+
export interface TextBlock {
|
|
20
|
+
type: "text";
|
|
21
|
+
lines: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OptionGroup {
|
|
25
|
+
type: "group";
|
|
26
|
+
mode: GroupMode;
|
|
27
|
+
options: AskOption[];
|
|
28
|
+
required: true;
|
|
29
|
+
indent: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AskOption {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
rawLabel: string;
|
|
36
|
+
indent: number;
|
|
37
|
+
line: number;
|
|
38
|
+
children: OptionGroup[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AskState {
|
|
42
|
+
selected: Set<string>;
|
|
43
|
+
draftMode: boolean;
|
|
44
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { buildAnswerMarkdown, createAskState, isOptionActive, isSelectionValid, selectOption } from "./state.js";
|
|
5
|
+
import type { AskBlock, AskDocument, AskOption, AskSection, OptionGroup, TextBlock } from "./types.js";
|
|
6
|
+
|
|
7
|
+
interface OptionRow {
|
|
8
|
+
option: AskOption;
|
|
9
|
+
group: OptionGroup;
|
|
10
|
+
depth: number;
|
|
11
|
+
active: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RenderRow {
|
|
15
|
+
text: string;
|
|
16
|
+
optionId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const maxBodyLines = 24;
|
|
20
|
+
|
|
21
|
+
export async function runAskUi(ctx: ExtensionContext, document: AskDocument): Promise<string> {
|
|
22
|
+
return ctx.ui.custom<string>((tui, theme, _keybindings, done) => {
|
|
23
|
+
const state = createAskState();
|
|
24
|
+
let focusedId = firstActiveOptionId(document, state);
|
|
25
|
+
let editMode = focusedId === undefined;
|
|
26
|
+
let scrollOffset = 0;
|
|
27
|
+
let cachedWidth: number | undefined;
|
|
28
|
+
let cachedLines: string[] | undefined;
|
|
29
|
+
|
|
30
|
+
const editorTheme: EditorTheme = {
|
|
31
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
32
|
+
selectList: {
|
|
33
|
+
selectedPrefix: (s) => theme.fg("accent", s),
|
|
34
|
+
selectedText: (s) => theme.fg("accent", s),
|
|
35
|
+
description: (s) => theme.fg("muted", s),
|
|
36
|
+
scrollInfo: (s) => theme.fg("dim", s),
|
|
37
|
+
noMatch: (s) => theme.fg("warning", s),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
const editor = new Editor(tui, editorTheme);
|
|
41
|
+
|
|
42
|
+
editor.onSubmit = (value) => {
|
|
43
|
+
done(value);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function refresh() {
|
|
47
|
+
cachedWidth = undefined;
|
|
48
|
+
cachedLines = undefined;
|
|
49
|
+
tui.requestRender();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureFocus() {
|
|
53
|
+
const rows = collectOptionRows(document, state).filter((row) => row.active);
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
focusedId = undefined;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!focusedId || !rows.some((row) => row.option.id === focusedId)) {
|
|
59
|
+
focusedId = rows[0].option.id;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function moveFocus(delta: number) {
|
|
64
|
+
const rows = collectOptionRows(document, state).filter((row) => row.active);
|
|
65
|
+
if (rows.length === 0) return;
|
|
66
|
+
|
|
67
|
+
const current = Math.max(0, rows.findIndex((row) => row.option.id === focusedId));
|
|
68
|
+
const next = Math.max(0, Math.min(rows.length - 1, current + delta));
|
|
69
|
+
focusedId = rows[next].option.id;
|
|
70
|
+
refresh();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function enterEditMode(prefill: string) {
|
|
74
|
+
editMode = true;
|
|
75
|
+
editor.setText(prefill);
|
|
76
|
+
refresh();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function handleInput(data: string) {
|
|
80
|
+
if (editMode) {
|
|
81
|
+
if (focusedId && matchesKey(data, Key.escape)) {
|
|
82
|
+
editMode = false;
|
|
83
|
+
refresh();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
editor.handleInput(data);
|
|
87
|
+
refresh();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
ensureFocus();
|
|
92
|
+
|
|
93
|
+
if (matchesKey(data, Key.up)) {
|
|
94
|
+
moveFocus(-1);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (matchesKey(data, Key.down)) {
|
|
98
|
+
moveFocus(1);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (matchesKey(data, Key.pageUp)) {
|
|
102
|
+
moveFocus(-8);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (matchesKey(data, Key.pageDown)) {
|
|
106
|
+
moveFocus(8);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (matchesKey(data, "shift+space")) {
|
|
111
|
+
if (focusedId) selectOption(document, state, focusedId, true);
|
|
112
|
+
ensureFocus();
|
|
113
|
+
refresh();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (matchesKey(data, Key.space) || data === " ") {
|
|
118
|
+
if (focusedId) selectOption(document, state, focusedId);
|
|
119
|
+
ensureFocus();
|
|
120
|
+
refresh();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (matchesKey(data, Key.enter)) {
|
|
125
|
+
if (!isSelectionValid(document, state)) return;
|
|
126
|
+
|
|
127
|
+
const answer = buildAnswerMarkdown(document, state);
|
|
128
|
+
if (state.draftMode) {
|
|
129
|
+
enterEditMode(answer);
|
|
130
|
+
} else {
|
|
131
|
+
done(answer);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
137
|
+
enterEditMode("");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function render(width: number): string[] {
|
|
142
|
+
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
143
|
+
|
|
144
|
+
ensureFocus();
|
|
145
|
+
const lines: string[] = [];
|
|
146
|
+
const add = (line: string) => lines.push(truncateToWidth(line, width));
|
|
147
|
+
|
|
148
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
149
|
+
add(theme.fg("accent", " Ask User"));
|
|
150
|
+
add(theme.fg("dim", editMode ? " Freitext/Draft: Enter sendet • Esc zurück zur Auswahl" : " ↑↓ bewegen • Space wählen • Shift+Space Draft • Enter senden • Esc Freitext"));
|
|
151
|
+
lines.push("");
|
|
152
|
+
|
|
153
|
+
const bodyRows = renderDocumentRows(document, state, focusedId, {
|
|
154
|
+
heading: (s) => theme.fg("accent", s),
|
|
155
|
+
text: (s) => theme.fg("text", s),
|
|
156
|
+
muted: (s) => theme.fg("muted", s),
|
|
157
|
+
dim: (s) => theme.fg("dim", s),
|
|
158
|
+
focus: (s) => theme.fg("accent", s),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (editMode) {
|
|
162
|
+
bodyRows.push({ text: "" }, { text: theme.fg("muted", "Antwort:") });
|
|
163
|
+
for (const editorLine of editor.render(Math.max(10, width - 2))) {
|
|
164
|
+
bodyRows.push({ text: ` ${editorLine}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (editMode) {
|
|
169
|
+
scrollOffset = Math.max(0, bodyRows.length - maxBodyLines);
|
|
170
|
+
} else {
|
|
171
|
+
const focusedLine = bodyRows.findIndex((row) => row.optionId === focusedId);
|
|
172
|
+
if (focusedLine >= 0) {
|
|
173
|
+
if (focusedLine < scrollOffset) scrollOffset = focusedLine;
|
|
174
|
+
if (focusedLine >= scrollOffset + maxBodyLines) scrollOffset = focusedLine - maxBodyLines + 1;
|
|
175
|
+
}
|
|
176
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, bodyRows.length - maxBodyLines)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const visibleRows = bodyRows.slice(scrollOffset, scrollOffset + maxBodyLines);
|
|
180
|
+
if (scrollOffset > 0) add(theme.fg("dim", `… ${scrollOffset} Zeilen darüber`));
|
|
181
|
+
for (const row of visibleRows) add(row.text);
|
|
182
|
+
if (scrollOffset + maxBodyLines < bodyRows.length) {
|
|
183
|
+
add(theme.fg("dim", `… ${bodyRows.length - scrollOffset - maxBodyLines} Zeilen darunter`));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
lines.push("");
|
|
187
|
+
add(theme.fg("accent", "─".repeat(width)));
|
|
188
|
+
|
|
189
|
+
cachedWidth = width;
|
|
190
|
+
cachedLines = lines;
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
get focused() {
|
|
196
|
+
return editor.focused;
|
|
197
|
+
},
|
|
198
|
+
set focused(value: boolean) {
|
|
199
|
+
editor.focused = value;
|
|
200
|
+
},
|
|
201
|
+
render,
|
|
202
|
+
invalidate: () => {
|
|
203
|
+
cachedWidth = undefined;
|
|
204
|
+
cachedLines = undefined;
|
|
205
|
+
editor.invalidate();
|
|
206
|
+
},
|
|
207
|
+
handleInput,
|
|
208
|
+
};
|
|
209
|
+
}, {
|
|
210
|
+
overlay: true,
|
|
211
|
+
overlayOptions: {
|
|
212
|
+
width: "100%",
|
|
213
|
+
minWidth: 50,
|
|
214
|
+
maxHeight: "65%",
|
|
215
|
+
anchor: "bottom-center",
|
|
216
|
+
offsetY: -2,
|
|
217
|
+
margin: 1,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function collectOptionRows(document: AskDocument, state: ReturnType<typeof createAskState>): OptionRow[] {
|
|
223
|
+
return document.sections.flatMap((section) => section.blocks.flatMap((block) => collectBlockRows(document, state, block, 0)));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function collectBlockRows(document: AskDocument, state: ReturnType<typeof createAskState>, block: AskBlock, depth: number): OptionRow[] {
|
|
227
|
+
if (block.type !== "group") return [];
|
|
228
|
+
return collectGroupRows(document, state, block, depth);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function collectGroupRows(document: AskDocument, state: ReturnType<typeof createAskState>, group: OptionGroup, depth: number): OptionRow[] {
|
|
232
|
+
return group.options.flatMap((option) => {
|
|
233
|
+
const active = isOptionActive(document, state, option.id);
|
|
234
|
+
return [
|
|
235
|
+
{ option, group, depth, active },
|
|
236
|
+
...option.children.flatMap((childGroup) => collectGroupRows(document, state, childGroup, depth + 1)),
|
|
237
|
+
];
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function firstActiveOptionId(document: AskDocument, state: ReturnType<typeof createAskState>): string | undefined {
|
|
242
|
+
return collectOptionRows(document, state).find((row) => row.active)?.option.id;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderDocumentRows(
|
|
246
|
+
document: AskDocument,
|
|
247
|
+
state: ReturnType<typeof createAskState>,
|
|
248
|
+
focusedId: string | undefined,
|
|
249
|
+
style: {
|
|
250
|
+
heading: (s: string) => string;
|
|
251
|
+
text: (s: string) => string;
|
|
252
|
+
muted: (s: string) => string;
|
|
253
|
+
dim: (s: string) => string;
|
|
254
|
+
focus: (s: string) => string;
|
|
255
|
+
},
|
|
256
|
+
): RenderRow[] {
|
|
257
|
+
return document.sections.flatMap((section) => renderSectionRows(document, state, section, focusedId, style));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderSectionRows(
|
|
261
|
+
document: AskDocument,
|
|
262
|
+
state: ReturnType<typeof createAskState>,
|
|
263
|
+
section: AskSection,
|
|
264
|
+
focusedId: string | undefined,
|
|
265
|
+
style: Parameters<typeof renderDocumentRows>[3],
|
|
266
|
+
): RenderRow[] {
|
|
267
|
+
const rows: RenderRow[] = [];
|
|
268
|
+
if (section.heading) rows.push({ text: style.heading(`${"#".repeat(section.heading.level)} ${section.heading.text}`) });
|
|
269
|
+
|
|
270
|
+
for (const block of section.blocks) {
|
|
271
|
+
if (block.type === "text") {
|
|
272
|
+
rows.push(...renderTextRows(block, style));
|
|
273
|
+
} else {
|
|
274
|
+
rows.push(...renderGroupRows(document, state, block, focusedId, 0, style));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return rows;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function renderTextRows(block: TextBlock, style: Parameters<typeof renderDocumentRows>[3]): RenderRow[] {
|
|
282
|
+
return block.lines.map((line) => ({ text: line.trim() ? style.text(line) : "" }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderGroupRows(
|
|
286
|
+
document: AskDocument,
|
|
287
|
+
state: ReturnType<typeof createAskState>,
|
|
288
|
+
group: OptionGroup,
|
|
289
|
+
focusedId: string | undefined,
|
|
290
|
+
depth: number,
|
|
291
|
+
style: Parameters<typeof renderDocumentRows>[3],
|
|
292
|
+
): RenderRow[] {
|
|
293
|
+
return group.options.flatMap((option) => {
|
|
294
|
+
const active = isOptionActive(document, state, option.id);
|
|
295
|
+
const selected = state.selected.has(option.id);
|
|
296
|
+
const focused = active && option.id === focusedId;
|
|
297
|
+
const marker = group.mode === "multi" ? (selected ? "[x]" : "[ ]") : (selected ? "(•)" : "( )");
|
|
298
|
+
const cursor = focused ? ">" : " ";
|
|
299
|
+
const indent = " ".repeat(depth);
|
|
300
|
+
const raw = `${cursor} ${indent}${marker} ${option.label}`;
|
|
301
|
+
const text = !active ? style.dim(raw) : focused ? style.focus(raw) : style.muted(raw);
|
|
302
|
+
|
|
303
|
+
return [
|
|
304
|
+
{ text, optionId: option.id },
|
|
305
|
+
...option.children.flatMap((childGroup) => renderGroupRows(document, state, childGroup, focusedId, depth + 1, style)),
|
|
306
|
+
];
|
|
307
|
+
});
|
|
308
|
+
}
|