pi-ask-popup 0.1.0 → 0.2.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 (37) 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 +54 -21
  11. package/src/config.ts +118 -24
  12. package/src/rpc-fallback.ts +85 -28
  13. package/src/state/build-questionnaire.ts +45 -8
  14. package/src/state/external-editor.ts +24 -12
  15. package/src/state/key-router.ts +152 -41
  16. package/src/state/questionnaire-session.ts +79 -14
  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 +9 -1
  21. package/src/state/state-reducer.ts +110 -38
  22. package/src/tool/response-envelope.ts +71 -22
  23. package/src/tool/types.ts +28 -4
  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 +32 -7
  27. package/src/view/components/option-list-view.ts +5 -0
  28. package/src/view/components/preview/markdown-content-cache.ts +75 -23
  29. package/src/view/components/preview/preview-block-renderer.ts +8 -1
  30. package/src/view/components/preview/preview-box-renderer.ts +4 -5
  31. package/src/view/components/preview/preview-layout-decider.ts +52 -15
  32. package/src/view/components/preview/preview-pane.ts +72 -28
  33. package/src/view/components/tab-bar.ts +26 -8
  34. package/src/view/components/wrapping-select.ts +110 -37
  35. package/src/view/dialog-builder.ts +44 -10
  36. package/src/view/props-adapter.ts +29 -7
  37. package/src/view/tab-content-strategy.ts +69 -21
