pi-ask-popup 0.1.0 → 0.1.1

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 (35) hide show
  1. package/README.md +95 -32
  2. package/docs/configuration.md +93 -0
  3. package/docs/hosts.md +71 -0
  4. package/docs/keyboard.md +65 -0
  5. package/docs/tool-schema.md +124 -0
  6. package/package.json +12 -2
  7. package/preview/popup-submit.webp +0 -0
  8. package/preview/popup-with-notes.webp +0 -0
  9. package/preview/popup-with-tab.webp +0 -0
  10. package/src/ask-user-question.ts +50 -24
  11. package/src/config.ts +66 -23
  12. package/src/rpc-fallback.ts +30 -15
  13. package/src/state/build-questionnaire.ts +3 -1
  14. package/src/state/external-editor.ts +6 -2
  15. package/src/state/key-router.ts +129 -41
  16. package/src/state/questionnaire-session.ts +35 -13
  17. package/src/state/row-intent.ts +6 -2
  18. package/src/state/selectors/derivations.ts +18 -6
  19. package/src/state/selectors/focus.ts +6 -2
  20. package/src/state/selectors/projections.ts +3 -1
  21. package/src/state/state-reducer.ts +99 -33
  22. package/src/tool/response-envelope.ts +32 -20
  23. package/src/tool/types.ts +13 -2
  24. package/src/view/component-binding.ts +3 -1
  25. package/src/view/components/inline-input.ts +3 -1
  26. package/src/view/components/multi-select-view.ts +15 -5
  27. package/src/view/components/preview/markdown-content-cache.ts +21 -6
  28. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  29. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  30. package/src/view/components/preview/preview-layout-decider.ts +20 -15
  31. package/src/view/components/preview/preview-pane.ts +54 -18
  32. package/src/view/components/wrapping-select.ts +15 -5
  33. package/src/view/dialog-builder.ts +24 -8
  34. package/src/view/props-adapter.ts +21 -7
  35. package/src/view/tab-content-strategy.ts +60 -20
@@ -74,7 +74,9 @@ export const BEL = "\x07";
74
74
  */
