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
@@ -8,6 +8,17 @@ import type { WrappingSelectItem } from "./row-intent.js";
8
8
  import type { QuestionnaireAction } from "./key-router.js";
9
9
  import { ROW_INTENT_META } from "./row-intent.js";
10
10
  import { noteForTab, type QuestionnaireState } from "./state.js";
11
+ type JsonValue =
12
+ | string
13
+ | number
14
+ | boolean
15
+ | null
16
+ | JsonValue[]
17
+ | { readonly [key: string]: JsonValue };
18
+
19
+ function isString(value: JsonValue | undefined): value is string {
20
+ return typeof value === "string";
21
+ }
11
22
 
12
23
  /** Session-lifetime constants. No live-component reads — peripheral values live on canonical state. */
13
24
  export interface ApplyContext {
@@ -51,7 +62,9 @@ function orderedAnswers(
51
62
  const out: QuestionAnswer[] = [];
52
63
  for (let i = 0; i < questions.length; i++) {
53
64
  const a = state.answers.get(i);
54
- if (a) out.push(a);
65
+ if (a) {
66
+ out.push(a);
67
+ }
55
68
  }
56
69
  return out;
57
70
  }
@@ -73,10 +86,14 @@ function unansweredNotesFor(
73
86
  ): UnansweredNote[] {
74
87
  const out: UnansweredNote[] = [];
75
88
  for (let i = 0; i < questions.length; i++) {
76
- if (state.answers.has(i)) continue;
89
+ if (state.answers.has(i)) {
90
+ continue;
91
+ }
77
92
  const question = questions[i];
78
93
  const note = state.notesByTab.get(i);
79
- if (!question || note === undefined || note.length === 0) continue;
94
+ if (!question || note === undefined || note.length === 0) {
95
+ continue;
96
+ }
80
97
  out.push({ questionIndex: i, question: question.question, note });
81
98
  }
82
99
  return out;
@@ -88,12 +105,16 @@ function syncMultiSelectFromAnswers(
88
105
  tab: number,
89
106
  ): ReadonlySet<number> {
90
107
  const q = questions[tab];
91
- if (!q?.multiSelect) return new Set();
108
+ if (!q?.multiSelect) {
109
+ return new Set();
110
+ }
92
111
  const saved = answers.get(tab);
93
112
  const labels = saved?.selected ?? [];
94
113
  const indices = new Set<number>();
95
114
  for (let i = 0; i < q.options.length; i++) {
96
- if (labels.includes(q.options[i]!.label)) indices.add(i);
115
+ if (labels.includes(q.options[i]!.label)) {
116
+ indices.add(i);
117
+ }
97
118
  }
98
119
  return indices;
99
120
  }
@@ -103,10 +124,14 @@ function persistMultiSelectAnswer(
103
124
  ctx: ApplyContext,
104
125
  ): ReadonlyMap<number, QuestionAnswer> {
105
126
  const q = ctx.questions[state.currentTab];
106
- if (!q?.multiSelect) return state.answers;
127
+ if (!q?.multiSelect) {
128
+ return state.answers;
129
+ }
107
130
  const selected: string[] = [];
108
131
  for (let i = 0; i < q.options.length; i++) {
109
- if (state.multiSelectChecked.has(i)) selected.push(q.options[i]!.label);
132
+ if (state.multiSelectChecked.has(i)) {
133
+ selected.push(q.options[i]!.label);
134
+ }
110
135
  }
111
136
  const out = new Map(state.answers);
112
137
  if (selected.length === 0) {
@@ -114,22 +139,28 @@ function persistMultiSelectAnswer(
114
139
  return out;
115
140
  }
116
141
  const pendingNotes = state.notesByTab.get(state.currentTab);
117
- out.set(state.currentTab, {
142
+ const entry: QuestionAnswer = {
118
143
  questionIndex: state.currentTab,
119
144
  question: q.question,
120
145
  kind: "multi",
121
146
  answer: null,
122
147
  selected,
123
- ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
124
- });
148
+ };
149
+ if (pendingNotes && pendingNotes.length > 0) {
150
+ // SAFETY: notes is an optional string per QuestionAnswer contract; adding when present preserves the shape.
151
+ (entry as QuestionAnswer & { notes: string }).notes = pendingNotes;
152
+ }
153
+ out.set(state.currentTab, entry);
125
154
  return out;
126
155
  }
127
156
 
128
157
  function customDraftValueFor(state: QuestionnaireState, tab: number): string {
129
158
  const draft = state.customDraftsByTab.get(tab);
130
- if (draft !== undefined) return draft;
159
+ if (draft !== undefined) {
160
+ return draft;
161
+ }
131
162
  const answer = state.answers.get(tab);
132
- return answer?.kind === "custom" && typeof answer.answer === "string" ? answer.answer : "";
163
+ return answer?.kind === "custom" && isString(answer.answer) ? answer.answer : "";
133
164
  }
134
165
 
135
166
  function setCustomDraft(
@@ -143,7 +174,9 @@ function setCustomDraft(
143
174
  }
144
175
 
145
176
  function withoutCustomDraft(state: QuestionnaireState, tab: number): ReadonlyMap<number, string> {
146
- if (!state.customDraftsByTab.has(tab)) return state.customDraftsByTab;
177
+ if (!state.customDraftsByTab.has(tab)) {
178
+ return state.customDraftsByTab;
179
+ }
147
180
  const drafts = new Map(state.customDraftsByTab);
148
181
  drafts.delete(tab);
149
182
  return drafts;
@@ -189,9 +222,16 @@ function doneFor(state: QuestionnaireState, ctx: ApplyContext, cancelled: boolea
189
222
  const result: QuestionnaireResult = {
190
223
  answers: orderedAnswers(state, ctx.questions),
191
224
  cancelled,
192
- ...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
193
- ...(unansweredNotes.length > 0 ? { unansweredNotes } : {}),
194
225
  };
226
+ if (globalNote && globalNote.length > 0) {
227
+ // SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
228
+ (result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
229
+ }
230
+ if (unansweredNotes.length > 0) {
231
+ // SAFETY: unansweredNotes is optional per QuestionnaireResult; present only when non-empty.
232
+ (result as QuestionnaireResult & { unansweredNotes: UnansweredNote[] }).unansweredNotes =
233
+ unansweredNotes;
234
+ }
195
235
  return { state, effects: [{ kind: "done", result }] };
196
236
  }
197
237
 
@@ -218,7 +258,9 @@ const navHandler: Handler<"nav"> = (state, action, ctx) => {
218
258
  inputMode,
219
259
  customDraftsByTab,
220
260
  };
221
- if (!inputMode) return { state: next, effects: [] };
261
+ if (!inputMode) {
262
+ return { state: next, effects: [] };
263
+ }
222
264
  return {
223
265
  state: next,
224
266
  effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }],
@@ -269,16 +311,23 @@ const confirmHandler: Handler<"confirm"> = (state, action, ctx) => {
269
311
  ...state,
270
312
  answers,
271
313
  customDraftsByTab,
272
- ...(isCustomMulti ? { multiSelectChecked: new Set<number>() } : {}),
273
314
  };
274
- if (action.autoAdvanceTab !== undefined) return switchTabResult(next, action.autoAdvanceTab, ctx);
315
+ if (isCustomMulti) {
316
+ next.multiSelectChecked = new Set<number>();
317
+ }
318
+ if (action.autoAdvanceTab !== undefined) {
319
+ return switchTabResult(next, action.autoAdvanceTab, ctx);
320
+ }
275
321
  return doneFor(next, ctx, false);
276
322
  };
277
323
 
278
324
  const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
279
325
  const checked = new Set(state.multiSelectChecked);
280
- if (checked.has(action.index)) checked.delete(action.index);
281
- else checked.add(action.index);
326
+ if (checked.has(action.index)) {
327
+ checked.delete(action.index);
328
+ } else {
329
+ checked.add(action.index);
330
+ }
282
331
  const intermediate: QuestionnaireState = { ...state, multiSelectChecked: checked };
283
332
  const answers = persistMultiSelectAnswer(intermediate, ctx);
284
333
  return { state: { ...intermediate, answers }, effects: [] };
@@ -286,24 +335,31 @@ const toggleHandler: Handler<"toggle"> = (state, action, ctx) => {
286
335
 
287
336
  const multiConfirmHandler: Handler<"multi_confirm"> = (state, action, ctx) => {
288
337
  const q = ctx.questions[state.currentTab];
289
- if (!q) return { state, effects: [] };
338
+ if (!q) {
339
+ return { state, effects: [] };
340
+ }
290
341
  const pendingNotes = state.notesByTab.get(state.currentTab);
291
342
  const answers = new Map(state.answers);
292
- answers.set(state.currentTab, {
343
+ const multiAnswer: QuestionAnswer = {
293
344
  questionIndex: state.currentTab,
294
345
  question: q.question,
295
346
  kind: "multi",
296
347
  answer: null,
297
348
  selected: action.selected,
298
- ...(pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}),
299
- });
349
+ };
350
+ if (pendingNotes && pendingNotes.length > 0) {
351
+ // SAFETY: notes is optional per QuestionAnswer; adding when present preserves the shape.
352
+ (multiAnswer as QuestionAnswer & { notes: string }).notes = pendingNotes;
353
+ }
354
+ answers.set(state.currentTab, multiAnswer);
300
355
  const synced: QuestionnaireState = {
301
356
  ...state,
302
357
  answers,
303
358
  multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab),
304
359
  };
305
- if (action.autoAdvanceTab !== undefined)
360
+ if (action.autoAdvanceTab !== undefined) {
306
361
  return switchTabResult(synced, action.autoAdvanceTab, ctx);
362
+ }
307
363
  return doneFor(synced, ctx, false);
308
364
  };
309
365
 
@@ -327,13 +383,16 @@ const notesExitHandler: Handler<"notes_exit"> = (state, _action, _ctx) => {
327
383
  const prev = answers.get(state.currentTab);
328
384
  if (prev?.notes) {
329
385
  const stripped = { ...prev };
330
- delete (stripped as { notes?: string }).notes;
331
- answers.set(state.currentTab, stripped);
386
+ const { notes: _removed, ...withoutNotes } = stripped;
387
+ // SAFETY: withoutNotes preserves all required QuestionAnswer fields; notes is optional and removed intentionally.
388
+ answers.set(state.currentTab, withoutNotes as QuestionAnswer);
332
389
  }
333
390
  } else {
334
391
  notes.set(state.currentTab, trimmed);
335
392
  const prev = answers.get(state.currentTab);
336
- if (prev) answers.set(state.currentTab, { ...prev, notes: trimmed });
393
+ if (prev) {
394
+ answers.set(state.currentTab, { ...prev, notes: trimmed });
395
+ }
337
396
  }
338
397
  return {
339
398
  state: { ...state, notesByTab: notes, answers, notesVisible: false },
@@ -356,7 +415,9 @@ const toggleCollapsedHandler: Handler<"toggle_collapsed"> = (s, _a, _c) => ({
356
415
  effects: [{ kind: "set_overlay_hidden", hidden: !s.collapsed }],
357
416
  });
358
417
  const tickHandler: Handler<"tick"> = (state, action, ctx) => {
359
- if (state.timerCancelled || state.deadline === undefined) return { state, effects: [] };
418
+ if (state.timerCancelled || state.deadline === undefined) {
419
+ return { state, effects: [] };
420
+ }
360
421
  const remaining = state.deadline - action.now;
361
422
  if (remaining > 0) {
362
423
  return { state: { ...state, remainingMs: remaining }, effects: [] };
@@ -366,8 +427,11 @@ const tickHandler: Handler<"tick"> = (state, action, ctx) => {
366
427
  answers: orderedAnswers(state, ctx.questions),
367
428
  cancelled: true,
368
429
  error: "timed_out",
369
- ...(globalNote && globalNote.length > 0 ? { globalNote } : {}),
370
430
  };
431
+ if (globalNote && globalNote.length > 0) {
432
+ // SAFETY: globalNote is optional per QuestionnaireResult; present only when non-empty.
433
+ (result as QuestionnaireResult & { globalNote: string }).globalNote = globalNote;
434
+ }
371
435
  return { state, effects: [{ kind: "done", result }] };
372
436
  };
373
437
  const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: [] });
@@ -378,7 +442,7 @@ const ignoreHandler: Handler<"ignore"> = (s, _a, _c) => ({ state: s, effects: []
378
442
  * compile here until a handler is registered, mirroring the `Record<RowKind, …>`
379
443
  * pattern used by `ROW_INTENT_META`.
380
444
  */
381
- const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
445
+ const HANDLERS = {
382
446
  nav: navHandler,
383
447
  input_clear: inputClearHandler,
384
448
  input_edit: inputEditHandler,
@@ -396,10 +460,10 @@ const HANDLERS: { [K in QuestionnaireAction["kind"]]: Handler<K> } = {
396
460
  toggle_collapsed: toggleCollapsedHandler,
397
461
  tick: tickHandler,
398
462
  ignore: ignoreHandler,
399
- };
463
+ } satisfies { [K in QuestionnaireAction["kind"]]: Handler<K> };
400
464
 
401
465
  /**
402
- * Pure reducer: (state, action, ctx) → (state, Effect[]). Mirrors `rpiv-todo`'s `applyTaskMutation`.
466
+ * Pure reducer: (state, action, ctx) → (state, Effect[]).
403
467
  * Delegates to `HANDLERS` — per-kind handlers above are pure, named, and individually testable.
404
468
  * `ignore` is also handled outside the reducer by `handleIgnoreInline` in the runtime fast path.
405
469
  */
@@ -408,7 +472,9 @@ export function reduce(
408
472
  action: QuestionnaireAction,
409
473
  ctx: ApplyContext,
410
474
  ): ApplyResult {
475
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
411
476
  const handler = HANDLERS[action.kind] as Handler<typeof action.kind>;
477
+ // SAFETY: safe cast — value is validated at boundary or test fixture with known shape.
412
478
  const result = handler(state, action as never, ctx);
413
479
  const isHumanKeystroke = action.kind !== "tick" && action.kind !== "toggle_collapsed";
414
480
  if (isHumanKeystroke && state.deadline !== undefined && !state.timerCancelled) {
@@ -37,32 +37,38 @@ export function buildQuestionnaireResponse(
37
37
  params: QuestionParams,
38
38
  ): ToolResult {
39
39
  if (result?.error === "timed_out") {
40
- return buildToolResult(TIMED_OUT_MESSAGE, {
40
+ const details: QuestionnaireResult = {
41
41
  answers: result.answers,
42
42
  cancelled: true,
43
43
  error: "timed_out",
44
- ...(result.globalNote && result.globalNote.length > 0
45
- ? { globalNote: result.globalNote }
46
- : {}),
47
- ...(result.unansweredNotes && result.unansweredNotes.length > 0
48
- ? { unansweredNotes: result.unansweredNotes }
49
- : {}),
50
- });
44
+ };
45
+ if (result.globalNote && result.globalNote.length > 0) {
46
+ (details as { globalNote: string }).globalNote = result.globalNote;
47
+ }
48
+ if (result.unansweredNotes && result.unansweredNotes.length > 0) {
49
+ (details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
50
+ result.unansweredNotes;
51
+ }
52
+ return buildToolResult(TIMED_OUT_MESSAGE, details);
51
53
  }
52
54
  if (!result || result.cancelled) {
53
55
  // The decline text stays canonical even when a global note rides a
54
56
  // cancelled result. The note survives in `details`, like partial answers.
55
- return buildToolResult(DECLINE_MESSAGE, {
57
+ const details: QuestionnaireResult = {
56
58
  answers: result?.answers ?? [],
57
59
  cancelled: true,
58
- ...(result?.error ? { error: result.error } : {}),
59
- ...(result?.globalNote && result.globalNote.length > 0
60
- ? { globalNote: result.globalNote }
61
- : {}),
62
- ...(result?.unansweredNotes && result.unansweredNotes.length > 0
63
- ? { unansweredNotes: result.unansweredNotes }
64
- : {}),
65
- });
60
+ };
61
+ if (result?.error) {
62
+ (details as { error: typeof result.error }).error = result.error;
63
+ }
64
+ if (result?.globalNote && result.globalNote.length > 0) {
65
+ (details as { globalNote: string }).globalNote = result.globalNote;
66
+ }
67
+ if (result?.unansweredNotes && result.unansweredNotes.length > 0) {
68
+ (details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
69
+ result.unansweredNotes;
70
+ }
71
+ return buildToolResult(DECLINE_MESSAGE, details);
66
72
  }
67
73
 
68
74
  const segments: string[] = [];
@@ -79,7 +85,9 @@ export function buildQuestionnaireResponse(
79
85
  // before the "nothing to report" check below, a questionnaire submitted
80
86
  // with nothing but such a note counts as answered rather than declined.
81
87
  const n = result.unansweredNotes?.find((x) => x.questionIndex === i);
82
- if (n) segments.push(buildUnansweredNoteSegment(n));
88
+ if (n) {
89
+ segments.push(buildUnansweredNoteSegment(n));
90
+ }
83
91
  }
84
92
  if (result.globalNote && result.globalNote.length > 0) {
85
93
  // Raw multiline echo, no reformatting, trailing period matching the shape
@@ -98,8 +106,12 @@ export function buildQuestionnaireResponse(
98
106
  */
99
107
  export function buildAnswerSegment(a: QuestionAnswer): string {
100
108
  const parts: string[] = [`"${a.question}"="${formatAnswerScalar(a, "envelope")}"`];
101
- if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
102
- if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
109
+ if (a.preview && a.preview.length > 0) {
110
+ parts.push(`selected preview: ${a.preview}`);
111
+ }
112
+ if (a.notes && a.notes.length > 0) {
113
+ parts.push(`user notes: ${a.notes}`);
114
+ }
103
115
  return `${parts.join(". ")}.`;
104
116
  }
105
117
 
package/src/tool/types.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { type Static, Type } from "typebox";
2
2
  import { LABELS_BY_KIND, ROW_INTENT_META } from "../state/row-intent.js";
3
3
 
4
+ type JsonValue =
5
+ | string
6
+ | number
7
+ | boolean
8
+ | null
9
+ | JsonValue[]
10
+ | { readonly [key: string]: JsonValue };
11
+
4
12
  export const MAX_QUESTIONS = 4;
5
13
  export const MIN_OPTIONS = 2;
6
14
  export const MAX_OPTIONS = 4;
@@ -187,7 +195,10 @@ export interface QuestionnaireResult {
187
195
  }
188
196
 
189
197
  export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
190
- if (!value || typeof value !== "object") return false;
191
- const v = value as Record<string, unknown>;
198
+ if (!value || typeof value !== "object") {
199
+ return false;
200
+ }
201
+ // SAFETY: boundary check for QuestionnaireResult; value is validated as object with required fields before use.
202
+ const v = value as Record<string, JsonValue>;
192
203
  return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
193
204
  }
@@ -44,7 +44,9 @@ export function globalBinding<P>(spec: ComponentBinding<P>): BoundGlobalBinding
44
44
  export function perTabBinding<P>(spec: PerTabBinding<P>): BoundPerTabBinding {
45
45
  return {
46
46
  apply: (state, ctx) => {
47
- if (spec.predicate && !spec.predicate(state, ctx)) return;
47
+ if (spec.predicate && !spec.predicate(state, ctx)) {
48
+ return;
49
+ }
48
50
  spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
49
51
  },
50
52
  };
@@ -27,7 +27,9 @@ export interface RenderInlineInputOptions {
27
27
  * Mirrors the original wrapping-select.resolveOffset exactly.
28
28
  */
29
29
  function resolveCursorOffset(buffer: string, requested: number | undefined): number {
30
- if (requested !== undefined && requested >= 0 && requested <= buffer.length) return requested;
30
+ if (requested !== undefined && requested >= 0 && requested <= buffer.length) {
31
+ return requested;
32
+ }
31
33
  return buffer.length;
32
34
  }
33
35
 
@@ -96,7 +96,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
96
96
  }
97
97
 
98
98
  private layout(width: number): MultiSelectLayout {
99
- if (this.cachedLayout?.width === width) return this.cachedLayout.value;
99
+ if (this.cachedLayout?.width === width) {
100
+ return this.cachedLayout.value;
101
+ }
100
102
 
101
103
  const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
102
104
  const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
@@ -106,7 +108,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
106
108
 
107
109
  const otherStart = build.lines.length;
108
110
  build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
109
- if (this.props.other.active) build.focusedRange = [otherStart, build.lines.length];
111
+ if (this.props.other.active) {
112
+ build.focusedRange = [otherStart, build.lines.length];
113
+ }
110
114
 
111
115
  this.appendNextRow(build, width);
112
116
 
@@ -124,7 +128,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
124
128
  for (let i = 0; i < this.question.options.length; i++) {
125
129
  const opt = this.question.options[i];
126
130
  const row = this.props.rows[i];
127
- if (!opt || !row) continue;
131
+ if (!opt || !row) {
132
+ continue;
133
+ }
128
134
  const start = build.lines.length;
129
135
  const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
130
136
  // Checked and active rows share the accent hue, matching the dialog's selection rhythm.
@@ -146,7 +152,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
146
152
  build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
147
153
  }
148
154
  }
149
- if (row.active) build.focusedRange = [start, build.lines.length];
155
+ if (row.active) {
156
+ build.focusedRange = [start, build.lines.length];
157
+ }
150
158
  }
151
159
  }
152
160
 
@@ -159,7 +167,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
159
167
  ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
160
168
  : this.props.nextLabel;
161
169
  build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
162
- if (this.props.nextActive) build.focusedRange = [nextStart, build.lines.length];
170
+ if (this.props.nextActive) {
171
+ build.focusedRange = [nextStart, build.lines.length];
172
+ }
163
173
  }
164
174
 
165
175
  private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
@@ -3,6 +3,8 @@ import { Markdown, type MarkdownTheme, visibleWidth } from "@earendil-works/pi-t
3
3
  import type { QuestionData } from "../../../tool/types.js";
4
4
  import { stripFenceMarkers } from "./preview-box-renderer.js";
5
5
 
6
+ export type MarkdownFactory = (text: string, markdownTheme: MarkdownTheme) => Markdown;
7
+
6
8
  /** CC parity in side-by-side layout. */
7
9
  export const MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
8
10
  /** Preserves narrow-terminal protection in stacked layout. */
@@ -22,18 +24,27 @@ export const NOTES_AFFORDANCE_OVERHEAD = 2;
22
24
  */
23
25
  export class MarkdownContentCache {
24
26
  private readonly previewTexts: Map<number, string>;
25
- private readonly markdownCache: Map<number, Markdown>;
27
+ private readonly markdownCache: Map<number, ReturnType<MarkdownFactory>>;
26
28
  private cachedWidth: number | undefined;
27
29
  private readonly theme: Theme;
28
30
  private readonly markdownTheme: MarkdownTheme;
31
+ private readonly markdownFactory: MarkdownFactory;
29
32
 
30
- constructor(question: QuestionData, theme: Theme, markdownTheme: MarkdownTheme) {
33
+ constructor(
34
+ question: QuestionData,
35
+ theme: Theme,
36
+ markdownTheme: MarkdownTheme,
37
+ markdownFactory: MarkdownFactory = (text, mt) => new Markdown(text, 0, 0, mt),
38
+ ) {
31
39
  this.theme = theme;
32
40
  this.markdownTheme = markdownTheme;
41
+ this.markdownFactory = markdownFactory;
33
42
  this.previewTexts = new Map();
34
43
  for (let i = 0; i < question.options.length; i++) {
35
44
  const raw = question.options[i]?.preview;
36
- if (raw && raw.length > 0) this.previewTexts.set(i, raw);
45
+ if (raw && raw.length > 0) {
46
+ this.previewTexts.set(i, raw);
47
+ }
37
48
  }
38
49
  this.markdownCache = new Map();
39
50
  }
@@ -52,7 +63,9 @@ export class MarkdownContentCache {
52
63
  */
53
64
  bodyFor(optionIndex: number, innerWidth: number): string[] {
54
65
  if (this.cachedWidth !== innerWidth) {
55
- for (const md of this.markdownCache.values()) md.invalidate();
66
+ for (const md of this.markdownCache.values()) {
67
+ md.invalidate();
68
+ }
56
69
  this.cachedWidth = innerWidth;
57
70
  }
58
71
  const text = this.previewTexts.get(optionIndex);
@@ -63,14 +76,16 @@ export class MarkdownContentCache {
63
76
  }
64
77
  let md = this.markdownCache.get(optionIndex);
65
78
  if (!md) {
66
- md = new Markdown(text, 0, 0, this.markdownTheme);
79
+ md = this.markdownFactory(text, this.markdownTheme);
67
80
  this.markdownCache.set(optionIndex, md);
68
81
  }
69
82
  return stripFenceMarkers(md.render(innerWidth));
70
83
  }
71
84
 
72
85
  invalidate(): void {
73
- for (const md of this.markdownCache.values()) md.invalidate();
86
+ for (const md of this.markdownCache.values()) {
87
+ md.invalidate();
88
+ }
74
89
  this.cachedWidth = undefined;
75
90
  }
76
91
  }
@@ -5,6 +5,7 @@ import {
5
5
  MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE,
6
6
  MAX_PREVIEW_HEIGHT_STACKED,
7
7
  MarkdownContentCache,
8
+ type MarkdownFactory,
8
9
  NOTES_AFFORDANCE_OVERHEAD,
9
10
  } from "./markdown-content-cache.js";
10
11
  import {
@@ -38,6 +39,7 @@ export interface PreviewBlockRendererConfig {
38
39
  question: QuestionData;
39
40
  theme: Theme;
40
41
  markdownTheme: MarkdownTheme;
42
+ markdownFactory?: MarkdownFactory;
41
43
  }
42
44
 
43
45
  /**
@@ -56,7 +58,12 @@ export class PreviewBlockRenderer {
56
58
 
57
59
  constructor(config: PreviewBlockRendererConfig) {
58
60
  this.theme = config.theme;
59
- this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
61
+ this.cache = new MarkdownContentCache(
62
+ config.question,
63
+ config.theme,
64
+ config.markdownTheme,
65
+ config.markdownFactory,
66
+ );
60
67
  }
61
68
 
62
69
  hasAnyPreview(): boolean {
@@ -73,14 +73,13 @@ export function renderBorderedBox(
73
73
  * `Markdown.render(width)` pads every line to `width`, which would otherwise force
74
74
  * the box to fill the whole column allocation.
75
75
  */
76
- export function computeBoxDimensions(
77
- contentLines: readonly string[],
78
- maxInnerWidth: number,
79
- ): { innerWidth: number; boxWidth: number } {
76
+ export function computeBoxDimensions(contentLines: readonly string[], maxInnerWidth: number) {
80
77
  let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
81
78
  for (const line of contentLines) {
82
79
  const w = visibleWidth(line.replace(/\s+$/, ""));
83
- if (w > widest) widest = w;
80
+ if (w > widest) {
81
+ widest = w;
82
+ }
84
83
  }
85
84
  const innerWidth = Math.min(widest, maxInnerWidth);
86
85
  const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
@@ -61,7 +61,9 @@ export function adaptiveLeftWidth(
61
61
  let maxLabel = 0;
62
62
  for (const item of items) {
63
63
  const w = visibleWidth(item.label);
64
- if (w > maxLabel) maxLabel = w;
64
+ if (w > maxLabel) {
65
+ maxLabel = w;
66
+ }
65
67
  }
66
68
  const desired = maxLabel + prefixW + confirmedOverhead;
67
69
  const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
@@ -88,7 +90,9 @@ export function crossTabMaxLeftWidth(
88
90
  const items = itemsByTab[i] ?? [];
89
91
  const totalForNumbering = items.length;
90
92
  const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
91
- if (tabWidth > max) max = tabWidth;
93
+ if (tabWidth > max) {
94
+ max = tabWidth;
95
+ }
92
96
  }
93
97
  return max;
94
98
  }
@@ -108,10 +112,14 @@ export function previewSourceWidth(question: QuestionData): number {
108
112
  let max = 0;
109
113
  for (const option of question.options) {
110
114
  const text = option.preview;
111
- if (!text) continue;
115
+ if (!text) {
116
+ continue;
117
+ }
112
118
  for (const line of text.split("\n")) {
113
119
  const w = visibleWidth(line);
114
- if (w > max) max = w;
120
+ if (w > max) {
121
+ max = w;
122
+ }
115
123
  }
116
124
  }
117
125
  return max;
@@ -138,7 +146,9 @@ export function crossTabPreviewBudget(
138
146
  BORDER_HORIZONTAL_OVERHEAD +
139
147
  2 * BORDER_INNER_PADDING_HORIZONTAL +
140
148
  PREVIEW_PADDING_LEFT;
141
- if (budget > max) max = budget;
149
+ if (budget > max) {
150
+ max = budget;
151
+ }
142
152
  }
143
153
  return max;
144
154
  }
@@ -193,10 +203,7 @@ export function crossTabLeftWidthWithDonation(
193
203
  * cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
194
204
  * extreme inputs.
195
205
  */
196
- export function columnWidths(
197
- paneWidth: number,
198
- adaptiveLeft: number,
199
- ): { leftWidth: number; rightWidth: number; gap: number } {
206
+ export function columnWidths(paneWidth: number, adaptiveLeft: number) {
200
207
  const gap = PREVIEW_COLUMN_GAP;
201
208
  const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
202
209
  const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
@@ -208,12 +215,10 @@ export function columnWidths(
208
215
  * `render()`. Stacked uses the full pane width for both; side-by-side splits via
209
216
  * `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
210
217
  */
211
- export function bodyWidths(
212
- paneWidth: number,
213
- mode: PreviewLayoutMode,
214
- adaptiveLeft: number,
215
- ): { optionsWidth: number; previewWidth: number } {
216
- if (mode === "stacked") return { optionsWidth: paneWidth, previewWidth: paneWidth };
218
+ export function bodyWidths(paneWidth: number, mode: PreviewLayoutMode, adaptiveLeft: number) {
219
+ if (mode === "stacked") {
220
+ return { optionsWidth: paneWidth, previewWidth: paneWidth };
221
+ }
217
222
  const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
218
223
  return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
219
224
  }