pi-ask-popup 0.1.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 (42) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +55 -0
  3. package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
  4. package/package.json +48 -0
  5. package/src/ask-user-question.ts +474 -0
  6. package/src/config.ts +250 -0
  7. package/src/events.ts +107 -0
  8. package/src/index.ts +25 -0
  9. package/src/reconcile.ts +31 -0
  10. package/src/rpc-fallback.ts +198 -0
  11. package/src/state/build-questionnaire.ts +346 -0
  12. package/src/state/external-editor.ts +94 -0
  13. package/src/state/key-router.ts +378 -0
  14. package/src/state/questionnaire-session.ts +382 -0
  15. package/src/state/row-intent.ts +156 -0
  16. package/src/state/selectors/contract.ts +40 -0
  17. package/src/state/selectors/derivations.ts +40 -0
  18. package/src/state/selectors/focus.ts +17 -0
  19. package/src/state/selectors/projections.ts +111 -0
  20. package/src/state/state-reducer.ts +421 -0
  21. package/src/state/state.ts +110 -0
  22. package/src/tool/format-answer.ts +28 -0
  23. package/src/tool/response-envelope.ts +123 -0
  24. package/src/tool/types.ts +193 -0
  25. package/src/tool/validate-questionnaire.ts +74 -0
  26. package/src/view/component-binding.ts +51 -0
  27. package/src/view/components/inline-input.ts +66 -0
  28. package/src/view/components/multi-select-view.ts +208 -0
  29. package/src/view/components/option-list-view.ts +77 -0
  30. package/src/view/components/preview/markdown-content-cache.ts +76 -0
  31. package/src/view/components/preview/preview-block-renderer.ts +116 -0
  32. package/src/view/components/preview/preview-box-renderer.ts +88 -0
  33. package/src/view/components/preview/preview-layout-decider.ts +219 -0
  34. package/src/view/components/preview/preview-pane.ts +240 -0
  35. package/src/view/components/submit-picker.ts +66 -0
  36. package/src/view/components/tab-bar.ts +70 -0
  37. package/src/view/components/wrapping-select.ts +313 -0
  38. package/src/view/dialog-builder.ts +325 -0
  39. package/src/view/props-adapter.ts +124 -0
  40. package/src/view/stateful-view.ts +20 -0
  41. package/src/view/tab-components.ts +16 -0
  42. package/src/view/tab-content-strategy.ts +447 -0
