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
@@ -59,7 +59,13 @@ export interface PreviewPaneProps {
59
59
 
60
60
  export interface PreviewPaneConfig {
61
61
  question: QuestionData;
62
- getTerminalWidth: () => number;
62
+ /**
63
+ * The frame's terminal width, snapshotted by `DialogView.render` before
64
+ * anything paints. Not a live read of `tui.terminal.columns`: two components
65
+ * deciding layout from two different readings of one frame is the bug this
66
+ * closes.
67
+ */
68
+ getFrameTerminalWidth: () => number;
63
69
  optionListView: OptionListView;
64
70
  previewBlock: PreviewBlockRenderer;
65
71
  }
@@ -74,11 +80,13 @@ export interface PreviewPaneConfig {
74
80
  *
75
81
  * `naturalHeight` and `maxNaturalHeight` query both children's heights; `render`
76
82
  * combines them via `decideLayout` (mode threaded into both calls — never
77
- * re-derived).
83
+ * re-derived). The probes ask `OptionListView.measureHeight`, not `render`, so
84
+ * one frame's three passes over the option list cost one wrap at each distinct
85
+ * width and no discarded rows.
78
86
  */
79
87
  export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
80
88
  private readonly question: QuestionData;
81
- private readonly getTerminalWidth: () => number;
89
+ private readonly getFrameTerminalWidth: () => number;
82
90
  private readonly optionListView: OptionListView;
83
91
  private readonly previewBlock: PreviewBlockRenderer;
84
92
  private props: PreviewPaneProps;
@@ -93,7 +101,7 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
93
101
 
94
102
  constructor(config: PreviewPaneConfig) {
95
103
  this.question = config.question;
96
- this.getTerminalWidth = config.getTerminalWidth;
104
+ this.getFrameTerminalWidth = config.getFrameTerminalWidth;
97
105
  this.optionListView = config.optionListView;
98
106
  this.previewBlock = config.previewBlock;
99
107
  this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
@@ -119,17 +127,25 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
119
127
  }
120
128
 
121
129
  render(width: number): string[] {
122
- if (this.question.multiSelect === true) return this.optionListView.render(width);
130
+ if (this.question.multiSelect === true) {
131
+ return this.optionListView.render(width);
132
+ }
123
133
  // Spec: hide the preview pane entirely when no option carries a `preview`.
124
- if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width);
134
+ if (!this.previewBlock.hasAnyPreview()) {
135
+ return this.optionListView.render(width);
136
+ }
125
137
  // `inputMode` (typing on the "other" custom-answer row): the preview is irrelevant —
126
138
  // the row sits at index `options.length`, out of bounds for any option's preview — so
127
139
  // render the option list at the full pane width instead of the cramped left column.
128
140
  // Side-by-side + preview block resume verbatim on nav-away (inputMode clears).
129
- if (this.props.inputMode) return this.optionListView.render(width);
141
+ if (this.props.inputMode) {
142
+ return this.optionListView.render(width);
143
+ }
130
144
 
131
- const mode = decideLayout(this.getTerminalWidth(), width);
132
- if (mode === "side-by-side") return this.renderSideBySide(width, mode);
145
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
146
+ if (mode === "side-by-side") {
147
+ return this.renderSideBySide(width, mode);
148
+ }
133
149
 
134
150
  // Stacked: options + blank gap + preview block.
135
151
  return [
@@ -146,53 +162,79 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
146
162
  }
147
163
 
148
164
  focusedItemRowRange(width: number): [number, number] {
149
- if (this.question.multiSelect === true) return this.optionListView.focusedItemRowRange(width);
150
- if (!this.previewBlock.hasAnyPreview()) return this.optionListView.focusedItemRowRange(width);
165
+ if (this.question.multiSelect === true) {
166
+ return this.optionListView.focusedItemRowRange(width);
167
+ }
168
+ if (!this.previewBlock.hasAnyPreview()) {
169
+ return this.optionListView.focusedItemRowRange(width);
170
+ }
151
171
  // `inputMode`: compute the focused row range against the FULL pane width (not the
152
172
  // side-by-side leftWidth), mirroring `render`'s full-width option list.
153
- if (this.props.inputMode) return this.optionListView.focusedItemRowRange(width);
154
- const mode = decideLayout(this.getTerminalWidth(), width);
155
- if (mode === "stacked") return this.optionListView.focusedItemRowRange(width);
173
+ if (this.props.inputMode) {
174
+ return this.optionListView.focusedItemRowRange(width);
175
+ }
176
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
177
+ if (mode === "stacked") {
178
+ return this.optionListView.focusedItemRowRange(width);
179
+ }
156
180
  const adaptiveLeft = this.getAdaptiveLeft(width);
157
181
  const { leftWidth } = columnWidths(width, adaptiveLeft);
158
182
  return this.optionListView.focusedItemRowRange(leftWidth);
159
183
  }
160
184
 
161
185
  naturalHeight(width: number): number {
162
- if (this.question.multiSelect === true) return this.optionListView.render(width).length;
163
- if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width).length;
186
+ if (this.question.multiSelect === true) {
187
+ return this.optionListView.measureHeight(width);
188
+ }
189
+ if (!this.previewBlock.hasAnyPreview()) {
190
+ return this.optionListView.measureHeight(width);
191
+ }
164
192
  // `inputMode`: height is the full-width option list only (no preview block) — preserves
165
193
  // the `naturalHeight === render.length` parity invariant.
166
- if (this.props.inputMode) return this.optionListView.render(width).length;
167
- const mode = decideLayout(this.getTerminalWidth(), width);
194
+ if (this.props.inputMode) {
195
+ return this.optionListView.measureHeight(width);
196
+ }
197
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
168
198
  const adaptiveLeft = this.getAdaptiveLeft(width);
169
199
  const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
170
- const optionsHeight = this.optionListView.render(optionsWidth).length;
200
+ const optionsHeight = this.optionListView.measureHeight(optionsWidth);
171
201
  const previewBlockHeight = this.previewBlock.blockHeight(
172
202
  previewWidth,
173
203
  this.props.selectedIndex,
174
204
  mode,
175
205
  );
176
- if (mode === "side-by-side") return Math.max(optionsHeight, previewBlockHeight);
206
+ if (mode === "side-by-side") {
207
+ return Math.max(optionsHeight, previewBlockHeight);
208
+ }
177
209
  return optionsHeight + STACKED_GAP_ROWS + previewBlockHeight;
178
210
  }
