@hheei/pi-ask-user 0.0.0-stage → 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.
Files changed (53) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/dist/ask-prompt.d.ts +60 -0
  4. package/dist/ask-prompt.js +360 -0
  5. package/dist/batch-prompt.d.ts +42 -0
  6. package/dist/batch-prompt.js +192 -0
  7. package/dist/constants.d.ts +15 -0
  8. package/dist/constants.js +17 -0
  9. package/dist/custom-prompt.d.ts +20 -0
  10. package/dist/custom-prompt.js +56 -0
  11. package/dist/dialogs.d.ts +25 -0
  12. package/dist/dialogs.js +93 -0
  13. package/dist/events.d.ts +23 -0
  14. package/dist/events.js +22 -0
  15. package/dist/execute.d.ts +5 -0
  16. package/dist/execute.js +157 -0
  17. package/dist/format.d.ts +18 -0
  18. package/dist/format.js +84 -0
  19. package/dist/index.d.ts +2 -0
  20. package/dist/index.js +6 -0
  21. package/dist/multi-select-list.d.ts +25 -0
  22. package/dist/multi-select-list.js +155 -0
  23. package/dist/option-rows.d.ts +33 -0
  24. package/dist/option-rows.js +89 -0
  25. package/dist/preferences.d.ts +8 -0
  26. package/dist/preferences.js +25 -0
  27. package/dist/presentation.d.ts +6 -0
  28. package/dist/presentation.js +129 -0
  29. package/dist/prompt-context.d.ts +26 -0
  30. package/dist/prompt-context.js +68 -0
  31. package/dist/prompt-help.d.ts +23 -0
  32. package/dist/prompt-help.js +50 -0
  33. package/dist/prompt-layout.d.ts +43 -0
  34. package/dist/prompt-layout.js +89 -0
  35. package/dist/prompt-overflow.d.ts +5 -0
  36. package/dist/prompt-overflow.js +59 -0
  37. package/dist/response.d.ts +46 -0
  38. package/dist/response.js +31 -0
  39. package/dist/schema.d.ts +43 -0
  40. package/dist/schema.js +87 -0
  41. package/dist/settings-provider.d.ts +15 -0
  42. package/dist/settings-provider.js +42 -0
  43. package/dist/shortcuts.d.ts +7 -0
  44. package/dist/shortcuts.js +21 -0
  45. package/dist/single-select-layout.d.ts +19 -0
  46. package/dist/single-select-layout.js +61 -0
  47. package/dist/single-select-list.d.ts +23 -0
  48. package/dist/single-select-list.js +99 -0
  49. package/dist/tool.d.ts +2 -0
  50. package/dist/tool.js +91 -0
  51. package/package.json +59 -5
  52. package/skills/ask-user/SKILL.md +189 -0
  53. package/skills/ask-user/references/ask-user-skill-extension-spec.md +106 -0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Enzo Lucchesi