75
75
  function emitTerminalAttention(): void {
76
76
  try {
77
- if (process.stdout.isTTY) process.stdout.write(BEL);
77
+ if (process.stdout.isTTY) {
78
+ process.stdout.write(BEL);
79
+ }
78
80
  } catch {
79
81
  // Best effort. Failing to get someone's attention must not stop the
80
82
  // questionnaire from being asked.
@@ -127,6 +129,8 @@ type SessionLoad =
127
129
  message: string;
128
130
  };
129
131
 
132
+ type LoadConfigInput = { agentDir: string; projectDir?: string };
133
+
130
134
  /**
131
135
  * Load the render graph on first use, guarding its two failure shapes.
132
136
  *
@@ -158,7 +162,7 @@ export async function loadQuestionnaireSession(
158
162
  message: `${ERROR_SESSION_LOAD_FAILED} (cause: ${cause})`,
159
163
  };
160
164
  }
161
- if (typeof mod.QuestionnaireSession !== "function") {
165
+ if (!(mod.QuestionnaireSession instanceof Function)) {
162
166
  const keys = JSON.stringify(Object.keys(mod));
163
167
  return {
164
168
  ok: false,
@@ -183,22 +187,31 @@ function registerCollapseKeyListener(
183
187
  sessionRef: SessionRef,
184
188
  overlayHandleRef: OverlayHandleRef,
185
189
  ): (() => void) | undefined {
186
- if (collapseKey === COLLAPSE_KEY_OFF || typeof ctx.ui.onTerminalInput !== "function") {
190
+ if (collapseKey === COLLAPSE_KEY_OFF || !(ctx.ui.onTerminalInput instanceof Function)) {
187
191
  return undefined;
188
192
  }
189
193
  let hasAnnouncedHide = false;
190
194
  return ctx.ui.onTerminalInput((data) => {
191
195
  const handle = overlayHandleRef.current;
192
- if (!handle) return undefined;
196
+ if (!handle) {
197
+ return undefined;
198
+ }
193
199
  // Act only while this questionnaire is hidden (its own input is
194
200
  // unreachable) or actually focused. With another overlay on top, the key
195
201
  // belongs to that one; toggling from underneath it would be baffling.
196
- if (!handle.isHidden() && !handle.isFocused()) return undefined;
197
- if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) return undefined;
202
+ if (!handle.isHidden() && !handle.isFocused()) {
203
+ return undefined;
204
+ }
205
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
206
+ if (!matchesKey(data, collapseKey as Parameters<typeof matchesKey>[1])) {
207
+ return undefined;
208
+ }
198
209
  // Kitty-protocol terminals report press, repeat and release separately.
199
210
  // Toggling on all three would make a tap reopen what it just closed, and a
200
211
  // held key flicker.
201
- if (isKeyRelease(data) || isKeyRepeat(data)) return { consume: true };
212
+ if (isKeyRelease(data) || isKeyRepeat(data)) {
213
+ return { consume: true };
214
+ }
202
215
  sessionRef.current?.toggleCollapsedExternal();
203
216
  if (handle.isHidden() && !hasAnnouncedHide) {
204
217
  // Once only. The dialog has just vanished, so say how to get it back —
@@ -251,7 +264,9 @@ function makeSessionFactory(config: {
251
264
  const editorCommand = SettingsManager.create(ctx.cwd, undefined, {
252
265
  projectTrusted: ctx.isProjectTrusted(),
253
266
  }).getExternalEditorCommand();
254
- if (!editorCommand) throw new Error("No external editor command is configured");
267
+ if (!editorCommand) {
268
+ throw new Error("No external editor command is configured");
269
+ }
255
270
  return await editWithExternalEditor(tui, editorCommand, value);
256
271
  } catch (error) {
257
272
  // Reported, then undefined, which the session reads as "keep the
@@ -341,12 +356,13 @@ Preview content is rendered as markdown in a monospace box. Multi-line text with
341
356
  * exists to prevent. The collapse key, a local UI preference, does read both
342
357
  * layers.
343
358
  */
344
- function loadGuidance(): {
345
- guidance: ReturnType<typeof validateGuidanceFields>;
346
- warnings: readonly string[];
347
- } {
359
+ function loadGuidance() {
348
360
  const { config, warnings } = loadConfig({ agentDir: getAgentDir() });
349
- return { guidance: validateGuidanceFields(config.guidance), warnings };
361
+ const result = {
362
+ guidance: validateGuidanceFields(config.guidance),
363
+ warnings,
364
+ };
365
+ return result;
350
366
  }
351
367
 
352
368
  export function registerAskPopupTool(pi: ExtensionAPI): void {
@@ -365,10 +381,15 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
365
381
  parameters: QuestionParamsSchema,
366
382
 
367
383
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
368
- const typed = params as unknown as QuestionParams;
369
- if (!ctx.hasUI) return rejectWithoutUi();
384
+ // SAFETY: params is validated by QuestionParamsSchema via validateQuestionnaire immediately after narrowing.
385
+ const typed = params as QuestionParams;
386
+ if (!ctx.hasUI) {
387
+ return rejectWithoutUi();
388
+ }
370
389
 
371
- for (const warning of pendingWarnings) ctx.ui.notify?.(warning, "warning");
390
+ for (const warning of pendingWarnings) {
391
+ ctx.ui.notify?.(warning, "warning");
392
+ }
372
393
  pendingWarnings = [];
373
394
 
374
395
  const validation = validateQuestionnaire(typed);
@@ -385,6 +406,7 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
385
406
  // Hosts that advertise their mode go straight to the walker and never
386
407
  // import the render graph at all. Older RPC builds fall through to the
387
408
  // undefined-result backstop below.
409
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
388
410
  if ((ctx as { mode?: string }).mode === "rpc" && hasDialogUI(ctx.ui)) {
389
411
  return runRpcPath(pi, ctx.ui, typed);
390
412
  }
@@ -393,7 +415,8 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
393
415
  // primitives is malformed, and calling `custom` on it throws a bare
394
416
  // TypeError that reaches the model as a broken tool rather than an
395
417
  // unsupported one. Answer honestly instead: nobody saw the questions.
396
- if (typeof (ctx.ui as { custom?: unknown }).custom !== "function") {
418
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
419
+ if (!((ctx.ui as { custom?: unknown }).custom instanceof Function)) {
397
420
  return resolveUndefinedResult(ctx, typed);
398
421
  }
399
422
 
@@ -414,12 +437,13 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
414
437
  // Both layers here: unlike guidance, this only binds a key in the user's
415
438
  // own terminal, and a project pinning a shortcut that suits its docs is
416
439
  // reasonable. `resolveCollapseKey` refuses anything malformed.
417
- const collapseKey = resolveCollapseKey(
418
- loadConfig({
419
- agentDir: getAgentDir(),
420
- ...(ctx.isProjectTrusted() ? { projectDir: ctx.cwd } : {}),
421
- }).config,
422
- );
440
+ const configInput: LoadConfigInput = {
441
+ agentDir: getAgentDir(),
442
+ };
443
+ if (ctx.isProjectTrusted()) {
444
+ configInput.projectDir = ctx.cwd;
445
+ }
446
+ const collapseKey = resolveCollapseKey(loadConfig(configInput).config);
423
447
 
424
448
  const sessionRef: SessionRef = { current: null };
425
449
  const overlayHandleRef: OverlayHandleRef = { current: undefined };
@@ -461,7 +485,9 @@ export function registerAskPopupTool(pi: ExtensionAPI): void {
461
485
  },
462
486
  );
463
487
 
464
- if (result === undefined) return resolveUndefinedResult(ctx, typed);
488
+ if (result === undefined) {
489
+ return resolveUndefinedResult(ctx, typed);
490
+ }
465
491
  return buildQuestionnaireResponse(result, typed);
466
492
  } finally {
467
493
  removeOverlayInputListener?.();
package/src/config.ts CHANGED
@@ -2,6 +2,25 @@ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
4
 
5
+ type JsonValue =
6
+ | string
7
+ | number
8
+ | boolean
9
+ | null
10
+ | JsonValue[]
11
+ | { readonly [key: string]: JsonValue };
12
+ type JsonRecord = Record<string, JsonValue>;
13
+
14
+ function isRecord(value: unknown): value is JsonRecord;
15
+ function isRecord(value: JsonValue | undefined): value is JsonRecord;
16
+ function isRecord(value: unknown): value is JsonRecord {
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
18
+ }
19
+
20
+ function isNonEmptyString(value: unknown): value is string {
21
+ return typeof value === "string" && value.length > 0;
22
+ }
23
+
5
24
  /** Key spec for the overlay collapse/expand shortcut, e.g. `"ctrl+]"` or `"alt+o"`. */
6
25
  export type CollapseKeySpec = string;
7
26
 
@@ -72,32 +91,34 @@ export function configPaths(sources: ConfigSources): string[] {
72
91
  * check-then-read pair races, and this keeps the no-write guarantee obvious —
73
92
  * nothing here can create a file or a parent directory.
74
93
  */
75
- function readLayer(path: string, warnings: string[]): Record<string, unknown> {
94
+ function readLayer(path: string, warnings: string[]): JsonRecord {
76
95
  let text: string;
77
96
  try {
78
97
  text = readFileSync(path, "utf8");
79
98
  } catch (err) {
99
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
80
100
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
101
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
81
102
  warnings.push(`pi-ask-popup: cannot read ${path} — ${(err as Error).message}`);
82
103
  }
83
104
  return {};
84
105
  }
85
106
  try {
86
107
  const parsed: unknown = JSON.parse(text);
87
- // `typeof null === "object"` and so is an array; a config file is neither.
88
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
108
+ if (!isRecord(parsed)) {
89
109
  warnings.push(`pi-ask-popup: ${path} is not a JSON object, ignoring it`);
90
110
  return {};
91
111
  }
92
- return parsed as Record<string, unknown>;
112
+ return parsed;
93
113
  } catch (err) {
114
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
94
115
  warnings.push(`pi-ask-popup: invalid JSON in ${path}, ignoring it — ${(err as Error).message}`);
95
116
  return {};
96
117
  }
97
118
  }
98
119
 
99
120
  function nonEmptyString(value: unknown): string | undefined {
100
- return typeof value === "string" && value.length > 0 ? value : undefined;
121
+ return isNonEmptyString(value) ? value : undefined;
101
122
  }
102
123
 
103
124
  /**
@@ -106,19 +127,26 @@ function nonEmptyString(value: unknown): string | undefined {
106
127
  * the tool still registers.
107
128
  */
108
129
  export function validateGuidanceFields(fields: unknown): GuidanceFields {
109
- if (fields === null || typeof fields !== "object") return {};
110
- const g = fields as Record<string, unknown>;
130
+ if (!isRecord(fields)) {
131
+ return {};
132
+ }
133
+ const g = fields;
111
134
  const out: GuidanceFields = {};
112
135
  const description = nonEmptyString(g.description);
113
- if (description !== undefined) out.description = description;
136
+ if (description !== undefined) {
137
+ out.description = description;
138
+ }
114
139
  const promptSnippet = nonEmptyString(g.promptSnippet);
115
- if (promptSnippet !== undefined) out.promptSnippet = promptSnippet;
140
+ if (promptSnippet !== undefined) {
141
+ out.promptSnippet = promptSnippet;
142
+ }
116
143
  const guidelines = g.promptGuidelines;
117
144
  if (
118
145
  Array.isArray(guidelines) &&
119
146
  guidelines.length > 0 &&
120
147
  guidelines.every((s) => nonEmptyString(s) !== undefined)
121
148
  ) {
149
+ // SAFETY: guidelines elements are validated as non-empty strings above; preserving string array type is safe.
122
150
  out.promptGuidelines = guidelines as string[];
123
151
  }
124
152
  return out;
@@ -135,7 +163,9 @@ export function loadConfig(sources: ConfigSources): ConfigLoadResult {
135
163
  for (const path of configPaths(sources)) {
136
164
  const raw = readLayer(path, warnings);
137
165
  const collapseKey = nonEmptyString(raw.collapseKey);
138
- if (collapseKey !== undefined) config.collapseKey = collapseKey;
166
+ if (collapseKey !== undefined) {
167
+ config.collapseKey = collapseKey;
168
+ }
139
169
  const guidance = validateGuidanceFields(raw.guidance);
140
170
  if (Object.keys(guidance).length > 0) {
141
171
  config.guidance = { ...config.guidance, ...guidance };
@@ -183,7 +213,9 @@ const SPECIAL_KEYS = new Set([
183
213
  const MODIFIERS = new Set(["ctrl", "shift", "alt", "super"]);
184
214
 
185
215
  function isBaseKey(key: string): boolean {
186
- if (key.length !== 1) return SPECIAL_KEYS.has(key);
216
+ if (key.length !== 1) {
217
+ return SPECIAL_KEYS.has(key);
218
+ }
187
219
  return (key >= "a" && key <= "z") || (key >= "0" && key <= "9") || SYMBOL_KEYS.has(key);
188
220
  }
189
221
 
@@ -199,12 +231,18 @@ function isBaseKey(key: string): boolean {
199
231
  * user types, anywhere.
200
232
  */
201
233
  function isValidCollapseKeySpec(spec: string): boolean {
202
- if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) return false;
234
+ if (!spec || spec.startsWith("+") || spec.endsWith("+") || spec.includes("++")) {
235
+ return false;
236
+ }
203
237
  const parts = spec.split("+");
204
238
  const base = parts[parts.length - 1] ?? "";
205
239
  const modifiers = parts.slice(0, -1);
206
- if (modifiers.length !== new Set(modifiers).size) return false;
207
- if (!modifiers.every((m) => MODIFIERS.has(m))) return false;
240
+ if (modifiers.length !== new Set(modifiers).size) {
241
+ return false;
242
+ }
243
+ if (!modifiers.every((m) => MODIFIERS.has(m))) {
244
+ return false;
245
+ }
208
246
  return isBaseKey(base);
209
247
  }
210
248
 
@@ -220,17 +258,21 @@ export function resolveCollapseKey(config: {
220
258
  collapseKey?: CollapseKeySpec | undefined;
221
259
  }): CollapseKeySpec {
222
260
  const raw = config.collapseKey?.trim().toLowerCase();
223
- if (raw === undefined || raw === "") return DEFAULT_COLLAPSE_KEY;
224
- if (raw === COLLAPSE_KEY_OFF) return COLLAPSE_KEY_OFF;
261
+ if (raw === undefined || raw === "") {
262
+ return DEFAULT_COLLAPSE_KEY;
263
+ }
264
+ if (raw === COLLAPSE_KEY_OFF) {
265
+ return COLLAPSE_KEY_OFF;
266
+ }
225
267
  return isValidCollapseKeySpec(raw) ? raw : DEFAULT_COLLAPSE_KEY;
226
268
  }
227
269
 
228
270
  // The only compound-word names in SPECIAL_KEYS — capitalizing the first letter
229
271
  // alone would render them "Pageup" and "Pagedown".
230
- const COMPOUND_KEY_DISPLAY: Record<string, string> = {
272
+ const COMPOUND_KEY_DISPLAY = {
231
273
  pageup: "PageUp",
232
274
  pagedown: "PageDown",
233
- };
275
+ } satisfies Record<string, string>;
234
276
 
235
277
  /**
236
278
  * Pretty-print a resolved spec for UI copy: `"ctrl+]"` → `"Ctrl+]"`, `"alt+o"` →
@@ -241,10 +283,11 @@ const COMPOUND_KEY_DISPLAY: Record<string, string> = {
241
283
  export function formatKeySpecForDisplay(spec: CollapseKeySpec): string {
242
284
  return spec
243
285
  .split("+")
244
- .map(
245
- (part) =>
246
- COMPOUND_KEY_DISPLAY[part] ??
247
- (part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1)),
248
- )
286
+ .map((part) => {
287
+ if (part === "pageup" || part === "pagedown") {
288
+ return COMPOUND_KEY_DISPLAY[part];
289
+ }
290
+ return part.length <= 1 ? part.toUpperCase() : part.charAt(0).toUpperCase() + part.slice(1);
291
+ })
249
292
  .join("+");
250
293
  }
@@ -58,8 +58,9 @@ export type DialogUI = {
58
58
 
59
59
  /** Whether the host implements the select and input primitives. */
60
60
  export function hasDialogUI(ui: unknown): ui is DialogUI {
61
- const u = ui as Partial<Record<"select" | "input", unknown>> | null | undefined;
62
- return typeof u?.select === "function" && typeof u?.input === "function";
61
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
62
+ const u = ui as Partial<DialogUI> | null | undefined;
63
+ return u?.select instanceof Function && u?.input instanceof Function;
63
64
  }
64
65
 
65
66
  type Option = QuestionData["options"][number];
@@ -105,12 +106,16 @@ export async function runRpcQuestionnaire(
105
106
  const dialogOpts = params.timeout === undefined ? undefined : { timeout: params.timeout };
106
107
  for (let qi = 0; qi < params.questions.length; qi++) {
107
108
  const q = params.questions[qi];
108
- if (!q) continue;
109
+ if (!q) {
110
+ continue;
111
+ }
109
112
  const header = q.header ? `[${q.header}] ` : "";
110
113
  const answer = q.multiSelect
111
114
  ? await askMultiSelect(ui, q, qi, header, dialogOpts)
112
115
  : await askSingleSelect(ui, q, qi, header, dialogOpts);
113
- if (answer === undefined) return { answers, cancelled: true };
116
+ if (answer === undefined) {
117
+ return { answers, cancelled: true };
118
+ }
114
119
  answers.push(answer);
115
120
  }
116
121
  return { answers, cancelled: false };
@@ -127,29 +132,35 @@ async function askSingleSelect(
127
132
  const options = q.options.map(formatOptionLine);
128
133
  options.push(`${q.options.length + 1}. ${ROW_INTENT_META.other.label}`);
129
134
  const chosen = await ui.select(`${header}${q.question}${buildPreviewBlock(q)}`, options, opts);
130
- if (chosen === undefined || chosen === null) return undefined;
135
+ if (chosen === undefined || chosen === null) {
136
+ return undefined;
137
+ }
131
138
  const idx = parseIndex(chosen, options.length);
132
139
  // A host that returns something outside the list it was given is
133
140
  // indistinguishable from a dismissal. Treating it as one beats fabricating
134
141
  // an answer the user never gave.
135
- if (idx === null) return undefined;
142
+ if (idx === null) {
143
+ return undefined;
144
+ }
136
145
  const option = q.options[idx];
137
146
  if (option) {
138
- return {
147
+ const answer: QuestionAnswer = {
139
148
  questionIndex,
140
149
  question: q.question,
141
150
  kind: "option",
142
151
  answer: option.label,
143
- // Spread rather than assigned: the envelope's contract is that the key is
144
- // absent when there was no preview, not present and undefined.
145
- ...(option.preview !== undefined && option.preview.length > 0
146
- ? { preview: option.preview }
147
- : {}),
148
152
  };
153
+ if (option.preview !== undefined && option.preview.length > 0) {
154
+ // SAFETY: preview is an optional string; present only when non-empty per contract.
155
+ (answer as QuestionAnswer & { preview: string }).preview = option.preview;
156
+ }
157
+ return answer;
149
158
  }
150
159
  // The "Type something." row, which is the one index past the authored options.
151
160
  const typed = await ui.input(`${header}${q.question}\n\n${CUSTOM_ANSWER_TITLE}`, "", opts);
152
- if (typed === undefined || typed === null) return undefined;
161
+ if (typed === undefined || typed === null) {
162
+ return undefined;
163
+ }
153
164
  return { questionIndex, question: q.question, kind: "custom", answer: typed };
154
165
  }
155
166
 
@@ -167,7 +178,9 @@ async function askMultiSelect(
167
178
  MULTI_SELECT_PLACEHOLDER,
168
179
  opts,
169
180
  );
170
- if (value === undefined || value === null) return undefined;
181
+ if (value === undefined || value === null) {
182
+ return undefined;
183
+ }
171
184
  const trimmed = value.trim();
172
185
  if (trimmed.length === 0) {
173
186
  // A deliberate empty commit, the same as pressing Next with nothing ticked.
@@ -186,7 +199,9 @@ async function askMultiSelect(
186
199
  const selected: string[] = [];
187
200
  for (const i of indices) {
188
201
  const label = q.options[i]?.label;
189
- if (label !== undefined && !selected.includes(label)) selected.push(label);
202
+ if (label !== undefined && !selected.includes(label)) {
203
+ selected.push(label);
204
+ }
190
205
  }
191
206
  return { questionIndex, question: q.question, kind: "multi", answer: null, selected };
192
207
  }
@@ -235,7 +235,9 @@ class QuestionnaireBuilder {
235
235
  let max = 0;
236
236
  for (const tab of tabs) {
237
237
  const h = tab.bodyHeights(width).max;
238
- if (h > max) max = h;
238
+ if (h > max) {
239
+ max = h;
240
+ }
239
241
  }
240
242
  return Math.max(1, max);
241
243
  };
@@ -23,7 +23,9 @@ function runEditor(command: string, file: string): Promise<void> {
23
23
  const [editor, ...args] = command.split(" ");
24
24
  // Unreachable given the caller's check, but the destructure is typed as
25
25
  // possibly-undefined and a bare assertion here would be worse.
26
- if (!editor) return Promise.reject(new Error("External editor command is empty"));
26
+ if (!editor) {
27
+ return Promise.reject(new Error("External editor command is empty"));
28
+ }
27
29
 
28
30
  return new Promise((resolve, reject) => {
29
31
  const child = spawn(editor, [...args, file], {
@@ -64,7 +66,9 @@ export async function editWithExternalEditor(
64
66
  // TUI has already been stopped, makes a misconfigured editor command flash
65
67
  // the screen off and back on before reporting a problem that was knowable
66
68
  // from the start.
67
- if (command.trim().length === 0) throw new Error("External editor command is empty");
69
+ if (command.trim().length === 0) {
70
+ throw new Error("External editor command is empty");
71
+ }
68
72
 
69
73
  const tempDir = mkdtempSync(join(tmpdir(), "pi-ask-popup-"));
70
74
  const tempFile = join(tempDir, "answer.md");