179
211
 
180
212
  maxNaturalHeight(width: number): number {
181
- if (this.question.multiSelect === true) return this.optionListView.render(width).length;
182
- if (!this.previewBlock.hasAnyPreview()) return this.optionListView.render(width).length;
213
+ if (this.question.multiSelect === true) {
214
+ return this.optionListView.measureHeight(width);
215
+ }
216
+ if (!this.previewBlock.hasAnyPreview()) {
217
+ return this.optionListView.measureHeight(width);
218
+ }
183
219
  // `inputMode`: like naturalHeight — full-width option list only, so the
184
220
  // `maxNaturalHeight >= naturalHeight` parity invariant holds (both equal the list height).
185
- if (this.props.inputMode) return this.optionListView.render(width).length;
186
- const mode = decideLayout(this.getTerminalWidth(), width);
221
+ if (this.props.inputMode) {
222
+ return this.optionListView.measureHeight(width);
223
+ }
224
+ const mode = decideLayout(this.getFrameTerminalWidth(), width);
187
225
  const adaptiveLeft = this.getAdaptiveLeft(width);
188
226
  const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
189
- const optionsHeight = this.optionListView.render(optionsWidth).length;
227
+ const optionsHeight = this.optionListView.measureHeight(optionsWidth);
190
228
  let maxPreviewBlock = 0;
191
229
  for (let i = 0; i < this.question.options.length; i++) {
192
230
  const h = this.previewBlock.blockHeight(previewWidth, i, mode);
193
- if (h > maxPreviewBlock) maxPreviewBlock = h;
231
+ if (h > maxPreviewBlock) {
232
+ maxPreviewBlock = h;
233
+ }
234
+ }
235
+ if (mode === "side-by-side") {
236
+ return Math.max(optionsHeight, maxPreviewBlock);
194
237
  }
195
- if (mode === "side-by-side") return Math.max(optionsHeight, maxPreviewBlock);
196
238
  return optionsHeight + STACKED_GAP_ROWS + maxPreviewBlock;
197
239
  }
