pi-ask-popup 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 (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +55 -0
  3. package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
  4. package/package.json +48 -0
  5. package/src/ask-user-question.ts +474 -0
  6. package/src/config.ts +250 -0
  7. package/src/events.ts +107 -0
  8. package/src/index.ts +25 -0
  9. package/src/reconcile.ts +31 -0
  10. package/src/rpc-fallback.ts +198 -0
  11. package/src/state/build-questionnaire.ts +346 -0
  12. package/src/state/external-editor.ts +94 -0
  13. package/src/state/key-router.ts +378 -0
  14. package/src/state/questionnaire-session.ts +382 -0
  15. package/src/state/row-intent.ts +156 -0
  16. package/src/state/selectors/contract.ts +40 -0
  17. package/src/state/selectors/derivations.ts +40 -0
  18. package/src/state/selectors/focus.ts +17 -0
  19. package/src/state/selectors/projections.ts +111 -0
  20. package/src/state/state-reducer.ts +421 -0
  21. package/src/state/state.ts +110 -0
  22. package/src/tool/format-answer.ts +28 -0
  23. package/src/tool/response-envelope.ts +123 -0
  24. package/src/tool/types.ts +193 -0
  25. package/src/tool/validate-questionnaire.ts +74 -0
  26. package/src/view/component-binding.ts +51 -0
  27. package/src/view/components/inline-input.ts +66 -0
  28. package/src/view/components/multi-select-view.ts +208 -0
  29. package/src/view/components/option-list-view.ts +77 -0
  30. package/src/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/src/view/components/preview/preview-block-renderer.ts +116 -0
  32. package/src/view/components/preview/preview-box-renderer.ts +88 -0
  33. package/src/view/components/preview/preview-layout-decider.ts +219 -0
  34. package/src/view/components/preview/preview-pane.ts +240 -0
  35. package/src/view/components/submit-picker.ts +66 -0
  36. package/src/view/components/tab-bar.ts +70 -0
  37. package/src/view/components/wrapping-select.ts +313 -0
  38. package/src/view/dialog-builder.ts +325 -0
  39. package/src/view/props-adapter.ts +124 -0
  40. package/src/view/stateful-view.ts +20 -0
  41. package/src/view/tab-components.ts +16 -0
  42. package/src/view/tab-content-strategy.ts +447 -0
@@ -0,0 +1,346 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Editor, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
+ import type { QuestionData } from "../tool/types.js";
4
+ import {
5
+ type BoundGlobalBinding,
6
+ type BoundPerTabBinding,
7
+ globalBinding,
8
+ perTabBinding,
9
+ } from "../view/component-binding.js";
10
+ import { MultiSelectView } from "../view/components/multi-select-view.js";
11
+ import { OptionListView } from "../view/components/option-list-view.js";
12
+ import { PreviewBlockRenderer } from "../view/components/preview/preview-block-renderer.js";
13
+ import { crossTabLeftWidthWithDonation } from "../view/components/preview/preview-layout-decider.js";
14
+ import { PreviewPane, type PreviewPaneProps } from "../view/components/preview/preview-pane.js";
15
+ import { SubmitPicker } from "../view/components/submit-picker.js";
16
+ import { TabBar } from "../view/components/tab-bar.js";
17
+ import type { WrappingSelectTheme } from "../view/components/wrapping-select.js";
18
+ import { DialogView } from "../view/dialog-builder.js";
19
+ import { QuestionnairePropsAdapter } from "../view/props-adapter.js";
20
+ import type { StatefulView } from "../view/stateful-view.js";
21
+ import type { TabBodyHeights, TabComponents } from "../view/tab-components.js";
22
+ import type { WrappingSelectItem } from "./row-intent.js";
23
+ import type { PerTabSelector } from "./selectors/contract.js";
24
+ import { selectActivePreviewPaneIndex } from "./selectors/derivations.js";
25
+ import {
26
+ selectDialogProps,
27
+ selectMultiSelectProps,
28
+ selectOptionListProps,
29
+ selectPreviewPaneProps,
30
+ selectSubmitPickerProps,
31
+ selectTabBarProps,
32
+ } from "./selectors/projections.js";
33
+ import type { QuestionnaireState } from "./state.js";
34
+
35
+ export interface QuestionnaireBuildConfig {
36
+ tui: TUI;
37
+ theme: Theme;
38
+ questions: readonly QuestionData[];
39
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
40
+ isMulti: boolean;
41
+ initialState: QuestionnaireState;
42
+ getCurrentTab: () => number;
43
+ /**
44
+ * Resolved collapse key. Construction-time config threaded into the dialog so
45
+ * the footer can name the key that is really bound, and deliberately not part
46
+ * of canonical state, which stays free of runtime context.
47
+ */
48
+ collapseKey: string;
49
+ }
50
+
51
+ export interface QuestionnaireBuilt {
52
+ adapter: QuestionnairePropsAdapter;
53
+ notesInput: Editor;
54
+ inlineInput: Editor;
55
+ render: (width: number) => string[];
56
+ invalidate: () => void;
57
+ }
58
+
59
+ interface HeightComputers {
60
+ global: (width: number) => number;
61
+ current: (width: number) => number;
62
+ }
63
+
64
+ function previewBodyHeights(pane: PreviewPane): (width: number) => TabBodyHeights {
65
+ return (width) => {
66
+ const current = pane.naturalHeight(width);
67
+ return { current, max: Math.max(current, pane.maxNaturalHeight(width)) };
68
+ };
69
+ }
70
+
71
+ function multiSelectBodyHeights(view: MultiSelectView): (width: number) => TabBodyHeights {
72
+ return (width) => {
73
+ const height = view.naturalHeight(width);
74
+ return { current: height, max: height };
75
+ };
76
+ }
77
+
78
+ function editorTheme(theme: Theme): EditorTheme {
79
+ return {
80
+ borderColor: (text) => theme.fg("borderMuted", text),
81
+ selectList: {
82
+ selectedPrefix: (text) => theme.bg("selectedBg", theme.fg("accent", text)),
83
+ selectedText: (text) => theme.bg("selectedBg", theme.bold(text)),
84
+ description: (text) => theme.fg("muted", text),
85
+ scrollInfo: (text) => theme.fg("dim", text),
86
+ noMatch: (text) => theme.fg("warning", text),
87
+ },
88
+ };
89
+ }
90
+
91
+ const isActiveTab: PerTabSelector<boolean> = (s, ctx) =>
92
+ ctx.i === selectActivePreviewPaneIndex(s.currentTab, ctx.totalQuestions);
93
+
94
+ /**
95
+ * Assemble every component, the props adapter and a lifecycle handle.
96
+ *
97
+ * Nothing here reads session state directly: the current tab arrives through a
98
+ * getter, and live custom text lives in a headless editor. No selector runs at
99
+ * build time either — the session calls `adapter.apply` once it has the handle,
100
+ * which is what paints the first frame.
101
+ */
102
+ export function buildQuestionnaire(config: QuestionnaireBuildConfig): QuestionnaireBuilt {
103
+ return new QuestionnaireBuilder(config).build();
104
+ }
105
+
106
+ /**
107
+ * One private method per construction step, so `build()` reads as the list of
108
+ * things that have to exist. The class is discarded once it returns the handle.
109
+ */
110
+ class QuestionnaireBuilder {
111
+ private readonly tui: QuestionnaireBuildConfig["tui"];
112
+ private readonly theme: Theme;
113
+ private readonly questions: readonly QuestionData[];
114
+ private readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
115
+ private readonly isMulti: boolean;
116
+ private readonly initialState: QuestionnaireState;
117
+ private readonly getCurrentTab: () => number;
118
+ private readonly collapseKey: string;
119
+
120
+ private readonly selectTheme: WrappingSelectTheme;
121
+ private readonly markdownTheme = getMarkdownTheme();
122
+ private readonly notesInput: Editor;
123
+ private readonly inlineInput: Editor;
124
+ private readonly getTerminalWidth = () => this.tui.terminal.columns;
125
+ private readonly getTerminalRows = () => this.tui.terminal.rows;
126
+
127
+ constructor(config: QuestionnaireBuildConfig) {
128
+ this.tui = config.tui;
129
+ this.theme = config.theme;
130
+ this.questions = config.questions;
131
+ this.itemsByTab = config.itemsByTab;
132
+ this.isMulti = config.isMulti;
133
+ this.initialState = config.initialState;
134
+ this.getCurrentTab = config.getCurrentTab;
135
+ this.collapseKey = config.collapseKey;
136
+
137
+ this.selectTheme = this.makeSelectTheme();
138
+ const textEditorTheme = editorTheme(this.theme);
139
+ this.notesInput = new Editor(this.tui, textEditorTheme);
140
+ this.inlineInput = new Editor(this.tui, textEditorTheme);
141
+ // The key router owns confirm and submit; keys that reach these editors are
142
+ // text editing only. Without this, a submit keybinding matched inside
143
+ // `Editor.handleInput` calls `submitValue()`, which resets the buffer — and
144
+ // since no `onSubmit` is wired here, whatever the user had typed is gone
145
+ // with no way to get it back.
146
+ this.notesInput.disableSubmit = true;
147
+ this.inlineInput.disableSubmit = true;
148
+ }
149
+
150
+ build(): QuestionnaireBuilt {
151
+ const tabs = this.buildTabComponents();
152
+ this.injectGlobalLeftWidth(tabs);
153
+ const submitPicker = this.buildSubmitPicker();
154
+ const tabBar = this.buildTabBar();
155
+ const heights = this.buildHeightComputers(tabs);
156
+ const dialog = this.buildDialog(tabs, submitPicker, tabBar, heights);
157
+ const globalBindings = this.buildGlobalBindings(dialog, submitPicker, tabBar);
158
+ const perTabBindings = this.buildPerTabBindings();
159
+ const adapter = this.buildAdapter(tabs, globalBindings, perTabBindings);
160
+ return this.handle(adapter, dialog);
161
+ }
162
+
163
+ private makeSelectTheme(): WrappingSelectTheme {
164
+ const theme = this.theme;
165
+ return {
166
+ selectedText: (s) => theme.fg("accent", theme.bold(s)),
167
+ description: (s) => theme.fg("muted", s),
168
+ scrollInfo: (s) => theme.fg("dim", s),
169
+ };
170
+ }
171
+
172
+ private buildTabComponents(): ReadonlyArray<TabComponents> {
173
+ return this.questions.map((q, i) => this.buildTabFor(q, i));
174
+ }
175
+
176
+ private buildTabFor(question: QuestionData, index: number): TabComponents {
177
+ const optionList = new OptionListView({
178
+ items: this.itemsByTab[index] ?? [],
179
+ theme: this.selectTheme,
180
+ });
181
+ const previewBlock = new PreviewBlockRenderer({
182
+ question,
183
+ theme: this.theme,
184
+ markdownTheme: this.markdownTheme,
185
+ });
186
+ const preview = new PreviewPane({
187
+ question,
188
+ getTerminalWidth: this.getTerminalWidth,
189
+ optionListView: optionList,
190
+ previewBlock,
191
+ });
192
+ if (question.multiSelect === true) {
193
+ const multiSelect = new MultiSelectView(this.theme, question);
194
+ return {
195
+ optionList,
196
+ preview,
197
+ multiSelect,
198
+ bodyHeights: multiSelectBodyHeights(multiSelect),
199
+ };
200
+ }
201
+ return { optionList, preview, bodyHeights: previewBodyHeights(preview) };
202
+ }
203
+
204
+ /**
205
+ * Give every pane the same adaptive left-column width, taken across all tabs.
206
+ *
207
+ * Done before anything renders, and shared rather than per-tab, because the
208
+ * option column jumping width as the user tabs between questions is far more
209
+ * distracting than a column slightly wider than one tab needs.
210
+ */
211
+ private injectGlobalLeftWidth(tabs: ReadonlyArray<TabComponents>): void {
212
+ const questions = this.questions;
213
+ const itemsByTab = this.itemsByTab;
214
+ // The questions are the tab descriptor. Mapping them into `{ multiSelect }`
215
+ // objects first, as upstream did, only produced a shape that already
216
+ // existed -- and produced it with an explicit undefined, which is a
217
+ // different type from an absent key here.
218
+ const globalLeftWidth = (paneWidth: number): number =>
219
+ crossTabLeftWidthWithDonation(questions, itemsByTab, questions, paneWidth);
220
+ for (const tab of tabs) {
221
+ tab.preview.setGlobalLeftWidth(globalLeftWidth);
222
+ }
223
+ }
224
+
225
+ private buildSubmitPicker(): SubmitPicker | undefined {
226
+ return this.isMulti ? new SubmitPicker(this.theme) : undefined;
227
+ }
228
+
229
+ private buildTabBar(): TabBar | undefined {
230
+ return this.isMulti ? new TabBar(this.theme) : undefined;
231
+ }
232
+
233
+ private buildHeightComputers(tabs: ReadonlyArray<TabComponents>): HeightComputers {
234
+ const global = (width: number): number => {
235
+ let max = 0;
236
+ for (const tab of tabs) {
237
+ const h = tab.bodyHeights(width).max;
238
+ if (h > max) max = h;
239
+ }
240
+ return Math.max(1, max);
241
+ };
242
+ const current = (width: number): number => {
243
+ const idx = Math.min(this.getCurrentTab(), tabs.length - 1);
244
+ return Math.max(0, tabs[idx]?.bodyHeights(width).current ?? 0);
245
+ };
246
+ return { global, current };
247
+ }
248
+
249
+ private pickInitialActivePreview(
250
+ tabs: ReadonlyArray<TabComponents>,
251
+ ): StatefulView<PreviewPaneProps> | undefined {
252
+ const idx = selectActivePreviewPaneIndex(this.initialState.currentTab, this.questions.length);
253
+ return tabs[idx]?.preview ?? tabs[0]?.preview;
254
+ }
255
+
256
+ private buildDialog(
257
+ tabs: ReadonlyArray<TabComponents>,
258
+ submitPicker: SubmitPicker | undefined,
259
+ tabBar: TabBar | undefined,
260
+ heights: HeightComputers,
261
+ ): DialogView {
262
+ const activePreviewPane = this.pickInitialActivePreview(tabs);
263
+ if (!activePreviewPane) {
264
+ // Validation caps a questionnaire at one to four questions, so there is
265
+ // always a tab. Saying so out loud beats a non-null assertion that would
266
+ // fail as a property access on undefined at first paint.
267
+ throw new Error("buildQuestionnaire requires at least one question");
268
+ }
269
+ return new DialogView(
270
+ {
271
+ theme: this.theme,
272
+ questions: this.questions,
273
+ tabBar,
274
+ notesInput: this.notesInput,
275
+ isMulti: this.isMulti,
276
+ tabsByIndex: tabs,
277
+ ...(submitPicker === undefined ? {} : { submitPicker }),
278
+ getBodyHeight: heights.global,
279
+ getCurrentBodyHeight: heights.current,
280
+ getTerminalRows: this.getTerminalRows,
281
+ collapseKey: this.collapseKey,
282
+ },
283
+ { state: this.initialState, activePreviewPane },
284
+ );
285
+ }
286
+
287
+ private buildGlobalBindings(
288
+ dialog: DialogView,
289
+ submitPicker: SubmitPicker | undefined,
290
+ tabBar: TabBar | undefined,
291
+ ): ReadonlyArray<BoundGlobalBinding> {
292
+ return [
293
+ globalBinding({ component: dialog, select: selectDialogProps }),
294
+ ...(submitPicker
295
+ ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })]
296
+ : []),
297
+ ...(tabBar ? [globalBinding({ component: tabBar, select: selectTabBarProps })] : []),
298
+ ];
299
+ }
300
+
301
+ private buildPerTabBindings(): ReadonlyArray<BoundPerTabBinding> {
302
+ return [
303
+ perTabBinding({
304
+ resolve: (tab) => tab.optionList,
305
+ predicate: isActiveTab,
306
+ select: selectOptionListProps,
307
+ }),
308
+ perTabBinding({
309
+ resolve: (tab) => tab.preview,
310
+ predicate: isActiveTab,
311
+ select: selectPreviewPaneProps,
312
+ }),
313
+ perTabBinding({
314
+ resolve: (tab) => tab.multiSelect,
315
+ select: selectMultiSelectProps,
316
+ }),
317
+ ];
318
+ }
319
+
320
+ private buildAdapter(
321
+ tabs: ReadonlyArray<TabComponents>,
322
+ globalBindings: ReadonlyArray<BoundGlobalBinding>,
323
+ perTabBindings: ReadonlyArray<BoundPerTabBinding>,
324
+ ): QuestionnairePropsAdapter {
325
+ return new QuestionnairePropsAdapter({
326
+ tui: this.tui,
327
+ questions: this.questions,
328
+ itemsByTab: this.itemsByTab,
329
+ tabsByIndex: tabs,
330
+ inlineInput: this.inlineInput,
331
+ globalBindings,
332
+ perTabBindings,
333
+ extraInvalidatables: [this.notesInput],
334
+ });
335
+ }
336
+
337
+ private handle(adapter: QuestionnairePropsAdapter, dialog: DialogView): QuestionnaireBuilt {
338
+ return {
339
+ adapter,
340
+ notesInput: this.notesInput,
341
+ inlineInput: this.inlineInput,
342
+ render: (w) => dialog.render(w),
343
+ invalidate: () => adapter.invalidate(),
344
+ };
345
+ }
346
+ }
@@ -0,0 +1,94 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ /** The slice of the TUI this needs: the terminal has to be handed over and taken back. */
7
+ export interface ExternalEditorTui {
8
+ stop(): void;
9
+ start(): void;
10
+ requestRender(force?: boolean): void;
11
+ }
12
+
13
+ /**
14
+ * Split the command the way Pi's own external-editor flow does, and hand the
15
+ * file over as the last argument.
16
+ *
17
+ * The grammar is deliberately the same as Pi's rather than better. Someone who
18
+ * has `EDITOR="code --wait"` working in Pi expects Ctrl+G here to behave
19
+ * identically; a separate shell or argv parser would make the same setting mean
20
+ * two different things depending on which editor opened.
21
+ */
22
+ function runEditor(command: string, file: string): Promise<void> {
23
+ const [editor, ...args] = command.split(" ");
24
+ // Unreachable given the caller's check, but the destructure is typed as
25
+ // possibly-undefined and a bare assertion here would be worse.
26
+ if (!editor) return Promise.reject(new Error("External editor command is empty"));
27
+
28
+ return new Promise((resolve, reject) => {
29
+ const child = spawn(editor, [...args, file], {
30
+ stdio: "inherit",
31
+ shell: process.platform === "win32",
32
+ });
33
+ child.once("error", reject);
34
+ child.once("close", (code, signal) => {
35
+ if (code === 0) {
36
+ resolve();
37
+ return;
38
+ }
39
+ const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
40
+ reject(new Error(`External editor exited with ${reason}`));
41
+ });
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Edit an answer in the user's configured editor and return what they saved.
47
+ *
48
+ * The TUI has to be stopped for the duration: the editor takes over the
49
+ * terminal, and two programs drawing to it at once produces garbage neither can
50
+ * clean up. Restarting it is in a `finally` for the same reason — a crashed
51
+ * editor, a failed temp write, anything at all must not leave the user in a
52
+ * stopped TUI with no way back.
53
+ *
54
+ * One trailing newline is stripped, matching Pi's main editor flow: most editors
55
+ * add one on save, and keeping it would silently append a blank line to every
56
+ * answer that went through here.
57
+ */
58
+ export async function editWithExternalEditor(
59
+ tui: ExternalEditorTui,
60
+ command: string,
61
+ value: string,
62
+ ): Promise<string> {
63
+ // Checked before anything is touched. Doing it inside `runEditor`, after the
64
+ // TUI has already been stopped, makes a misconfigured editor command flash
65
+ // the screen off and back on before reporting a problem that was knowable
66
+ // from the start.
67
+ if (command.trim().length === 0) throw new Error("External editor command is empty");
68
+
69
+ const tempDir = mkdtempSync(join(tmpdir(), "pi-ask-popup-"));
70
+ const tempFile = join(tempDir, "answer.md");
71
+ let tuiStopped = false;
72
+
73
+ try {
74
+ writeFileSync(tempFile, value, "utf8");
75
+ tui.stop();
76
+ tuiStopped = true;
77
+ process.stdout.write(
78
+ `Launching external editor: ${command}\nPi will resume when the editor exits.\n`,
79
+ );
80
+ await runEditor(command, tempFile);
81
+ return readFileSync(tempFile, "utf8").replace(/\r?\n$/, "");
82
+ } finally {
83
+ try {
84
+ rmSync(tempDir, { recursive: true, force: true });
85
+ } catch {
86
+ // Best effort. A temp directory left behind is a nuisance; a TUI left
87
+ // stopped because cleanup threw is a hung session.
88
+ }
89
+ if (tuiStopped) {
90
+ tui.start();
91
+ tui.requestRender(true);
92
+ }
93
+ }
94
+ }