@@ -9,6 +9,8 @@ import type {
9
9
  export const DECLINE_MESSAGE = "User declined to answer questions";
10
10
  export const TIMED_OUT_MESSAGE =
11
11
  "Questionnaire timed out — the user did not respond within the configured timeout. The user never saw a decline; do NOT treat this as a rejection. Ask the questions as plain chat text instead or retry.";
12
+ export const HOST_ERROR_MESSAGE =
13
+ "The host replied with a value that was never offered, so the questionnaire could not be completed. Nobody declined and nobody answered — do NOT treat this as a rejection. Ask the questions as plain chat text instead.";
12
14
  export const ENVELOPE_PREFIX = "User has answered your questions:";
13
15
  export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
14
16
  /** Opens the segment for a note whose question was never answered. */
@@ -37,39 +39,69 @@ export function buildQuestionnaireResponse(
37
39
  params: QuestionParams,
38
40
  ): ToolResult {
39
41
  if (result?.error === "timed_out") {
40
- return buildToolResult(TIMED_OUT_MESSAGE, {
42
+ const details: QuestionnaireResult = {
41
43
  answers: result.answers,
42
44
  cancelled: true,
43
45
  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
- });
46
+ };
47
+ if (result.globalNote && result.globalNote.length > 0) {
48
+ (details as { globalNote: string }).globalNote = result.globalNote;
49
+ }
50
+ if (result.unansweredNotes && result.unansweredNotes.length > 0) {
51
+ (details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
52
+ result.unansweredNotes;
53
+ }
54
+ return buildToolResult(TIMED_OUT_MESSAGE, details);
55
+ }
56
+ if (result?.error === "host_error") {
57
+ // A broken host is not a decision. Sharing the decline text would tell the
58
+ // model the user said no, when the user was never shown a working dialog.
59
+ const details: QuestionnaireResult = {
60
+ answers: result.answers,
61
+ cancelled: true,
62
+ error: "host_error",
63
+ };
64
+ if (result.hostErrorDetail && result.hostErrorDetail.length > 0) {
65
+ (details as { hostErrorDetail: string }).hostErrorDetail = result.hostErrorDetail;
66
+ return buildToolResult(
67
+ `${HOST_ERROR_MESSAGE} (host sent: ${result.hostErrorDetail})`,
68
+ details,
69
+ );
70
+ }
71
+ return buildToolResult(HOST_ERROR_MESSAGE, details);
51
72
  }
52
73
  if (!result || result.cancelled) {
53
74
  // The decline text stays canonical even when a global note rides a
54
75
  // cancelled result. The note survives in `details`, like partial answers.
55
- return buildToolResult(DECLINE_MESSAGE, {
76
+ const details: QuestionnaireResult = {
56
77
  answers: result?.answers ?? [],
57
78
  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
- });
79
+ };
80
+ if (result?.error) {
81
+ (details as { error: typeof result.error }).error = result.error;
82
+ }
83
+ if (result?.globalNote && result.globalNote.length > 0) {
84
+ (details as { globalNote: string }).globalNote = result.globalNote;
85
+ }
86
+ if (result?.unansweredNotes && result.unansweredNotes.length > 0) {
87
+ (details as { unansweredNotes: typeof result.unansweredNotes }).unansweredNotes =
88
+ result.unansweredNotes;
89
+ }
90
+ return buildToolResult(DECLINE_MESSAGE, details);
66
91
  }
67
92
 
93
+ // Indexed once rather than scanned per question. Both sides are keyed by
94
+ // `questionIndex`, which is what the loop below asks for, and it is the same
95
+ // shape `orderedAnswers` uses in the reducer. A first entry wins, so a
96
+ // duplicated index reads as the earlier `find` did.
97
+ const answerByIndex = byQuestionIndex(result.answers);
98
+ const noteByIndex = byQuestionIndex(result.unansweredNotes ?? []);
99
+
68
100
  const segments: string[] = [];
69
101
  // Iterate the questions rather than the answers so segments always follow the
70
102
  // order the model asked in, whatever order the user filled tabs.
71
103
  for (let i = 0; i < params.questions.length; i++) {
72
- const a = result.answers.find((x) => x.questionIndex === i);
104
+ const a = answerByIndex.get(i);
73
105
  if (a) {
74
106
  segments.push(buildAnswerSegment(a));
75
107
  continue;
@@ -78,8 +110,10 @@ export function buildQuestionnaireResponse(
78
110
  // emitted here rather than grouped at the end. Because this loop runs
79
111
  // before the "nothing to report" check below, a questionnaire submitted
80
112
  // with nothing but such a note counts as answered rather than declined.
81
- const n = result.unansweredNotes?.find((x) => x.questionIndex === i);
82
- if (n) segments.push(buildUnansweredNoteSegment(n));
113
+ const n = noteByIndex.get(i);
114
+ if (n) {
115
+ segments.push(buildUnansweredNoteSegment(n));
116
+ }
83
117
  }
84
118
  if (result.globalNote && result.globalNote.length > 0) {
85
119
  // Raw multiline echo, no reformatting, trailing period matching the shape
@@ -92,14 +126,29 @@ export function buildQuestionnaireResponse(
92
126
  return buildToolResult(`${ENVELOPE_PREFIX} ${segments.join(" ")} ${ENVELOPE_SUFFIX}`, result);
93
127
  }
94
128
 
129
+ /** First entry per `questionIndex` wins, which is what `Array.find` did. */
130
+ function byQuestionIndex<T extends { questionIndex: number }>(items: readonly T[]): Map<number, T> {
131
+ const out = new Map<number, T>();
132
+ for (const item of items) {
133
+ if (!out.has(item.questionIndex)) {
134
+ out.set(item.questionIndex, item);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+
95
140
  /**
96
141
  * One answer as an envelope segment: `"question"="answer"`, optionally followed
97
142
  * by the preview the user was looking at and the note they wrote.
98
143
  */
99
144
  export function buildAnswerSegment(a: QuestionAnswer): string {
100
145
  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}`);
146
+ if (a.preview && a.preview.length > 0) {
147
+ parts.push(`selected preview: ${a.preview}`);
148
+ }
149
+ if (a.notes && a.notes.length > 0) {
150
+ parts.push(`user notes: ${a.notes}`);
151
+ }
103
152
  return `${parts.join(". ")}.`;
104
153
  }
105
154
 
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;
@@ -106,7 +114,10 @@ export type QuestionParams = Static<typeof QuestionParamsSchema>;
106
114
  * - `custom` — the user typed free text in the "Type something." row.
107
115
  * `answer` is the text, or null when they committed nothing.
108
116
  * - `multi` — the user committed multi-select choices. `selected` carries the
109
- * chosen labels and `answer` is null.
117
+ * chosen labels and `answer` is null. Text typed on the "Type something." row
118
+ * appears in `selected` too, as its own trimmed entry: on a multi-select
119
+ * question that row counts as chosen whenever it holds text, so an answer can
120
+ * be several authored labels plus one thing the user wrote.
110
121
  */
111
122
  export interface QuestionAnswer {
112
123
  questionIndex: number;
@@ -154,7 +165,8 @@ export type QuestionnaireError =
154
165
  | "reserved_label"
155
166
  | "session_load_failed"
156
167
  | "stale_module_cache"
157
- | "timed_out";
168
+ | "timed_out"
169
+ | "host_error";
158
170
 
159
171
  export interface QuestionnaireResult {
160
172
  answers: QuestionAnswer[];
@@ -184,10 +196,22 @@ export interface QuestionnaireResult {
184
196
  */
185
197
  unansweredNotes?: UnansweredNote[];
186
198
  error?: QuestionnaireError;
199
+ /**
200
+ * What the host actually sent, on a `host_error` result. Quoted into the
201
+ * envelope so whoever reads the transcript can see which value was rejected
202
+ * rather than guessing at the malfunction.
203
+ *
204
+ * Same conditional-spread contract as `globalNote`: present only alongside
205
+ * `error: "host_error"`, never assigned `undefined`.
206
+ */
207
+ hostErrorDetail?: string;
187
208
  }
188
209
 
189
210
  export function isQuestionnaireResult(value: unknown): value is QuestionnaireResult {
190
- if (!value || typeof value !== "object") return false;
191
- const v = value as Record<string, unknown>;
211
+ if (!value || typeof value !== "object") {
212
+ return false;
213
+ }
214
+ // SAFETY: boundary check for QuestionnaireResult; value is validated as object with required fields before use.
215
+ const v = value as Record<string, JsonValue>;
192
216
  return Array.isArray(v.answers) && typeof v.cancelled === "boolean";
193
217
  }
@@ -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
 
@@ -20,6 +20,13 @@ export const MULTI_SUBMIT_LABEL = "Submit";
20
20
  export interface MultiSelectOtherRowProps {
21
21
  /** The "Type something." row is the focused row (optionIndex === options.length). */
22
22
  active: boolean;
23
+ /**
24
+ * The typed text counts as a selection, which on this row means only that
25
+ * there is some. Drawn with the same ✔ as an option: the row is as chosen as
26
+ * a ticked box, and the box used to be painted permanently empty while the
27
+ * text under it was on its way to the model.
28
+ */
29
+ checked: boolean;
23
30
  /** `state.inputMode` — true once the row has focus and keystrokes append to the buffer. */
24
31
  inputMode: boolean;
25
32
  /** Live inline-input buffer (read from `runtime.inputBuffer` / `ctx.inputBuffer`). */
@@ -66,7 +73,13 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
66
73
  ) {
67
74
  this.props = {
68
75
  rows: [],
69
- other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
76
+ other: {
77
+ active: false,
78
+ checked: false,
79
+ inputMode: false,
80
+ inputBuffer: "",
81
+ inputCursorOffset: undefined,
82
+ },
70
83
  nextActive: false,
71
84
  nextLabel: ROW_INTENT_META.next.label,
72
85
  };
@@ -96,7 +109,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
96
109
  }
97
110
 
98
111
  private layout(width: number): MultiSelectLayout {
99
- if (this.cachedLayout?.width === width) return this.cachedLayout.value;
112
+ if (this.cachedLayout?.width === width) {
113
+ return this.cachedLayout.value;
114
+ }
100
115
 
101
116
  const build: MultiSelectBuild = { lines: [], focusedRange: [0, 0] };
102
117
  const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
@@ -106,7 +121,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
106
121
 
107
122
  const otherStart = build.lines.length;
108
123
  build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
109
- if (this.props.other.active) build.focusedRange = [otherStart, build.lines.length];
124
+ if (this.props.other.active) {
125
+ build.focusedRange = [otherStart, build.lines.length];
126
+ }
110
127
 
111
128
  this.appendNextRow(build, width);
112
129
 
@@ -124,7 +141,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
124
141
  for (let i = 0; i < this.question.options.length; i++) {
125
142
  const opt = this.question.options[i];
126
143
  const row = this.props.rows[i];
127
- if (!opt || !row) continue;
144
+ if (!opt || !row) {
145
+ continue;
146
+ }
128
147
  const start = build.lines.length;
129
148
  const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
130
149
  // Checked and active rows share the accent hue, matching the dialog's selection rhythm.
@@ -146,7 +165,9 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
146
165
  build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
147
166
  }
148
167
  }
149
- if (row.active) build.focusedRange = [start, build.lines.length];
168
+ if (row.active) {
169
+ build.focusedRange = [start, build.lines.length];
170
+ }
150
171
  }
151
172
  }
152
173
 
@@ -159,13 +180,17 @@ export class MultiSelectView implements StatefulView<MultiSelectViewProps> {
159
180
  ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel))
160
181
  : this.props.nextLabel;
161
182
  build.lines.push(truncateToWidth(`${nextPointer}${nextLabel}`, width, ""));
162
- if (this.props.nextActive) build.focusedRange = [nextStart, build.lines.length];
183
+ if (this.props.nextActive) {
184
+ build.focusedRange = [nextStart, build.lines.length];
185
+ }
163
186
  }
164
187
 
165
188
  private renderOtherRow(contentWidth: number, numberWidth: number): string[] {
166
189
  const other = this.props.other;
167
190
  const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
168
- const box = this.theme.fg("muted", UNCHECKED);
191
+ const box = other.checked
192
+ ? this.theme.fg("accent", CHECKED)
193
+ : this.theme.fg("muted", UNCHECKED);
169
194
  const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
170
195
  const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
171
196
  const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
@@ -71,6 +71,11 @@ export class OptionListView implements StatefulView<OptionListViewProps> {
71
71
  return this.select.render(width);
72
72
  }
73
73
 
74
+ /** Row count at `width`, equal to `render(width).length`, without building the rows. */
75
+ measureHeight(width: number): number {
76
+ return this.select.measureHeight(width);
77
+ }
78
+
74
79
  focusedItemRowRange(width: number): [number, number] {
75
80
  return this.select.focusedItemRowRange(width);
76
81
  }
@@ -3,37 +3,65 @@ 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. */
9
11
  export const MAX_PREVIEW_HEIGHT_STACKED = 15;
10
12
  export const NO_PREVIEW_TEXT = "No preview available";
13
+ /** Fallback body line when a preview's markdown fails to render (untrusted input boundary). */
14
+ export const PREVIEW_RENDER_FAILED_TEXT = "Preview failed to render";
11
15
  /** 1 blank separator + 1 affordance text row reserved when `hasAnyPreview` (height stability of the affordance row's offset relative to the box). */
12
16
  export const NOTES_AFFORDANCE_OVERHEAD = 2;
13
17
 
14
18
  /**
15
- * Per-question cache for rendered markdown previews. Width-keyed: switching the
16
- * inner width invalidates every cached `Markdown`'s render output (pi-tui's
17
- * `Markdown.render(width)` re-wraps when width changes).
19
+ * One entry per option that carries a preview. `md` is built once and kept for
20
+ * the life of the cache; `lines` are the stripped rows it last produced, and
21
+ * `width` is the inner width they were produced at.
22
+ */
23
+ interface PreviewEntry {
24
+ md: ReturnType<MarkdownFactory>;
25
+ width: number | undefined;
26
+ lines: string[] | undefined;
27
+ }
28
+
29
+ /**
30
+ * Per-question cache for rendered markdown previews, keyed per option.
18
31
  *
19
- * Replaces the inline `previewTexts`, `markdownCache`, `cachedWidth` triple from
20
- * the previous monolithic `preview-pane.ts`. One Markdown per option, lazy on
21
- * first request, never re-constructed — count semantics frozen by tests.
32
+ * Width used to be tracked for the cache as a whole: any change invalidated
33
+ * every `Markdown` it held, so a resize drag re-wrapped all four options one by
34
+ * one through the `maxNaturalHeight` loop even though the frame only measured
35
+ * some of them. Each option now remembers the width it was rendered at, so a
36
+ * width flip costs a re-render of the options that frame actually asks for and
37
+ * nothing else. It also holds the stripped rows, which spares the second strip
38
+ * when `blockHeight` and `renderBlock` ask for the same option in one frame.
39
+ *
40
+ * One Markdown per option, lazy on first request, never re-constructed — count
41
+ * semantics frozen by tests.
22
42
  */
23
43
  export class MarkdownContentCache {
24
44
  private readonly previewTexts: Map<number, string>;
25
- private readonly markdownCache: Map<number, Markdown>;
26
- private cachedWidth: number | undefined;
45
+ private readonly markdownCache: Map<number, PreviewEntry>;
27
46
  private readonly theme: Theme;
28
47
  private readonly markdownTheme: MarkdownTheme;
48
+ private readonly markdownFactory: MarkdownFactory;
29
49
 
30
- constructor(question: QuestionData, theme: Theme, markdownTheme: MarkdownTheme) {
50
+ constructor(
51
+ question: QuestionData,
52
+ theme: Theme,
53
+ markdownTheme: MarkdownTheme,
54
+ markdownFactory: MarkdownFactory = (text, mt) => new Markdown(text, 0, 0, mt),
55
+ ) {
31
56
  this.theme = theme;
32
57
  this.markdownTheme = markdownTheme;
58
+ this.markdownFactory = markdownFactory;
33
59
  this.previewTexts = new Map();
34
60
  for (let i = 0; i < question.options.length; i++) {
35
61
  const raw = question.options[i]?.preview;
36
- if (raw && raw.length > 0) this.previewTexts.set(i, raw);
62
+ if (raw && raw.length > 0) {
63
+ this.previewTexts.set(i, raw);
64
+ }
37
65
  }
38
66
  this.markdownCache = new Map();
39
67
  }
@@ -47,30 +75,54 @@ export class MarkdownContentCache {
47
75
  }
48
76
 
49
77
  /**
50
- * Compute the body lines for a given option at a given inner width. Width changes
51
- * invalidate the per-Markdown render cache.
78
+ * Body lines for one option at one inner width. A repeat request at the width
79
+ * the option was last rendered at returns the stored rows.
80
+ *
81
+ * Untrusted boundary: `preview` is model-authored markdown and this is the only
82
+ * place it reaches the render path. A `Markdown` that throws (malformed input,
83
+ * width edge cases) must not take the overlay down with it, so the render is
84
+ * wrapped and a single dim fallback line is returned instead. There is no
85
+ * notify channel from the view layer — the fallback line IS the diagnostic.
86
+ * A failed render stores nothing, so the next frame tries again.
52
87
  */
53
88
  bodyFor(optionIndex: number, innerWidth: number): string[] {
54
- if (this.cachedWidth !== innerWidth) {
55
- for (const md of this.markdownCache.values()) md.invalidate();
56
- this.cachedWidth = innerWidth;
57
- }
58
89
  const text = this.previewTexts.get(optionIndex);
59
90
  if (!text) {
60
91
  const placeholder = this.theme.fg("dim", NO_PREVIEW_TEXT);
61
92
  const pad = Math.max(0, innerWidth - visibleWidth(placeholder));
62
93
  return [placeholder + " ".repeat(pad)];
63
94
  }
64
- let md = this.markdownCache.get(optionIndex);
65
- if (!md) {
66
- md = new Markdown(text, 0, 0, this.markdownTheme);
67
- this.markdownCache.set(optionIndex, md);
95
+ let entry = this.markdownCache.get(optionIndex);
96
+ if (!entry) {
97
+ entry = {
98
+ md: this.markdownFactory(text, this.markdownTheme),
99
+ width: undefined,
100
+ lines: undefined,
101
+ };
102
+ this.markdownCache.set(optionIndex, entry);
103
+ }
104
+ if (entry.lines !== undefined && entry.width === innerWidth) {
105
+ // A copy: the rows travel into the box renderer and out to the pane, and
106
+ // the cache is the only thing that may hold the originals.
107
+ return [...entry.lines];
108
+ }
109
+ try {
110
+ const lines = stripFenceMarkers(entry.md.render(innerWidth));
111
+ entry.width = innerWidth;
112
+ entry.lines = lines;
113
+ return [...lines];
114
+ } catch {
115
+ entry.width = undefined;
116
+ entry.lines = undefined;
117
+ return [this.theme.fg("dim", PREVIEW_RENDER_FAILED_TEXT)];
68
118
  }
69
- return stripFenceMarkers(md.render(innerWidth));
70
119
  }
71
120
 
72
121
  invalidate(): void {
73
- for (const md of this.markdownCache.values()) md.invalidate();
74
- this.cachedWidth = undefined;
122
+ for (const entry of this.markdownCache.values()) {
123
+ entry.md.invalidate();
124
+ entry.width = undefined;
125
+ entry.lines = undefined;
126
+ }
75
127
  }
76
128
  }
@@ -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
  }
@@ -187,16 +197,45 @@ export function crossTabLeftWidthWithDonation(
187
197
  return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
188
198
  }
189
199
 
200
+ /**
201
+ * Widths kept before the memo below is dropped wholesale. A frame asks for one;
202
+ * the rest of the entries are old terminal sizes from a resize drag.
203
+ */
204
+ const PANE_WIDTH_MEMO_MAX_ENTRIES = 16;
205
+
206
+ /**
207
+ * Memoize a pane-width computation. For `crossTabLeftWidthWithDonation` and
208
+ * `crossTabPreviewBudget`, whose other inputs are fixed for the life of a
209
+ * questionnaire: both scan every tab, every option and every preview source
210
+ * line, and the pane width only changes on resize, yet the callers ask four to
211
+ * five times a frame (`focusedItemRowRange`, `naturalHeight`,
212
+ * `maxNaturalHeight`, `renderSideBySide`).
213
+ */
214
+ export function memoizeByPaneWidth(
215
+ compute: (paneWidth: number) => number,
216
+ ): (paneWidth: number) => number {
217
+ const cache = new Map<number, number>();
218
+ return (paneWidth: number): number => {
219
+ const hit = cache.get(paneWidth);
220
+ if (hit !== undefined) {
221
+ return hit;
222
+ }
223
+ const value = compute(paneWidth);
224
+ if (cache.size >= PANE_WIDTH_MEMO_MAX_ENTRIES) {
225
+ cache.clear();
226
+ }
227
+ cache.set(paneWidth, value);
228
+ return value;
229
+ };
230
+ }
231
+
190
232
  /**
191
233
  * Width allocation for side-by-side mode.
192
234
  * `adaptiveLeft` is the pre-computed left column width (from `adaptiveLeftWidth`,
193
235
  * cross-tab aggregated). The Math.max(1, ...) calls keep both columns >= 1 col on
194
236
  * extreme inputs.
195
237
  */
196
- export function columnWidths(
197
- paneWidth: number,
198
- adaptiveLeft: number,
199
- ): { leftWidth: number; rightWidth: number; gap: number } {
238
+ export function columnWidths(paneWidth: number, adaptiveLeft: number) {
200
239
  const gap = PREVIEW_COLUMN_GAP;
201
240
  const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
202
241
  const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
@@ -208,12 +247,10 @@ export function columnWidths(
208
247
  * `render()`. Stacked uses the full pane width for both; side-by-side splits via
209
248
  * `columnWidths`, with the preview column offset by `PREVIEW_PADDING_LEFT`.
210
249
  */
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 };
250
+ export function bodyWidths(paneWidth: number, mode: PreviewLayoutMode, adaptiveLeft: number) {
251
+ if (mode === "stacked") {
252
+ return { optionsWidth: paneWidth, previewWidth: paneWidth };
253
+ }
217
254
  const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
218
255
  return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
219
256
  }