4
+ Copyright (c) 2026 hheei
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,69 @@
1
- # Temporary Holding Version
1
+ # @hheei/pi-ask-user
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Interactive `ask_user` tool for pi: one focused question, or 2-4 independent questions in one prompt, with multiple choice, multi-select, and freeform answers.
4
+
5
+ Forked from [`edlsh/pi-ask-user`](https://github.com/edlsh/pi-ask-user) at revision `df1df70` (v0.17.1). Upstream is MIT licensed; see [LICENSE](./LICENSE).
6
+
7
+ ## What the fork changes
8
+
9
+ - Ships compiled `dist/` through `tsc` instead of raw TypeScript, matching the other `@hheei/pi-*` packages.
10
+ - Uses the `typebox` package instead of `@sinclair/typebox`.
11
+ - Tests run on vitest instead of `bun test`.
12
+ - Code conforms to this repo's Biome and `exactOptionalPropertyTypes` rules.
13
+ - Reads preferences through `@hheei/pi-ext-core` from the `askUser` section of `ext_settings.json`, so project `.pi/ext_settings.json` can override the global file. Upstream's own `ask-user.json` and the `PI_ASK_USER_*` environment layer are gone.
14
+ - Drops upstream's searchable split-pane list, optional comment stage, overlay hide shortcut, review-page scrolling, and `ctrl+j`/`ctrl+k` vim aliases: the prompt now matches pi's own select-list keys.
15
+ - Drops the upstream `pi-tern` native-presentation integration. Prompts render through pi's own TUI components, and results publish presentation facts that `@hheei/pi-ext-ui` frames with its 0/1/2 fold levels when it is installed.
16
+ - Registers an `ask_user` settings page through `@hheei/pi-ext-core`, so the preferences below are editable in the host settings UI instead of by hand.
17
+
18
+ Module boundaries, invariants, and the test layout are recorded in
19
+ [`docs/architecture/ask-user.md`](../../docs/architecture/ask-user.md).
20
+
21
+ The batch contract is unchanged: `context`, `options`, `allowMultiple`, and `allowFreeform` belong on each `questions` entry, never at the top level. Top-level copies are ignored rather than rejected, because strict providers fill every declared property with an empty default. A one-entry `questions` array is unwrapped and asked as a single question instead of failing.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pi install npm:@hheei/pi-ask-user
27
+ ```
28
+
29
+ ## Tool
30
+
31
+ | Parameter | Type | Default | Notes |
32
+ | --- | --- | --- | --- |
33
+ | `question` | `string?` | — | The question. With `questions`, it is only the batch heading |
34
+ | `questions` | `{question, context?, options?, allowMultiple?, allowFreeform?}[]?` | — | 2-4 independent questions, used instead of `question` |
35
+ | `context` | `string?` | — | Summary shown above the question |
36
+ | `options` | `{title, description?}[]?` | — | Choices offered to the user |
37
+ | `allowMultiple` | `boolean?` | `false` | Allow selecting several options |
38
+ | `allowFreeform` | `boolean?` | `true` | Add a freeform answer row |
39
+ | `displayMode` | `"overlay" \| "inline"` | `overlay` | Centered modal or in-place prompt |
40
+ | `timeout_ms` | `integer?` | — | Auto-dismiss after N milliseconds; the result is cancelled |
41
+
42
+ ## Keys
43
+
44
+ | Context | Keys |
45
+ | --- | --- |
46
+ | List | `↑`/`↓`, `tab`/`shift+tab`, `1`-`9` jump or toggle |
47
+ | Single select | `enter` choose (on the freeform row: open the editor) |
48
+ | Multi select | `space` tick, `enter` submit |
49
+ | Editor | `enter` submit, `shift+enter`/`ctrl+j` newline, `esc` back or cancel |
50
+ | Prompt | `ctrl+e` expand context, `PgUp`/`PgDn`/`Home`/`End` scroll (overlay) |
51
+ | Batch | `tab`/`shift+tab` pages, `1`-`N` jump from review, `enter` submit, `esc` cancel |
52
+ | Anywhere | `esc`/`ctrl+c` cancel |
53
+
54
+ ## Preferences
55
+
56
+ One setting, editable from the host settings UI or by hand: `displayMode` in the `askUser` section of `<agent-dir>/ext_settings.json` (project `.pi/ext_settings.json` wins), read on every call. A call parameter overrides it.
57
+
58
+ `ask:answered` and `ask:cancelled` events carry only the question and the response kind; context and the actual answer stay in the tool result.
59
+
60
+ ## Skill
61
+
62
+ The package bundles the `ask-user` skill, which turns `ask_user` into a gate before high-stakes or ambiguous decisions. It is installed with the package; remove `skills/` from your package list if you only want the tool.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pnpm --filter @hheei/pi-ask-user run build
68
+ pnpm exec vitest run packages/pi-ask-user/test
69
+ ```
@@ -0,0 +1,60 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui";
3
+ import type { PromptSettings } from "./preferences";
4
+ import { type AskUIResult } from "./response";
5
+ import type { BatchQuestion } from "./schema";
6
+ /**
7
+ * Interactive ask UI. Uses a root Container for layout and swaps the center
8
+ * component between the option list and an Editor (freeform mode).
9
+ */
10
+ export declare class AskComponent extends Container {
11
+ private readonly prompt;
12
+ private readonly settings;
13
+ private readonly contextPane;
14
+ private readonly tui;
15
+ private readonly theme;
16
+ private readonly keybindings;
17
+ private readonly onDone;
18
+ private mode;
19
+ private chrome;
20
+ private freeformDraft;
21
+ private promptScrollOffset;
22
+ private promptMaxScrollOffset;
23
+ private promptViewportRows;
24
+ private get contextIsCollapsible();
25
+ private get contextExpanded();
26
+ private questionText;
27
+ private modeContainer;
28
+ private optionListValue?;
29
+ private editor?;
30
+ private _focused;
31
+ get focused(): boolean;
32
+ set focused(value: boolean);
33
+ constructor(prompt: BatchQuestion, settings: PromptSettings, tui: TUI, theme: Theme, keybindings: KeybindingsManager, onDone: (result: AskUIResult | null) => void);
34
+ invalidate(): void;
35
+ render(width: number): string[];
36
+ private renderInlineLayout;
37
+ private renderOverlayLayout;
38
+ private buildPromptLines;
39
+ private renderModeLines;
40
+ private renderEditorModeLines;
41
+ /** Header shared by the container children (inline) and the overlay budget path. */
42
+ private editorHeader;
43
+ private renderPromptPane;
44
+ private updateStaticText;
45
+ /** Batch pages: show the question strip and the page-navigation hint above the question. */
46
+ setBatchChrome(frameTitle: string, navigationHint: string): void;
47
+ /** The hint row for the current mode; `promptScrollable` only applies where the pane can clip. */
48
+ private helpLines;
49
+ /** The select-mode body; both lists share the wiring and differ in the selection shape. */
50
+ private get optionList();
51
+ private ensureEditor;
52
+ private saveEditorDraft;
53
+ private showSelectMode;
54
+ private showEditorMode;
55
+ private toggleContext;
56
+ private setPromptScrollOffset;
57
+ private handlePromptScrollInput;
58
+ private scrollPromptBy;
59
+ handleInput(data: string): void;
60
+ }
@@ -0,0 +1,360 @@
1
+ import { Container, Editor, Key, matchesKey, Spacer, Text, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
2
+ import { INLINE_CONTEXT_MAX_ROWS } from "./constants";
3
+ import { MultiSelectList } from "./multi-select-list";
4
+ import { PromptContextPane } from "./prompt-context";
5
+ import { buildHelpText } from "./prompt-help";
6
+ import { contextShouldCollapse, getOverlayMaxRenderLinesForRows, planOverlayBody, RAIL_ROWS, railRow, } from "./prompt-layout";
7
+ import { applyOverflowMarkers, limitEditorLines, limitLines } from "./prompt-overflow";
8
+ import { createFreeformResponse, createSelectionResponse } from "./response";
9
+ import { pageDelta } from "./shortcuts";
10
+ import { WrappedSingleSelectList } from "./single-select-list";
11
+ function editorTheme(theme) {
12
+ return {
13
+ borderColor: (text) => theme.fg("accent", text),
14
+ selectList: {
15
+ selectedPrefix: (text) => theme.fg("accent", text),
16
+ selectedText: (text) => theme.fg("accent", text),
17
+ description: (text) => theme.fg("muted", text),
18
+ scrollInfo: (text) => theme.fg("dim", text),
19
+ noMatch: (text) => theme.fg("warning", text),
20
+ },
21
+ };
22
+ }
23
+ /**
24
+ * Interactive ask UI. Uses a root Container for layout and swaps the center
25
+ * component between the option list and an Editor (freeform mode).
26
+ */
27
+ export class AskComponent extends Container {
28
+ prompt;
29
+ settings;
30
+ contextPane;
31
+ tui;
32
+ theme;
33
+ keybindings;
34
+ onDone;
35
+ mode = "select";
36
+ chrome = "";
37
+ freeformDraft = "";
38
+ promptScrollOffset = 0;
39
+ promptMaxScrollOffset = 0;
40
+ promptViewportRows = 0;
41
+ get contextIsCollapsible() {
42
+ return this.contextPane.collapsible;
43
+ }
44
+ get contextExpanded() {
45
+ return this.contextPane.expanded;
46
+ }
47
+ // Static layout components
48
+ questionText;
49
+ modeContainer;
50
+ // Mode components
51
+ optionListValue;
52
+ editor;
53
+ // Focusable - propagate to Editor for IME cursor positioning
54
+ _focused = false;
55
+ get focused() {
56
+ return this._focused;
57
+ }
58
+ set focused(value) {
59
+ this._focused = value;
60
+ if (this.editor && this.mode === "freeform") {
61
+ this.editor.focused = value;
62
+ }
63
+ }
64
+ constructor(prompt, settings, tui, theme, keybindings, onDone) {
65
+ super();
66
+ this.prompt = prompt;
67
+ this.settings = settings;
68
+ this.tui = tui;
69
+ this.theme = theme;
70
+ this.keybindings = keybindings;
71
+ this.onDone = onDone;
72
+ this.questionText = new Text("", 1, 0);
73
+ this.addChild(this.questionText);
74
+ this.contextPane = new PromptContextPane(prompt.context, theme, () => this.invalidate());
75
+ if (this.contextPane.component) {
76
+ this.addChild(new Spacer(1));
77
+ this.addChild(this.contextPane.component);
78
+ }
79
+ this.addChild(new Spacer(1));
80
+ this.modeContainer = new Container();
81
+ this.addChild(this.modeContainer);
82
+ this.updateStaticText();
83
+ // A question without options opens straight in the editor, like the
84
+ // input dialog a single question without options uses.
85
+ if (this.prompt.options.length === 0) {
86
+ this.showEditorMode();
87
+ }
88
+ else {
89
+ this.showSelectMode();
90
+ }
91
+ }
92
+ invalidate() {
93
+ super.invalidate();
94
+ this.updateStaticText();
95
+ }
96
+ render(width) {
97
+ if (this.settings.displayMode === "overlay")
98
+ return this.renderOverlayLayout(width);
99
+ if (this.mode === "select")
100
+ this.optionList.setMaxVisibleRows(12);
101
+ return this.renderInlineLayout(width);
102
+ }
103
+ renderInlineLayout(width) {
104
+ const fullContextLines = this.contextPane.fullLines(width);
105
+ this.contextPane.setCollapsible(fullContextLines.length > INLINE_CONTEXT_MAX_ROWS);
106
+ return [
107
+ railRow(this.theme, width),
108
+ ...this.buildPromptLines(width, fullContextLines),
109
+ "",
110
+ ...this.modeContainer.render(width),
111
+ railRow(this.theme, width),
112
+ ...this.helpLines(width, false),
113
+ ];
114
+ }
115
+ renderOverlayLayout(width) {
116
+ const maxLines = getOverlayMaxRenderLinesForRows(this.tui.terminal.rows);
117
+ if (maxLines <= 0)
118
+ return [];
119
+ const bodyCapacity = Math.max(0, maxLines - RAIL_ROWS);
120
+ let helpLines = this.helpLines(width, false);
121
+ const questionLines = this.questionText.render(width);
122
+ const fullContextLines = this.contextPane.fullLines(width);
123
+ this.contextPane.setCollapsible(contextShouldCollapse({
124
+ displayMode: this.settings.displayMode,
125
+ mode: this.mode,
126
+ contextCollapsible: this.contextIsCollapsible,
127
+ questionRows: questionLines.length,
128
+ contextRows: fullContextLines.length,
129
+ bodyCapacity,
130
+ helpRows: helpLines.length,
131
+ }));
132
+ const promptLines = this.buildPromptLines(width, fullContextLines);
133
+ const plan = planOverlayBody({
134
+ maxRenderLines: bodyCapacity,
135
+ mode: this.mode,
136
+ contextCollapsible: this.contextIsCollapsible,
137
+ promptRows: promptLines.length,
138
+ helpRows: helpLines.length,
139
+ });
140
+ const modeLines = this.renderModeLines(width, plan.modeBudget);
141
+ const promptBudget = plan.promptBudget + Math.max(0, plan.modeBudget - modeLines.length);
142
+ const promptPaneLines = this.renderPromptPane(promptLines, promptBudget, width);
143
+ helpLines = this.helpLines(width, promptLines.length > promptBudget);
144
+ const visibleHelpLines = limitLines(this.theme, helpLines, plan.helpBudget, width, false);
145
+ const contentLines = [
146
+ ...promptPaneLines,
147
+ ...(plan.separatorRows > 0 && promptPaneLines.length > 0 && modeLines.length > 0 ? [""] : []),
148
+ ...modeLines,
149
+ ];
150
+ return [
151
+ railRow(this.theme, width),
152
+ ...contentLines.slice(0, plan.bodyCapacity),
153
+ railRow(this.theme, width),
154
+ ...visibleHelpLines,
155
+ ].slice(0, maxLines);
156
+ }
157
+ buildPromptLines(width, fullContextLines) {
158
+ const questionLines = this.questionText.render(width);
159
+ const contextLines = this.contextPane.displayLines(fullContextLines, width);
160
+ const contextSeparator = this.contextIsCollapsible && !this.contextExpanded ? [] : [""];
161
+ return [
162
+ ...questionLines,
163
+ ...(contextLines.length > 0 ? [...contextSeparator, ...contextLines] : []),
164
+ ];
165
+ }
166
+ renderModeLines(width, budget) {
167
+ const safeBudget = Math.max(0, Math.floor(budget));
168
+ if (safeBudget <= 0)
169
+ return [];
170
+ if (this.mode === "select") {
171
+ this.optionList.setMaxVisibleRows(Math.max(1, safeBudget));
172
+ return limitLines(this.theme, this.modeContainer.render(width), safeBudget, width, true);
173
+ }
174
+ return this.renderEditorModeLines(width, safeBudget);
175
+ }
176
+ renderEditorModeLines(width, budget) {
177
+ const headerLines = [...new Text(this.editorHeader(), 1, 0).render(width), ""];
178
+ const minimumEditorRows = Math.min(3, budget);
179
+ const headerBudget = Math.max(0, budget - minimumEditorRows);
180
+ const visibleHeaderLines = limitLines(this.theme, headerLines, headerBudget, width, true);
181
+ const editorBudget = Math.max(0, budget - visibleHeaderLines.length);
182
+ return [
183
+ ...visibleHeaderLines,
184
+ ...limitEditorLines(this.theme, this.ensureEditor().render(width), editorBudget, width),
185
+ ];
186
+ }
187
+ /** Header shared by the container children (inline) and the overlay budget path. */
188
+ editorHeader() {
189
+ return this.theme.fg("accent", this.theme.bold("Custom response"));
190
+ }
191
+ renderPromptPane(promptLines, budget, width) {
192
+ const viewportRows = Math.max(0, Math.floor(budget));
193
+ this.promptViewportRows = viewportRows;
194
+ if (viewportRows <= 0 || promptLines.length === 0) {
195
+ this.promptMaxScrollOffset = 0;
196
+ this.promptScrollOffset = 0;
197
+ return [];
198
+ }
199
+ this.promptMaxScrollOffset = Math.max(0, promptLines.length - viewportRows);
200
+ this.promptScrollOffset = Math.max(0, Math.min(this.promptScrollOffset, this.promptMaxScrollOffset));
201
+ const visibleLines = promptLines.slice(this.promptScrollOffset, this.promptScrollOffset + viewportRows);
202
+ const hasHiddenAbove = this.promptScrollOffset > 0;
203
+ const hasHiddenBelow = this.promptScrollOffset + viewportRows < promptLines.length;
204
+ return applyOverflowMarkers(this.theme, visibleLines, width, hasHiddenAbove, hasHiddenBelow);
205
+ }
206
+ updateStaticText() {
207
+ const theme = this.theme;
208
+ const question = theme.fg("text", theme.bold(this.prompt.question));
209
+ this.questionText.setText(this.chrome ? `${this.chrome}\n${question}` : question);
210
+ this.contextPane.updateText();
211
+ }
212
+ /** Batch pages: show the question strip and the page-navigation hint above the question. */
213
+ setBatchChrome(frameTitle, navigationHint) {
214
+ const theme = this.theme;
215
+ this.chrome = `${theme.fg("dim", frameTitle)} ${theme.fg("dim", navigationHint)}`;
216
+ this.updateStaticText();
217
+ }
218
+ /** The hint row for the current mode; `promptScrollable` only applies where the pane can clip. */
219
+ helpLines(width, promptScrollable) {
220
+ return wrapTextWithAnsi(buildHelpText({
221
+ theme: this.theme,
222
+ mode: this.mode,
223
+ displayMode: this.settings.displayMode,
224
+ allowMultiple: this.prompt.allowMultiple,
225
+ hasOptions: this.prompt.options.length > 0,
226
+ contextCollapsible: this.contextIsCollapsible,
227
+ contextToggleKey: this.contextPane.toggleKey(),
228
+ promptScrollable,
229
+ keybindings: this.keybindings,
230
+ }), width);
231
+ }
232
+ /** The select-mode body; both lists share the wiring and differ in the selection shape. */
233
+ get optionList() {
234
+ if (this.optionListValue)
235
+ return this.optionListValue;
236
+ if (this.prompt.allowMultiple) {
237
+ const list = new MultiSelectList(this.prompt.options, this.prompt.allowFreeform, this.theme, this.keybindings);
238
+ list.onSubmit = (result) => this.onDone(createSelectionResponse(result));
239
+ this.optionListValue = list;
240
+ }
241
+ else {
242
+ const list = new WrappedSingleSelectList(this.prompt.options, this.prompt.allowFreeform, this.theme, this.keybindings);
243
+ list.onSubmit = (result) => this.onDone(createSelectionResponse([result]));
244
+ this.optionListValue = list;
245
+ }
246
+ this.optionListValue.onCancel = () => this.onDone(null);
247
+ this.optionListValue.onEnterFreeform = () => this.showEditorMode();
248
+ return this.optionListValue;
249
+ }
250
+ ensureEditor() {
251
+ if (this.editor)
252
+ return this.editor;
253
+ const editor = new Editor(this.tui, editorTheme(this.theme));
254
+ editor.disableSubmit = false;
255
+ editor.onSubmit = (text) => {
256
+ this.onDone(createFreeformResponse(text));
257
+ };
258
+ this.editor = editor;
259
+ return editor;
260
+ }
261
+ saveEditorDraft() {
262
+ if (!this.editor)
263
+ return;
264
+ this.freeformDraft = this.editor.getText();
265
+ }
266
+ showSelectMode() {
267
+ if (this.mode === "freeform")
268
+ this.saveEditorDraft();
269
+ this.mode = "select";
270
+ this.modeContainer.clear();
271
+ this.modeContainer.addChild(this.optionList);
272
+ this.invalidate();
273
+ this.tui.requestRender();
274
+ }
275
+ showEditorMode() {
276
+ if (this.mode === "freeform")
277
+ this.saveEditorDraft();
278
+ this.mode = "freeform";
279
+ this.modeContainer.clear();
280
+ const editor = this.ensureEditor();
281
+ editor.setText(this.freeformDraft);
282
+ editor.focused = this._focused;
283
+ this.modeContainer.addChild(new Text(this.editorHeader(), 1, 0));
284
+ this.modeContainer.addChild(new Spacer(1));
285
+ this.modeContainer.addChild(editor);
286
+ this.invalidate();
287
+ this.tui.requestRender();
288
+ }
289
+ toggleContext() {
290
+ if (this.mode !== "select")
291
+ return false;
292
+ if (!this.contextPane.toggle())
293
+ return false;
294
+ this.promptScrollOffset = 0;
295
+ this.invalidate();
296
+ this.tui.requestRender();
297
+ return true;
298
+ }
299
+ setPromptScrollOffset(nextOffset) {
300
+ this.promptScrollOffset = Math.max(0, Math.min(Math.floor(nextOffset), this.promptMaxScrollOffset));
301
+ }
302
+ handlePromptScrollInput(data) {
303
+ if (this.settings.displayMode !== "overlay")
304
+ return false;
305
+ if (this.promptMaxScrollOffset <= 0)
306
+ return false;
307
+ // Prompt scrolling is select-mode only: in freeform mode the editor owns
308
+ // PageUp/PageDown (tui.editor.pageUp/pageDown) for paging through long
309
+ // input, so intercepting them here would steal editor keys.
310
+ if (this.mode !== "select")
311
+ return false;
312
+ const pageRows = Math.max(1, this.promptViewportRows - 1);
313
+ const halfPageRows = Math.max(1, Math.floor(this.promptViewportRows / 2));
314
+ const delta = pageDelta(data, pageRows);
315
+ if (delta !== 0)
316
+ return this.scrollPromptBy(delta);
317
+ if (matchesKey(data, Key.home))
318
+ return this.scrollPromptBy(-this.promptScrollOffset);
319
+ if (matchesKey(data, Key.end))
320
+ return this.scrollPromptBy(this.promptMaxScrollOffset);
321
+ if (matchesKey(data, Key.ctrl("u")))
322
+ return this.scrollPromptBy(-halfPageRows);
323
+ if (matchesKey(data, Key.ctrl("d")))
324
+ return this.scrollPromptBy(halfPageRows);
325
+ return false;
326
+ }
327
+ scrollPromptBy(delta) {
328
+ this.setPromptScrollOffset(this.promptScrollOffset + delta);
329
+ return true;
330
+ }
331
+ handleInput(data) {
332
+ if (matchesKey(data, this.contextPane.toggleKey()) && this.toggleContext()) {
333
+ return;
334
+ }
335
+ if (this.handlePromptScrollInput(data)) {
336
+ this.tui.requestRender();
337
+ return;
338
+ }
339
+ if (this.mode === "freeform") {
340
+ if (matchesKey(data, Key.escape)) {
341
+ // Without options there is no list to go back to, so esc cancels.
342
+ if (this.prompt.options.length === 0) {
343
+ this.onDone(null);
344
+ return;
345
+ }
346
+ this.showSelectMode();
347
+ return;
348
+ }
349
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
350
+ this.onDone(null);
351
+ return;
352
+ }
353
+ this.ensureEditor().handleInput(data);
354
+ this.tui.requestRender();
355
+ return;
356
+ }
357
+ this.optionList.handleInput(data);
358
+ this.tui.requestRender();
359
+ }
360
+ }
@@ -0,0 +1,42 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type KeybindingsManager, type TUI } from "@earendil-works/pi-tui";
3
+ import type { PromptSettings } from "./preferences";
4
+ import { type BatchAnswer } from "./response";
5
+ import type { BatchQuestion } from "./schema";
6
+ /**
7
+ * One prompt for a whole `questions` batch: a page per question plus a review
8
+ * page. Each page is an AskComponent that stays alive, so drafts and checkboxes
9
+ * survive moving between pages. Answers are recorded per page and only the
10
+ * review page submits; esc that would cancel a page cancels the batch.
11
+ */
12
+ export declare class BatchAskComponent implements Component {
13
+ private questions;
14
+ private settings;
15
+ private tui;
16
+ private theme;
17
+ private keybindings;
18
+ private onDone;
19
+ private heading?;
20
+ private pages;
21
+ private answers;
22
+ private current;
23
+ private title;
24
+ private confirmingSkips;
25
+ private _focused;
26
+ constructor(questions: BatchQuestion[], settings: PromptSettings, tui: TUI, theme: Theme, keybindings: KeybindingsManager, onDone: (answers: BatchAnswer[] | null) => void, heading?: string | undefined);
27
+ get focused(): boolean;
28
+ set focused(value: boolean);
29
+ invalidate(): void;
30
+ render(width: number): string[];
31
+ handleInput(data: string): void;
32
+ private handlePageDone;
33
+ private goTo;
34
+ private updateChrome;
35
+ /** Submit from the review page; with unanswered questions, the first submit asks to confirm. */
36
+ private submitReview;
37
+ private unansweredCount;
38
+ private handleReviewInput;
39
+ private renderReview;
40
+ /** Rows the review page may use, rails included. */
41
+ private reviewLineCap;
42
+ }