@@ -0,0 +1,124 @@
1
+ import type { Editor } from "@earendil-works/pi-tui";
2
+ import type { BindingContext, PerTabBindingContext } from "../state/selectors/contract.js";
3
+ import { selectActivePreviewPaneIndex } from "../state/selectors/derivations.js";
4
+ import { selectActiveView } from "../state/selectors/focus.js";
5
+ import type { WrappingSelectItem } from "../state/row-intent.js";
6
+ import type { QuestionnaireState } from "../state/state.js";
7
+ import type { QuestionData } from "../tool/types.js";
8
+ import type { BoundGlobalBinding, BoundPerTabBinding } from "./component-binding.js";
9
+ import type { TabComponents } from "./tab-components.js";
10
+
11
+ /** What the adapter needs of a renderable to refresh it. pi-tui's `Component` already has it. */
12
+ interface Invalidatable {
13
+ invalidate(): void;
14
+ }
15
+
16
+ /**
17
+ * Flatten the editor's line/column cursor to an offset into `getText()`, which
18
+ * is the coordinate the row renderers draw a caret at. The `+ 1` per line is
19
+ * the newline `getText()` joins with.
20
+ */
21
+ function getInputCursorOffset(input: Editor): number {
22
+ const lines = input.getLines();
23
+ const cursor = input.getCursor();
24
+ let offset = cursor.col;
25
+ for (let i = 0; i < cursor.line; i++) offset += (lines[i]?.length ?? 0) + 1;
26
+ return offset;
27
+ }
28
+
29
+ export interface QuestionnairePropsAdapterConfig {
30
+ tui: { requestRender(): void };
31
+ questions: readonly QuestionData[];
32
+ itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
33
+ tabsByIndex: ReadonlyArray<TabComponents>;
34
+ inlineInput: Editor;
35
+ globalBindings: ReadonlyArray<BoundGlobalBinding>;
36
+ perTabBindings: ReadonlyArray<BoundPerTabBinding>;
37
+ /**
38
+ * Renderables the binding registries do not reach — the notes `Editor`, which
39
+ * is typed into directly and has no props. Walked by `invalidate()` after the
40
+ * bound components.
41
+ */
42
+ extraInvalidatables?: ReadonlyArray<Invalidatable>;
43
+ }
44
+
45
+ /**
46
+ * View fan-out. Every component setter is driven from canonical state through
47
+ * two registries: `globalBindings` for the cross-tab components (dialog, submit
48
+ * picker, tab bar), `perTabBindings` for the per-tab kinds (option list,
49
+ * preview, multi-select). One global loop and one nested per-tab loop replace a
50
+ * hand-written fan-out that had to be edited every time a component was added.
51
+ *
52
+ * The inline-Other text is read off the headless `inlineInput` once per tick
53
+ * and put in the context, so the row selectors see the live value without any
54
+ * component reaching for the editor itself.
55
+ */
56
+ export class QuestionnairePropsAdapter {
57
+ private readonly tui: QuestionnairePropsAdapterConfig["tui"];
58
+ private readonly questions: readonly QuestionData[];
59
+ private readonly itemsByTab: ReadonlyArray<readonly WrappingSelectItem[]>;
60
+ private readonly tabsByIndex: ReadonlyArray<TabComponents>;
61
+ private readonly inlineInput: Editor;
62
+ private readonly globalBindings: ReadonlyArray<BoundGlobalBinding>;
63
+ private readonly perTabBindings: ReadonlyArray<BoundPerTabBinding>;
64
+ private readonly extraInvalidatables: ReadonlyArray<Invalidatable>;
65
+
66
+ constructor(config: QuestionnairePropsAdapterConfig) {
67
+ this.tui = config.tui;
68
+ this.questions = config.questions;
69
+ this.itemsByTab = config.itemsByTab;
70
+ this.tabsByIndex = config.tabsByIndex;
71
+ this.inlineInput = config.inlineInput;
72
+ this.globalBindings = config.globalBindings;
73
+ this.perTabBindings = config.perTabBindings;
74
+ this.extraInvalidatables = config.extraInvalidatables ?? [];
75
+ }
76
+
77
+ apply(state: QuestionnaireState): void {
78
+ const totalQuestions = this.questions.length;
79
+ const paneIndex = selectActivePreviewPaneIndex(state.currentTab, totalQuestions);
80
+ const firstTab = this.tabsByIndex[0];
81
+ // Unreachable in practice: validation caps a questionnaire at 1-4 questions,
82
+ // so there is always a tab. Skipping the tick beats asserting non-null and
83
+ // throwing out of a render path if that ever stops being true.
84
+ const activePreviewPane = this.tabsByIndex[paneIndex]?.preview ?? firstTab?.preview;
85
+ if (!activePreviewPane) return;
86
+
87
+ const ctx: BindingContext = {
88
+ questions: this.questions,
89
+ itemsByTab: this.itemsByTab,
90
+ totalQuestions,
91
+ activeView: selectActiveView(state, totalQuestions),
92
+ inputBuffer: this.inlineInput.getText(),
93
+ inputCursorOffset: getInputCursorOffset(this.inlineInput),
94
+ activePreviewPane,
95
+ };
96
+
97
+ for (const binding of this.globalBindings) binding.apply(state, ctx);
98
+
99
+ for (let i = 0; i < this.tabsByIndex.length; i++) {
100
+ const tab = this.tabsByIndex[i];
101
+ if (!tab) continue;
102
+ const tabCtx: PerTabBindingContext = { ...ctx, tab, i };
103
+ for (const binding of this.perTabBindings) binding.apply(state, tabCtx);
104
+ }
105
+
106
+ this.tui.requestRender();
107
+ }
108
+
109
+ /**
110
+ * Invalidate every renderable this adapter owns. The session calls this
111
+ * instead of the old `dialog.invalidate()` chain: the dialog no longer reaches
112
+ * sideways into the tab bar, the notes editor or the active preview pane to
113
+ * refresh them. Walks the same registries `apply()` does, then the extras.
114
+ */
115
+ invalidate(): void {
116
+ for (const b of this.globalBindings) b.invalidate();
117
+ for (const tab of this.tabsByIndex) {
118
+ tab.optionList.invalidate();
119
+ tab.preview.invalidate();
120
+ tab.multiSelect?.invalidate();
121
+ }
122
+ for (const x of this.extraInvalidatables) x.invalidate();
123
+ }
124
+ }
@@ -0,0 +1,20 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+
3
+ /**
4
+ * Generic prop-driven component contract. Every renderable owns its own `P` shape;
5
+ * the adapter computes `P` from canonical state via per-component selectors and
6
+ * pushes it via `setProps`. `focused: boolean` is a field on `P` only where the
7
+ * component needs it.
8
+ */
9
+ export interface StatefulView<P> extends Component {
10
+ setProps(props: P): void;
11
+ }
12
+
13
+ /**
14
+ * Re-exported, never redeclared. Which surface owns the keyboard is canonical
15
+ * state: the key router's cascade and the reducer's defensive clears are what
16
+ * enforce mutual exclusion, and components only read the result. Per-component
17
+ * `focused` flags derive from one equality check against this discriminant
18
+ * rather than from parallel booleans.
19
+ */
20
+ export type { ActiveView } from "../state/state.js";
@@ -0,0 +1,16 @@
1
+ import type { MultiSelectView } from "./components/multi-select-view.js";
2
+ import type { OptionListViewProps } from "./components/option-list-view.js";
3
+ import type { PreviewPane } from "./components/preview/preview-pane.js";
4
+ import type { StatefulView } from "./stateful-view.js";
5
+
6
+ export interface TabBodyHeights {
7
+ current: number;
8
+ max: number;
9
+ }
10
+
11
+ export interface TabComponents {
12
+ optionList: StatefulView<OptionListViewProps>;
13
+ preview: PreviewPane;
14
+ multiSelect?: MultiSelectView;
15
+ bodyHeights: (width: number) => TabBodyHeights;
16
+ }
@@ -0,0 +1,447 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Container,
5
+ type Editor,
6
+ Spacer,
7
+ Text,
8
+ truncateToWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import { COLLAPSE_KEY_OFF, formatKeySpecForDisplay } from "../config.js";
11
+ import { formatAnswerScalar } from "../tool/format-answer.js";
12
+ import { noteForTab } from "../state/state.js";
13
+ import type { QuestionData } from "../tool/types.js";
14
+ import type { PreviewPane, PreviewPaneProps } from "./components/preview/preview-pane.js";
15
+ import {
16
+ type DialogState,
17
+ HINT_PART_CANCEL,
18
+ HINT_PART_CLEAR,
19
+ HINT_PART_COLLAPSE_TEMPLATE,
20
+ HINT_PART_ENTER,
21
+ HINT_PART_NAV,
22
+ HINT_PART_NEW_LINE,
23
+ HINT_PART_NOTES,
24
+ HINT_PART_NOTES_EDIT,
25
+ HINT_PART_TAB,
26
+ HINT_PART_TOGGLE,
27
+ INCOMPLETE_WARNING_PREFIX,
28
+ KEY_PLACEHOLDER,
29
+ READY_PROMPT,
30
+ REVIEW_HEADING,
31
+ } from "./dialog-builder.js";
32
+ import type { StatefulView } from "./stateful-view.js";
33
+ import type { TabComponents } from "./tab-components.js";
34
+
35
+ const NOTES_HEADER = "Notes:";
36
+ /**
37
+ * Label for a committed note, shared by the question tab's resting row and the
38
+ * Submit review so the two cannot drift apart. Lowercase where the editor
39
+ * header is capitalised: capitalised means live, lowercase means at rest.
40
+ */
41
+ const NOTES_LABEL = "notes:";
42
+ const GLOBAL_NOTES_HEADER = "Global note:";
43
+ const REVIEW_GLOBAL_HINT = "n to add a note";
44
+ const REVIEW_NOTE_LABEL = "Note";
45
+
46
+ /**
47
+ * A chrome cell that is always exactly one row, clipped to width.
48
+ *
49
+ * The footer row count is an invariant the height math depends on, and pi-tui's
50
+ * `Text` word-wraps: a hint longer than the terminal is wide would silently
51
+ * become two rows, and every tab's height would stop agreeing. Clipping keeps
52
+ * it at one row and lets the tail — the collapse affordance first — fall off
53
+ * the right edge with `…` on a narrow terminal.
54
+ */
55
+ class OneLineClippedText implements Component {
56
+ constructor(
57
+ private readonly text: string,
58
+ private readonly paddingLeft: number = 0,
59
+ ) {}
60
+
61
+ render(width: number): string[] {
62
+ const pad = " ".repeat(this.paddingLeft);
63
+ const avail = Math.max(0, width - this.paddingLeft);
64
+ return [pad + truncateToWidth(this.text, avail, "…", false)];
65
+ }
66
+
67
+ invalidate(): void {}
68
+
69
+ handleInput(_data: string): void {}
70
+ }
71
+
72
+ /** Header text for a review row, falling back to a position when the author gave none. */
73
+ function tabLabel(header: string | undefined, index: number): string {
74
+ return header !== undefined && header.length > 0 ? header : `Q${index + 1}`;
75
+ }
76
+
77
+ /**
78
+ * What a tab puts in each region of the dialog. Pure: construction-time config
79
+ * is closed over, per-tick state arrives as an argument. The frame equalizes
80
+ * height across tabs from `bodyHeight + footerRowCount`, which is why the row
81
+ * counts below are contracts and not estimates.
82
+ */
83
+ export interface TabContentStrategy {
84
+ /** Rendered footer rows. MUST equal what `footerRows()` emits — the residual math reads it. */
85
+ readonly footerRowCount: number;
86
+
87
+ /** Rows between the top chrome and the body. */
88
+ headingRows(state: DialogState): Component[];
89
+
90
+ /** The body itself. */
91
+ bodyComponent(state: DialogState): Component;
92
+
93
+ /** Rendered height of `bodyComponent(state)` at this width. */
94
+ bodyHeight(width: number, state: DialogState): number;
95
+
96
+ /** Rows between the body's trailing spacer and the bottom border. */
97
+ midRows(state: DialogState): Component[];
98
+
99
+ /** Rows below the bottom border. Rendered count MUST equal `footerRowCount`. */
100
+ footerRows(state: DialogState): Component[];
101
+
102
+ /** Where the focused item sits inside the body, or undefined when nothing is focused. */
103
+ focusedItemRowRange(width: number, state: DialogState): [number, number] | undefined;
104
+ /**
105
+ * Rows this strategy spends on a committed note at rest, so the frame can
106
+ * equalize them across tabs. Not measured from `midRows`, because the open
107
+ * notes editor also lives there and its height is an intentional expansion
108
+ * that must stay outside the equalization.
109
+ */
110
+ restingNoteRowCount(state: DialogState): number;
111
+ }
112
+
113
+ /** One line, whatever the note did. See `QuestionTabStrategy.restingNoteRows`. */
114
+ function collapseToOneLine(note: string): string {
115
+ return note.replace(/\s*\n\s*/g, " ");
116
+ }
117
+
118
+ export interface QuestionTabStrategyConfig {
119
+ theme: Theme;
120
+ questions: readonly QuestionData[];
121
+ getPreviewPane: () => StatefulView<PreviewPaneProps>;
122
+ tabsByIndex: ReadonlyArray<TabComponents>;
123
+ notesInput: Editor;
124
+ isMulti: boolean;
125
+ getCurrentBodyHeight: (width: number) => number;
126
+ /** Resolved collapse key. Drives whether the footer advertises the shortcut at all. */
127
+ collapseKey: string;
128
+ }
129
+
130
+ export class QuestionTabStrategy implements TabContentStrategy {
131
+ /** Spacer(1) + the clipped hint row. */
132
+ readonly footerRowCount = 2;
133
+
134
+ constructor(private readonly config: QuestionTabStrategyConfig) {}
135
+
136
+ headingRows(state: DialogState): Component[] {
137
+ const out: Component[] = [];
138
+ const question = this.config.questions[state.currentTab];
139
+ // With several questions the tab bar already shows the header, so the
140
+ // inline badge would say it twice.
141
+ if (!this.config.isMulti && question?.header !== undefined && question.header.length > 0) {
142
+ out.push(new Text(this.config.theme.bg("selectedBg", ` ${question.header} `), 1, 0));
143
+ out.push(new Spacer(1));
144
+ }
145
+ if (question) {
146
+ out.push(new Text(this.config.theme.bold(question.question), 1, 0));
147
+ out.push(new Spacer(1));
148
+ }
149
+ return out;
150
+ }
151
+
152
+ bodyComponent(state: DialogState): Component {
153
+ const question = this.config.questions[state.currentTab];
154
+ const multiSelect = this.config.tabsByIndex[state.currentTab]?.multiSelect;
155
+ if (question?.multiSelect === true && multiSelect) return multiSelect;
156
+ return this.config.getPreviewPane();
157
+ }
158
+
159
+ bodyHeight(width: number, _state: DialogState): number {
160
+ return this.config.getCurrentBodyHeight(width);
161
+ }
162
+
163
+ midRows(state: DialogState): Component[] {
164
+ if (!state.notesVisible) return this.restingNoteRows(state);
165
+ return [
166
+ new Text(this.config.theme.fg("muted", NOTES_HEADER), 1, 0),
167
+ this.config.notesInput,
168
+ new Spacer(1),
169
+ ];
170
+ }
171
+
172
+ /**
173
+ * A committed note, shown at rest in the slot the editor vacates.
174
+ *
175
+ * Before this existed a note vanished the moment the editor closed, and
176
+ * walking back to its tab did not bring it back — the only way to see one
177
+ * again was to reopen the editor.
178
+ *
179
+ * One row, always. The editor accepts newlines, and rendering an eight-line
180
+ * note in full would eat a scroll region that `computeScrollStart` centres on
181
+ * the option list. The whole text is one keypress away, and the Submit review
182
+ * shows it complete, where `Text` wraps instead of clipping.
183
+ *
184
+ * The reserved blank row is a `Spacer`, never `Text("")`: pi-tui's `Text`
185
+ * renders no lines at all for whitespace-only content, so an empty `Text`
186
+ * would reserve nothing and the height equalization would quietly do nothing.
187
+ * It carries no placeholder text on purpose. A `Spacer(1)` already sits
188
+ * between the body and this slot, so a second blank line reads as bottom
189
+ * padding rather than as something missing, whereas a dim `notes: —` would
190
+ * put noise on every un-noted tab of every questionnaire.
191
+ */
192
+ restingNoteRows(state: DialogState): Component[] {
193
+ if (this.restingNoteRowCount(state) === 0) return [];
194
+ const note = noteForTab(state, state.currentTab);
195
+ if (note.length === 0) return [new Spacer(1)];
196
+ return [
197
+ new OneLineClippedText(
198
+ this.config.theme.fg("dim", `${NOTES_LABEL} ${collapseToOneLine(note)}`),
199
+ 1,
200
+ ),
201
+ ];
202
+ }
203
+
204
+ /**
205
+ * One row as soon as any question tab carries a note, zero otherwise.
206
+ *
207
+ * Reserved on every question tab rather than only the noted ones: a row
208
+ * present on one tab and absent on the next would resize the dialog on every
209
+ * Tab press, which is exactly what `spacerRows` exists to prevent. It tracks
210
+ * live state, so clearing the last note gives the row back.
211
+ *
212
+ * Zero while the editor is open. The editor is the note's representation
213
+ * then, and its own height is the intentional expansion.
214
+ */
215
+ restingNoteRowCount(state: DialogState): number {
216
+ if (state.notesVisible) return 0;
217
+ for (let i = 0; i < this.config.questions.length; i++) {
218
+ if (noteForTab(state, i).length > 0) return 1;
219
+ }
220
+ return 0;
221
+ }
222
+
223
+ footerRows(state: DialogState): Component[] {
224
+ const question = this.config.questions[state.currentTab];
225
+ return [
226
+ new Spacer(1),
227
+ new OneLineClippedText(
228
+ this.config.theme.fg(
229
+ "dim",
230
+ buildHintText(question, this.config.isMulti, state, this.config.collapseKey),
231
+ ),
232
+ 1,
233
+ ),
234
+ ];
235
+ }
236
+
237
+ focusedItemRowRange(width: number, state: DialogState): [number, number] | undefined {
238
+ const question = this.config.questions[state.currentTab];
239
+ const multiSelect = this.config.tabsByIndex[state.currentTab]?.multiSelect;
240
+ if (question?.multiSelect === true && multiSelect) {
241
+ return multiSelect.focusedItemRowRange(width);
242
+ }
243
+ // SAFETY: getPreviewPane returns a StatefulView<PreviewPaneProps> structurally identical to PreviewPane; cast is to access row range.
244
+ return (this.config.getPreviewPane() as unknown as PreviewPane).focusedItemRowRange(width);
245
+ }
246
+ }
247
+
248
+ export interface SubmitTabStrategyConfig {
249
+ theme: Theme;
250
+ questions: readonly QuestionData[];
251
+ submitPicker: Component | undefined;
252
+ /** The shared notes editor, mounted here while the global note is being written. */
253
+ notesInput: Editor;
254
+ }
255
+
256
+ export class SubmitTabStrategy implements TabContentStrategy {
257
+ /**
258
+ * Spacer(1) + prompt + picker(2) + hint. Without a picker, two spacers stand
259
+ * in so the count is still 5.
260
+ */
261
+ readonly footerRowCount = 5;
262
+
263
+ constructor(private readonly config: SubmitTabStrategyConfig) {}
264
+
265
+ headingRows(_state: DialogState): Component[] {
266
+ return [
267
+ new Text(this.config.theme.bold(this.config.theme.fg("accent", REVIEW_HEADING)), 1, 0),
268
+ new Spacer(1),
269
+ ];
270
+ }
271
+
272
+ bodyComponent(state: DialogState): Component {
273
+ const c = new Container();
274
+ for (let i = 0; i < this.config.questions.length; i++) {
275
+ const q = this.config.questions[i];
276
+ if (!q) continue;
277
+ const a = state.answers.get(i);
278
+ const note = noteForTab(state, i);
279
+ // A note with no answer still gets an entry. Skipping it, which is what
280
+ // this loop used to do, dropped the note from the review and from the
281
+ // result: the user wrote something and was never told it went nowhere.
282
+ if (!a && note.length === 0) continue;
283
+ c.addChild(new Text(this.config.theme.fg("muted", ` ● ${tabLabel(q.header, i)}`), 1, 0));
284
+ // No arrow row when there is no answer. The absent row is the signal, and
285
+ // the footer already names the question in its incomplete warning, so
286
+ // there is nothing to gain from minting a placeholder to sit in the
287
+ // answer's place.
288
+ if (a) {
289
+ const answerText = formatAnswerScalar(a, "summary");
290
+ c.addChild(
291
+ new Text(
292
+ ` ${this.config.theme.fg("muted", "→")} ${this.config.theme.fg("text", answerText)}`,
293
+ 1,
294
+ 0,
295
+ ),
296
+ );
297
+ }
298
+ if (note.length > 0) {
299
+ // `Text`, not `OneLineClippedText`: the review is where a long note is
300
+ // meant to be read whole, so it wraps and `bodyHeight` measures it.
301
+ c.addChild(new Text(this.config.theme.fg("dim", ` ${NOTES_LABEL} ${note}`), 1, 0));
302
+ }
303
+ }
304
+ // The committed global note appears as a review entry, so pressing `n` has
305
+ // visible effect and the note can be read back before submitting. Hidden
306
+ // while the editor is open: the editor below is seeded with this text, and
307
+ // a copy above it would read as a second, separate note.
308
+ const globalNote = state.notesByTab.get(this.config.questions.length);
309
+ if (!state.notesVisible && globalNote !== undefined && globalNote.length > 0) {
310
+ c.addChild(new Text(this.config.theme.fg("muted", ` ● ${REVIEW_NOTE_LABEL}`), 1, 0));
311
+ c.addChild(
312
+ new Text(
313
+ ` ${this.config.theme.fg("muted", "→")} ${this.config.theme.fg("text", globalNote)}`,
314
+ 1,
315
+ 0,
316
+ ),
317
+ );
318
+ }
319
+ return c;
320
+ }
321
+
322
+ bodyHeight(width: number, state: DialogState): number {
323
+ return this.bodyComponent(state).render(width).length;
324
+ }
325
+
326
+ midRows(state: DialogState): Component[] {
327
+ // The question tabs' notes editor, mirrored for the global note. The draft
328
+ // itself is reducer-owned; this only renders it.
329
+ if (!state.notesVisible) return [];
330
+ return [
331
+ new Text(this.config.theme.fg("muted", GLOBAL_NOTES_HEADER), 1, 0),
332
+ this.config.notesInput,
333
+ new Spacer(1),
334
+ ];
335
+ }
336
+
337
+ footerRows(state: DialogState): Component[] {
338
+ const missing: string[] = [];
339
+ for (let i = 0; i < this.config.questions.length; i++) {
340
+ const q = this.config.questions[i];
341
+ if (q && !state.answers.has(i)) missing.push(tabLabel(q.header, i));
342
+ }
343
+ const promptText =
344
+ missing.length === 0
345
+ ? this.config.theme.fg("muted", READY_PROMPT)
346
+ : this.config.theme.fg("warning", `${INCOMPLETE_WARNING_PREFIX} ${missing.join(", ")}`);
347
+ // Clipped, not `Text`, for the same reason as the hint row below it. The
348
+ // incomplete warning is the longest string this footer can produce — the
349
+ // prefix plus every missing header — and it passes 80 columns with four
350
+ // unanswered questions, which would wrap it into a second row and make the
351
+ // rendered count disagree with `footerRowCount`.
352
+ const out: Component[] = [new Spacer(1), new OneLineClippedText(promptText, 1)];
353
+ if (this.config.submitPicker) {
354
+ out.push(this.config.submitPicker);
355
+ } else {
356
+ // Padding for the unwired case, so the rendered count still matches.
357
+ out.push(new Spacer(1));
358
+ out.push(new Spacer(1));
359
+ }
360
+ // The same dim `·`-joined bottom row the question tabs use, so the prompt
361
+ // reads straight into its picker with no hint wedged between them. Always
362
+ // present, so the count stays at 5.
363
+ out.push(new OneLineClippedText(this.config.theme.fg("dim", buildSubmitHintText(state)), 1));
364
+ return out;
365
+ }
366
+
367
+ focusedItemRowRange(_width: number, _state: DialogState): [number, number] | undefined {
368
+ return undefined;
369
+ }
370
+
371
+ /**
372
+ * Always zero. The global note already appears as a review entry in the body,
373
+ * so a resting row here would show the same note twice. The frame pads this
374
+ * tab instead, which is what keeps it level with the question tabs once one
375
+ * of them reserves a row.
376
+ */
377
+ restingNoteRowCount(_state: DialogState): number {
378
+ return 0;
379
+ }
380
+ }
381
+
382
+ function remainingLabel(state: DialogState): string | undefined {
383
+ if (state.timerCancelled) return undefined;
384
+ const ms = state.remainingMs;
385
+ if (ms === undefined) return undefined;
386
+ const secs = Math.max(0, Math.ceil(ms / 1000));
387
+ return `${secs}s left`;
388
+ }
389
+
390
+ /**
391
+ * The controls hint, in order:
392
+ * Enter · ↑/↓ [· Space toggle] [· n notes] [· Tab switch] · Esc [· <key> collapse]
393
+ * [· Shift+Enter newline] [· Ctrl+U clear] [· Ns left]
394
+ *
395
+ * NOTES belongs to the resting core and drops as soon as the notes editor or
396
+ * the custom-answer row has the keyboard. Ctrl+G is Pi's own external-editor
397
+ * shortcut and needs no hint from us; the clear shortcut is appended at the far
398
+ * right only while input mode is active.
399
+ *
400
+ * The collapse part is omitted when the key is `"off"`. Both the key router and
401
+ * the raw terminal listener refuse to collapse in that case, so advertising a
402
+ * key would be a lie.
403
+ */
404
+ export function buildHintText(
405
+ question: QuestionData | undefined,
406
+ isMulti: boolean,
407
+ state: DialogState,
408
+ collapseKey: string,
409
+ ): string {
410
+ const parts: string[] = [HINT_PART_ENTER, HINT_PART_NAV];
411
+ if (question?.multiSelect === true) parts.push(HINT_PART_TOGGLE);
412
+ if (question && !state.notesVisible && !state.inputMode) {
413
+ // "add" is wrong once one exists, and the hint is the only affordance
414
+ // telling a keyboard user the resting row can be reopened at all.
415
+ parts.push(
416
+ noteForTab(state, state.currentTab).length > 0 ? HINT_PART_NOTES_EDIT : HINT_PART_NOTES,
417
+ );
418
+ }
419
+ if (isMulti) parts.push(HINT_PART_TAB);
420
+ parts.push(HINT_PART_CANCEL);
421
+ if (collapseKey !== COLLAPSE_KEY_OFF) {
422
+ parts.push(
423
+ HINT_PART_COLLAPSE_TEMPLATE.replace(KEY_PLACEHOLDER, formatKeySpecForDisplay(collapseKey)),
424
+ );
425
+ }
426
+ if (state.notesVisible || state.inputMode) parts.push(HINT_PART_NEW_LINE);
427
+ if (state.inputMode) parts.push(HINT_PART_CLEAR);
428
+ const rem = remainingLabel(state);
429
+ if (rem) parts.push(rem);
430
+ return parts.join(" · ");
431
+ }
432
+
433
+ /**
434
+ * The submit tab's counterpart. Resting: Enter · ↑/↓ · n to add a note · Esc.
435
+ * With the global-note editor open the note part drops, because the open editor
436
+ * is the affordance, and the newline hint is appended after cancel — the same
437
+ * shape the question tabs take when their notes are open.
438
+ */
439
+ export function buildSubmitHintText(state: DialogState): string {
440
+ const parts: string[] = [HINT_PART_ENTER, HINT_PART_NAV];
441
+ if (!state.notesVisible) parts.push(REVIEW_GLOBAL_HINT);
442
+ parts.push(HINT_PART_CANCEL);
443
+ if (state.notesVisible) parts.push(HINT_PART_NEW_LINE);
444
+ const rem = remainingLabel(state);
445
+ if (rem) parts.push(rem);
446
+ return parts.join(" · ");
447
+ }