pi-ask-popup 0.1.1 → 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 +1 -1
- package/package.json +1 -1
- package/src/ask-user-question.ts +10 -3
- package/src/config.ts +52 -1
- package/src/rpc-fallback.ts +63 -21
- package/src/state/build-questionnaire.ts +42 -7
- package/src/state/external-editor.ts +18 -10
- package/src/state/key-router.ts +23 -0
- package/src/state/questionnaire-session.ts +44 -1
- package/src/state/selectors/projections.ts +6 -0
- package/src/state/state-reducer.ts +14 -8
- package/src/tool/response-envelope.ts +39 -2
- package/src/tool/types.ts +15 -2
- package/src/view/components/multi-select-view.ts +17 -2
- package/src/view/components/option-list-view.ts +5 -0
- package/src/view/components/preview/markdown-content-cache.ts +61 -24
- package/src/view/components/preview/preview-layout-decider.ts +32 -0
- package/src/view/components/preview/preview-pane.ts +24 -16
- package/src/view/components/tab-bar.ts +26 -8
- package/src/view/components/wrapping-select.ts +100 -37
- package/src/view/dialog-builder.ts +20 -2
- package/src/view/props-adapter.ts +8 -0
- package/src/view/tab-content-strategy.ts +10 -2
|
@@ -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,16 +151,78 @@ 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[] {
|
|
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 {
|
|
133
218
|
if (this.items.length === 0) {
|
|
134
|
-
return [];
|
|
219
|
+
return { lines: [], rangeByIndex: new Map() };
|
|
135
220
|
}
|
|
136
221
|
|
|
137
222
|
const { startIndex, endIndex } = this.computeVisibleWindow();
|
|
138
223
|
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
139
224
|
const lines: string[] = [];
|
|
225
|
+
const rangeByIndex = new Map<number, [number, number]>();
|
|
140
226
|
|
|
141
227
|
for (let i = startIndex; i < endIndex; i++) {
|
|
142
228
|
const item = this.items[i];
|
|
@@ -144,55 +230,32 @@ export class WrappingSelect implements Component {
|
|
|
144
230
|
continue;
|
|
145
231
|
}
|
|
146
232
|
const isActive = i === this.selectedIndex && this.focused;
|
|
233
|
+
const start = lines.length;
|
|
147
234
|
lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
|
|
235
|
+
rangeByIndex.set(i, [start, lines.length]);
|
|
148
236
|
}
|
|
149
237
|
|
|
150
238
|
if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
|
|
151
239
|
lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
|
|
152
240
|
}
|
|
153
|
-
return lines;
|
|
241
|
+
return { lines, rangeByIndex };
|
|
154
242
|
}
|
|
155
243
|
|
|
156
244
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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.
|
|
160
253
|
*/
|
|
161
254
|
focusedItemRowRange(width: number): [number, number] {
|
|
162
255
|
if (this.items.length === 0) {
|
|
163
256
|
return [0, 0];
|
|
164
257
|
}
|
|
165
|
-
|
|
166
|
-
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
167
|
-
let row = 0;
|
|
168
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
169
|
-
const item = this.items[i];
|
|
170
|
-
if (!item) {
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
const isActive = i === this.selectedIndex && this.focused;
|
|
174
|
-
const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
|
|
175
|
-
if (i === this.selectedIndex) {
|
|
176
|
-
return [row, row + itemRowCount];
|
|
177
|
-
}
|
|
178
|
-
row += itemRowCount;
|
|
179
|
-
}
|
|
180
|
-
return [0, 1];
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Per-item row count. Delegates to `renderItem().length` so `renderItem` remains
|
|
185
|
-
* the single source of truth for per-item row math — eliminates the prior shadow-copy
|
|
186
|
-
* that risked silent miscounts when new `kind` values branch in `renderItem` but not here.
|
|
187
|
-
*/
|
|
188
|
-
private computeItemRowCount(
|
|
189
|
-
item: WrappingSelectItem,
|
|
190
|
-
index: number,
|
|
191
|
-
isActive: boolean,
|
|
192
|
-
width: number,
|
|
193
|
-
numberWidth: number,
|
|
194
|
-
): number {
|
|
195
|
-
return this.renderItem(item, index, isActive, width, numberWidth).length;
|
|
258
|
+
return this.cachedRows(width).rangeByIndex.get(this.selectedIndex) ?? [0, 1];
|
|
196
259
|
}
|
|
197
260
|
|
|
198
261
|
private computeVisibleWindow(): { startIndex: number; endIndex: number } {
|
|
@@ -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";
|
|
@@ -162,7 +169,14 @@ export interface DialogConfig {
|
|
|
162
169
|
/** Body height of the tab showing right now. The difference is absorbed outside the border. */
|
|
163
170
|
getCurrentBodyHeight: (width: number) => number;
|
|
164
171
|
/** Terminal height, read at render time — the mirror of the width getter. */
|
|
165
|
-
|
|
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;
|
|
166
180
|
/**
|
|
167
181
|
* Resolved collapse key (`"ctrl+]"`, `"alt+o"`, `"off"`). Construction-time
|
|
168
182
|
* config, deliberately not canonical state: the runtime's copy must never
|
|
@@ -227,6 +241,10 @@ export class DialogView implements StatefulView<DialogProps> {
|
|
|
227
241
|
invalidate(): void {}
|
|
228
242
|
|
|
229
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
|
+
|
|
230
248
|
const state = this.liveProps.state;
|
|
231
249
|
const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
|
|
232
250
|
const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
|
|
@@ -266,7 +284,7 @@ export class DialogView implements StatefulView<DialogProps> {
|
|
|
266
284
|
strategy.restingNoteRowCount(state),
|
|
267
285
|
);
|
|
268
286
|
|
|
269
|
-
const termRows = this.config.
|
|
287
|
+
const termRows = this.config.getFrameTerminalRows();
|
|
270
288
|
|
|
271
289
|
if (natural.length + spacerRows <= termRows) {
|
|
272
290
|
return renderFitsTerminal(natural, spacerRows);
|
|
@@ -102,6 +102,14 @@ export class QuestionnairePropsAdapter {
|
|
|
102
102
|
binding.apply(state, ctx);
|
|
103
103
|
}
|
|
104
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
|
+
}
|
|
112
|
+
|
|
105
113
|
for (let i = 0; i < this.tabsByIndex.length; i++) {
|
|
106
114
|
const tab = this.tabsByIndex[i];
|
|
107
115
|
if (!tab) {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
HINT_PART_NOTES,
|
|
24
24
|
HINT_PART_NOTES_EDIT,
|
|
25
25
|
HINT_PART_TAB,
|
|
26
|
+
HINT_PART_ENTER_CONFIRM,
|
|
26
27
|
HINT_PART_TOGGLE,
|
|
27
28
|
INCOMPLETE_WARNING_PREFIX,
|
|
28
29
|
KEY_PLACEHOLDER,
|
|
@@ -431,8 +432,15 @@ export function buildHintText(
|
|
|
431
432
|
state: DialogState,
|
|
432
433
|
collapseKey: string,
|
|
433
434
|
): string {
|
|
434
|
-
const
|
|
435
|
-
|
|
435
|
+
const typingOnMulti = question?.multiSelect === true && state.inputMode;
|
|
436
|
+
// Typing on the multi-select free-text row: Enter ticks that row rather than
|
|
437
|
+
// choosing an answer, and Space belongs to the draft, so neither resting part
|
|
438
|
+
// is true here.
|
|
439
|
+
const parts: string[] = [
|
|
440
|
+
typingOnMulti ? HINT_PART_ENTER_CONFIRM : HINT_PART_ENTER,
|
|
441
|
+
HINT_PART_NAV,
|
|
442
|
+
];
|
|
443
|
+
if (question?.multiSelect === true && !typingOnMulti) {
|
|
436
444
|
parts.push(HINT_PART_TOGGLE);
|
|
437
445
|
}
|
|
438
446
|
if (question && !state.notesVisible && !state.inputMode) {
|