198
240
 
@@ -227,7 +269,9 @@ export class PreviewPane implements StatefulView<PreviewPaneProps>, Component {
227
269
  const boxWidth = Math.max(1, visibleWidth(contentLines[0] ?? ""));
228
270
  const boxAlignedPad = Math.max(PREVIEW_PADDING_LEFT, colWidth - boxWidth);
229
271
  return contentLines.map((line) => {
230
- if (line === "") return "";
272
+ if (line === "") {
273
+ return "";
274
+ }
231
275
  // A line wider than the box (a long-locale notes affordance) slides left to
232
276
  // stay fully visible; truncation engages only when the column itself runs out.
233
277
  const pad = Math.max(
@@ -1,5 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
3
  import type { StatefulView } from "../stateful-view.js";
4
4
 
5
5
  /**
@@ -16,12 +16,13 @@ import type { StatefulView } from "../stateful-view.js";
16
16
  * than being appended, so a segment is the same width noted or not. Appending
17
17
  * it would add one cell per tab, and four tabs at the schema's 16-character
18
18
  * header limit already put this bar at 99 columns: four suffixes would push it
19
- * to 103, past the 100 columns previews require. `truncateToWidth` below is
20
- * called with an empty ellipsis and drops the tail, so what would go missing is
21
- * the Submit tab, silently.
19
+ * to 103, past the 100 columns previews require.
22
20
  */
23
21
  export const NOTED_MARKER = "*";
24
22
 
23
+ /** Shown in place of the question tabs that did not fit. */
24
+ export const TAB_OVERFLOW_ELLIPSIS = "…";
25
+
25
26
  export interface TabBarProps {
26
27
  /** One per author-defined question, in order. */
27
28
  tabs: ReadonlyArray<{ label: string; answered: boolean; active: boolean; noted: boolean }>;
@@ -44,6 +45,19 @@ export class TabBar implements StatefulView<TabBarProps> {
44
45
 
45
46
  invalidate(): void {}
46
47
 
48
+ /**
49
+ * Question tabs absorb the truncation; the Submit pill does not.
50
+ *
51
+ * Four 16-character headers put this bar past 99 columns, so a narrower
52
+ * terminal always drops something. Trimming the joined line from the right
53
+ * dropped Submit first, and a user who cannot see Submit cannot finish the
54
+ * questionnaire. The tail is reserved, the question tabs are trimmed to what
55
+ * is left, and `TAB_OVERFLOW_ELLIPSIS` marks the tabs that went missing.
56
+ *
57
+ * Below the tail's own width there is nothing left to reserve — the whole
58
+ * line is clipped, as before, so the caller's `visibleWidth <= width`
59
+ * invariant survives every terminal size.
60
+ */
47
61
  render(width: number): string[] {
48
62
  const pieces: string[] = [" ← "];
49
63
 
@@ -61,10 +75,14 @@ export class TabBar implements StatefulView<TabBarProps> {
61
75
  const submitStyled = this.props.submit.active
62
76
  ? this.theme.bg("selectedBg", this.theme.fg("text", submitText))
63
77
  : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
64
- pieces.push(submitStyled);
65
- pieces.push(" →");
78
+ const tail = `${submitStyled} →`;
79
+ const tailWidth = visibleWidth(tail);
80
+ const head = pieces.join("");
66
81
 
67
- const tabLine = truncateToWidth(pieces.join(""), width, "");
68
- return [tabLine, ""];
82
+ if (width <= tailWidth) {
83
+ return [truncateToWidth(`${head}${tail}`, width, ""), ""];
84
+ }
85
+ const headLine = truncateToWidth(head, width - tailWidth, TAB_OVERFLOW_ELLIPSIS);
86
+ return [`${headLine}${tail}`, ""];
69
87
  }
70
88
  }
@@ -38,12 +38,24 @@ export interface WrappingSelectOptions {
38
38
  totalItemsForNumbering?: number;
39
39
  }
40
40
 
41
+ /** One width's worth of rendered rows, plus where each item sits in them. */
42
+ interface RenderedRows {
43
+ lines: string[];
44
+ rangeByIndex: Map<number, [number, number]>;
45
+ }
46
+
41
47
  export class WrappingSelect implements Component {
42
48
  private static readonly ACTIVE_POINTER = "❯ ";
43
49
  private static readonly INACTIVE_POINTER = " ";
44
50
  private static readonly NUMBER_SEPARATOR = ". ";
45
51
  private static readonly CONFIRMED_MARK = " ✔";
46
52
  private static readonly MIN_CONTENT_WIDTH = 1;
53
+ /**
54
+ * Widths kept in the render memo before it is dropped wholesale. A frame asks
55
+ * for two or three (full pane, side-by-side left column, height probes); the
56
+ * rest of the entries are old terminal sizes from a resize drag.
57
+ */
58
+ private static readonly RENDER_CACHE_MAX_WIDTHS = 8;
47
59
 
48
60
  private readonly items: readonly WrappingSelectItem[];
49
61
  private readonly maxVisible: number;
@@ -68,6 +80,18 @@ export class WrappingSelect implements Component {
68
80
  * prior answer was custom text, we render that text instead (e.g. `4. Hello ✔`).
69
81
  */
70
82
  private confirmedLabelOverride: string | undefined = undefined;
83
+ /**
84
+ * Rendered rows and row boundaries per width, valid while
85
+ * `renderStateSignature()` holds.
86
+ *
87
+ * One frame calls `render` two or three times at different widths — the
88
+ * option list, the height probes in `PreviewPane`, the side-by-side left
89
+ * column — and every call re-wrapped every visible row. The signature covers
90
+ * all the mutable state a row can read, so the props write that happens each
91
+ * tick with unchanged values keeps the memo warm instead of dropping it.
92
+ */
93
+ private readonly renderCache = new Map<number, RenderedRows>();
94
+ private renderCacheSignature: string | undefined = undefined;
71
95
 
72
96
  constructor(
73
97
  items: readonly WrappingSelectItem[],
@@ -127,64 +151,111 @@ export class WrappingSelect implements Component {
127
151
  /** Intentionally empty — input is routed at the container level. */
128
152
  handleInput(_data: string): void {}
129
153
 
130
- invalidate(): void {}
154
+ invalidate(): void {
155
+ this.renderCache.clear();
156
+ }
131
157
 
132
158
  render(width: number): string[] {
133
- if (this.items.length === 0) return [];
159
+ // A copy, because callers own what they get back: `PreviewPane` hands this
160
+ // array straight up to the dialog, which decorates rows in place.
161
+ return [...this.cachedRows(width).lines];
162
+ }
163
+
164
+ /**
165
+ * Row count at `width` without handing out the rows.
166
+ *
167
+ * Reads the same memo `render` fills, so `measureHeight(w) ===
168
+ * render(w).length` by construction — the height probes in `PreviewPane`
169
+ * relied on that parity while paying for an array copy to get it.
170
+ */
171
+ measureHeight(width: number): number {
172
+ return this.cachedRows(width).lines.length;
173
+ }
174
+
175
+ /** The memo. Returns the stored rows themselves — callers must not mutate them. */
176
+ private cachedRows(width: number): RenderedRows {
177
+ const signature = this.renderStateSignature();
178
+ if (signature !== this.renderCacheSignature) {
179
+ this.renderCache.clear();
180
+ this.renderCacheSignature = signature;
181
+ }
182
+ const cached = this.renderCache.get(width);
183
+ if (cached !== undefined) {
184
+ return cached;
185
+ }
186
+ const rendered = this.renderRows(width);
187
+ if (this.renderCache.size >= WrappingSelect.RENDER_CACHE_MAX_WIDTHS) {
188
+ this.renderCache.clear();
189
+ }
190
+ this.renderCache.set(width, rendered);
191
+ return rendered;
192
+ }
193
+
194
+ /**
195
+ * Every mutable input a rendered row reads. `items`, `maxVisible` and `theme`
196
+ * are constructor-fixed, so they stay out. A NUL separator keeps a buffer
197
+ * containing the separator from colliding with the next field.
198
+ */
199
+ private renderStateSignature(): string {
200
+ return [
201
+ this.selectedIndex,
202
+ this.focused,
203
+ this.confirmedIndex ?? -1,
204
+ this.confirmedLabelOverride ?? "",
205
+ this.inputCursorOffset ?? -1,
206
+ this.numberStartOffset,
207
+ this.totalItemsForNumbering,
208
+ this.inputBuffer,
209
+ ].join("\u0000");
210
+ }
211
+
212
+ /**
213
+ * The single pass. Rows and the row range each item occupies come out
214
+ * together, because they are the same walk: counting an item's rows any other
215
+ * way means rendering it a second time and throwing the strings away.
216
+ */
217
+ private renderRows(width: number): RenderedRows {
218
+ if (this.items.length === 0) {
219
+ return { lines: [], rangeByIndex: new Map() };
220
+ }
134
221
 
135
222
  const { startIndex, endIndex } = this.computeVisibleWindow();
136
223
  const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
137
224
  const lines: string[] = [];
225
+ const rangeByIndex = new Map<number, [number, number]>();
138
226
 
139
227
  for (let i = startIndex; i < endIndex; i++) {
140
228
  const item = this.items[i];
141
- if (!item) continue;
229
+ if (!item) {
230
+ continue;
231
+ }
142
232
  const isActive = i === this.selectedIndex && this.focused;
233
+ const start = lines.length;
143
234
  lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
235
+ rangeByIndex.set(i, [start, lines.length]);
144
236
  }
145
237
 
146
238
  if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
147
239
  lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
148
240
  }
149
- return lines;
241
+ return { lines, rangeByIndex };
150
242
  }
151
243
 
152
244
  /**
153
- * Returns the [startRow, endRow) range of the focused (selected) item within
154
- * the output of `render(width)`. Computed by iterating the visible window and
155
- * summing per-item row counts — O(maxVisible) per call.
245
+ * The [startRow, endRow) range the focused item occupies in `render(width)`.
246
+ *
247
+ * Read out of the same memo the rows come from. It used to re-render every
248
+ * item in the window to count their rows and drop the strings, on every frame
249
+ * the dialog anchored its scroll window — which was every frame.
250
+ *
251
+ * `[0, 1]` when the selection is outside the visible window: there is no
252
+ * range to report, and the first row is the safe thing to anchor on.
156
253
  */
157
254
  focusedItemRowRange(width: number): [number, number] {
158
- if (this.items.length === 0) return [0, 0];
159
- const { startIndex, endIndex } = this.computeVisibleWindow();
160
- const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
161
- let row = 0;
162
- for (let i = startIndex; i < endIndex; i++) {
163
- const item = this.items[i];
164
- if (!item) continue;
165
- const isActive = i === this.selectedIndex && this.focused;
166
- const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
167
- if (i === this.selectedIndex) {
168
- return [row, row + itemRowCount];
169
- }
170
- row += itemRowCount;
255
+ if (this.items.length === 0) {
256
+ return [0, 0];
171
257
  }
172
- return [0, 1];
173
- }
174
-
175
- /**
176
- * Per-item row count. Delegates to `renderItem().length` so `renderItem` remains
177
- * the single source of truth for per-item row math — eliminates the prior shadow-copy
178
- * that risked silent miscounts when new `kind` values branch in `renderItem` but not here.
179
- */
180
- private computeItemRowCount(
181
- item: WrappingSelectItem,
182
- index: number,
183
- isActive: boolean,
184
- width: number,
185
- numberWidth: number,
186
- ): number {
187
- return this.renderItem(item, index, isActive, width, numberWidth).length;
258
+ return this.cachedRows(width).rangeByIndex.get(this.selectedIndex) ?? [0, 1];
188
259
  }
189
260
 
190
261
  private computeVisibleWindow(): { startIndex: number; endIndex: number } {
@@ -306,7 +377,9 @@ export class WrappingSelect implements Component {
306
377
  continuationPrefix: string,
307
378
  contentWidth: number,
308
379
  ): string[] {
309
- if (!description) return [];
380
+ if (!description) {
381
+ return [];
382
+ }
310
383
  const wrapped = wrapTextWithAnsi(description, contentWidth);
311
384
  return wrapped.map((segment) => `${continuationPrefix}${this.theme.description(segment)}`);
312
385
  }
@@ -18,6 +18,13 @@ export const HINT_PART_NAV = "↑/↓ to navigate";
18
18
  export const HINT_PART_NEW_LINE = "Shift+Enter for newline";
19
19
  export const HINT_PART_CLEAR = "Ctrl+U to clear";
20
20
  export const HINT_PART_TOGGLE = "Space to toggle";
21
+ /**
22
+ * Replaces ENTER while the typed row of a multi-select question has the
23
+ * keyboard. Enter there commits the question, the way the Next row does, rather
24
+ * than choosing a single answer — and Space belongs to the draft, so the resting
25
+ * "Space to toggle" is dropped alongside this.
26
+ */
27
+ export const HINT_PART_ENTER_CONFIRM = "Enter to confirm";
21
28
  export const HINT_PART_NOTES = "n to add notes";
22
29
  /** Replaces the add form once the tab already has a note. See `buildHintText`. */
23
30
  export const HINT_PART_NOTES_EDIT = "n to edit notes";
@@ -103,7 +110,9 @@ function computeScrollStart(
103
110
  availableMiddle: number,
104
111
  middleRows: number,
105
112
  ): number {
106
- if (!bodyRange) return 0;
113
+ if (!bodyRange) {
114
+ return 0;
115
+ }
107
116
  const focusedRowInMiddle = headingCount + bodyRange[0];
108
117
  const focusedHeight = bodyRange[1] - bodyRange[0];
109
118
  const idealStart =
@@ -122,13 +131,19 @@ function decorateOverflow(
122
131
  hasDown: boolean,
123
132
  theme: Theme,
124
133
  ): void {
125
- if (scrollableMiddle.length === 0) return;
134
+ if (scrollableMiddle.length === 0) {
135
+ return;
136
+ }
126
137
  if (hasUp && hasDown && scrollableMiddle.length === 1) {
127
138
  scrollableMiddle[0] = theme.fg("dim", OVERFLOW_BOTH);
128
139
  return;
129
140
  }
130
- if (hasUp) scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
131
- if (hasDown) scrollableMiddle[scrollableMiddle.length - 1] = theme.fg("dim", OVERFLOW_DOWN);
141
+ if (hasUp) {
142
+ scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
143
+ }
144
+ if (hasDown) {
145
+ scrollableMiddle[scrollableMiddle.length - 1] = theme.fg("dim", OVERFLOW_DOWN);
146
+ }
132
147
  }
133
148
 
134
149
  export type DialogState = QuestionnaireState;
@@ -154,7 +169,14 @@ export interface DialogConfig {
154
169
  /** Body height of the tab showing right now. The difference is absorbed outside the border. */
155
170
  getCurrentBodyHeight: (width: number) => number;
156
171
  /** Terminal height, read at render time — the mirror of the width getter. */
157
- getTerminalRows: () => number;
172
+ /**
173
+ * Snapshot the terminal size for the frame about to be painted. Called first
174
+ * thing in `render`, before any child renders or is measured, so every
175
+ * component in the frame decides against the same terminal.
176
+ */
177
+ beginFrame: () => void;
178
+ /** The size `beginFrame` last snapshotted. */
179
+ getFrameTerminalRows: () => number;
158
180
  /**
159
181
  * Resolved collapse key (`"ctrl+]"`, `"alt+o"`, `"off"`). Construction-time
160
182
  * config, deliberately not canonical state: the runtime's copy must never
@@ -219,6 +241,10 @@ export class DialogView implements StatefulView<DialogProps> {
219
241
  invalidate(): void {}
220
242
 
221
243
  render(width: number): string[] {
244
+ // First, before any child renders or is measured: the whole frame decides
245
+ // against one terminal size.
246
+ this.config.beginFrame();
247
+
222
248
  const state = this.liveProps.state;
223
249
  const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
224
250
  const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
@@ -258,7 +284,7 @@ export class DialogView implements StatefulView<DialogProps> {
258
284
  strategy.restingNoteRowCount(state),
259
285
  );
260
286
 
261
- const termRows = this.config.getTerminalRows();
287
+ const termRows = this.config.getFrameTerminalRows();
262
288
 
263
289
  if (natural.length + spacerRows <= termRows) {
264
290
  return renderFitsTerminal(natural, spacerRows);
@@ -307,16 +333,24 @@ export class DialogView implements StatefulView<DialogProps> {
307
333
  const border = () => new DynamicBorder((s) => theme.fg("accent", s));
308
334
 
309
335
  container.addChild(border());
310
- if (isMulti && tabBar) container.addChild(tabBar);
336
+ if (isMulti && tabBar) {
337
+ container.addChild(tabBar);
338
+ }
311
339
  container.addChild(new Spacer(1));
312
340
 
313
- for (const c of headingRowCache) container.addChild(c);
341
+ for (const c of headingRowCache) {
342
+ container.addChild(c);
343
+ }
314
344
  container.addChild(strategy.bodyComponent(state));
315
345
  container.addChild(new Spacer(1));
316
- for (const c of strategy.midRows(state)) container.addChild(c);
346
+ for (const c of strategy.midRows(state)) {
347
+ container.addChild(c);
348
+ }
317
349
 
318
350
  container.addChild(border());
319
- for (const c of strategy.footerRows(state)) container.addChild(c);
351
+ for (const c of strategy.footerRows(state)) {
352
+ container.addChild(c);
353
+ }
320
354
 
321
355
  // The residual spacer lives in render(), not here: whether it applies
322
356
  // depends on overflow, which the container cannot see.
@@ -22,7 +22,9 @@ function getInputCursorOffset(input: Editor): number {
22
22
  const lines = input.getLines();
23
23
  const cursor = input.getCursor();
24
24
  let offset = cursor.col;
25
- for (let i = 0; i < cursor.line; i++) offset += (lines[i]?.length ?? 0) + 1;
25
+ for (let i = 0; i < cursor.line; i++) {
26
+ offset += (lines[i]?.length ?? 0) + 1;
27
+ }
26
28
  return offset;
27
29
  }
28
30
 
@@ -82,7 +84,9 @@ export class QuestionnairePropsAdapter {
82
84
  // so there is always a tab. Skipping the tick beats asserting non-null and
83
85
  // throwing out of a render path if that ever stops being true.
84
86
  const activePreviewPane = this.tabsByIndex[paneIndex]?.preview ?? firstTab?.preview;
85
- if (!activePreviewPane) return;
87
+ if (!activePreviewPane) {
88
+ return;
89
+ }
86
90
 
87
91
  const ctx: BindingContext = {
88
92
  questions: this.questions,
@@ -94,13 +98,27 @@ export class QuestionnairePropsAdapter {
94
98
  activePreviewPane,
95
99
  };
96
100
 
97
- for (const binding of this.globalBindings) binding.apply(state, ctx);
101
+ for (const binding of this.globalBindings) {
102
+ binding.apply(state, ctx);
103
+ }
104
+
105
+ // Collapsed, the overlay is one dim row and no tab is on screen. The
106
+ // per-tab writes would only clear caches for a frame nobody sees; expanding
107
+ // dispatches, which brings the whole loop back with current state.
108
+ if (state.collapsed) {
109
+ this.tui.requestRender();
110
+ return;
111
+ }
98
112
 
99
113
  for (let i = 0; i < this.tabsByIndex.length; i++) {
100
114
  const tab = this.tabsByIndex[i];
101
- if (!tab) continue;
115
+ if (!tab) {
116
+ continue;
117
+ }
102
118
  const tabCtx: PerTabBindingContext = { ...ctx, tab, i };
103
- for (const binding of this.perTabBindings) binding.apply(state, tabCtx);
119
+ for (const binding of this.perTabBindings) {
120
+ binding.apply(state, tabCtx);
121
+ }
104
122
  }
105
123
 
106
124
  this.tui.requestRender();
@@ -113,12 +131,16 @@ export class QuestionnairePropsAdapter {
113
131
  * refresh them. Walks the same registries `apply()` does, then the extras.
114
132
  */
115
133
  invalidate(): void {
116
- for (const b of this.globalBindings) b.invalidate();
134
+ for (const b of this.globalBindings) {
135
+ b.invalidate();
136
+ }
117
137
  for (const tab of this.tabsByIndex) {
118
138
  tab.optionList.invalidate();
119
139
  tab.preview.invalidate();
120
140
  tab.multiSelect?.invalidate();
121
141
  }
122
- for (const x of this.extraInvalidatables) x.invalidate();
142
+ for (const x of this.extraInvalidatables) {
143
+ x.invalidate();
144
+ }
123
145
  }
124
146
  }