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.
- package/README.md +95 -32
- package/docs/configuration.md +93 -0
- package/docs/hosts.md +71 -0
- package/docs/keyboard.md +65 -0
- package/docs/tool-schema.md +124 -0
- package/package.json +12 -2
- package/preview/popup-submit.webp +0 -0
- package/preview/popup-with-notes.webp +0 -0
- package/preview/popup-with-tab.webp +0 -0
- package/src/ask-user-question.ts +54 -21
- package/src/config.ts +118 -24
- package/src/rpc-fallback.ts +85 -28
- package/src/state/build-questionnaire.ts +45 -8
- package/src/state/external-editor.ts +24 -12
- package/src/state/key-router.ts +152 -41
- package/src/state/questionnaire-session.ts +79 -14
- package/src/state/row-intent.ts +6 -2
- package/src/state/selectors/derivations.ts +18 -6
- package/src/state/selectors/focus.ts +6 -2
- package/src/state/selectors/projections.ts +9 -1
- package/src/state/state-reducer.ts +110 -38
- package/src/tool/response-envelope.ts +71 -22
- package/src/tool/types.ts +28 -4
- package/src/view/component-binding.ts +3 -1
- package/src/view/components/inline-input.ts +3 -1
- package/src/view/components/multi-select-view.ts +32 -7
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +75 -23
- package/src/view/components/preview/preview-block-renderer.ts +8 -1
- package/src/view/components/preview/preview-box-renderer.ts +4 -5
- package/src/view/components/preview/preview-layout-decider.ts +52 -15
- package/src/view/components/preview/preview-pane.ts +72 -28
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +110 -37
- package/src/view/dialog-builder.ts +44 -10
- package/src/view/props-adapter.ts +29 -7
- 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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
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())
|
|
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)
|
|
141
|
+
if (this.props.inputMode) {
|
|
142
|
+
return this.optionListView.render(width);
|
|
143
|
+
}
|
|
130
144
|
|
|
131
|
-
const mode = decideLayout(this.
|
|
132
|
-
if (mode === "side-by-side")
|
|
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)
|
|
150
|
-
|
|
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)
|
|
154
|
-
|
|
155
|
-
|
|
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)
|
|
163
|
-
|
|
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)
|
|
167
|
-
|
|
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.
|
|
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")
|
|
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)
|
|
182
|
-
|
|
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)
|
|
186
|
-
|
|
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.
|
|
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)
|
|
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 === "")
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
78
|
+
const tail = `${submitStyled} →`;
|
|
79
|
+
const tailWidth = visibleWidth(tail);
|
|
80
|
+
const head = pieces.join("");
|
|
66
81
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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)
|
|
159
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
131
|
-
|
|
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
|
-
|
|
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.
|
|
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)
|
|
336
|
+
if (isMulti && tabBar) {
|
|
337
|
+
container.addChild(tabBar);
|
|
338
|
+
}
|
|
311
339
|
container.addChild(new Spacer(1));
|
|
312
340
|
|
|
313
|
-
for (const c of headingRowCache)
|
|
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))
|
|
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))
|
|
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++)
|
|
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)
|
|
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)
|
|
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)
|
|
115
|
+
if (!tab) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
102
118
|
const tabCtx: PerTabBindingContext = { ...ctx, tab, i };
|
|
103
|
-
for (const binding of this.perTabBindings)
|
|
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)
|
|
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)
|
|
142
|
+
for (const x of this.extraInvalidatables) {
|
|
143
|
+
x.invalidate();
|
|
144
|
+
}
|
|
123
145
|
}
|
|
124
146
|
}
|