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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- 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
|
+
}
|