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.
- package/LICENSE +22 -0
- package/README.md +55 -0
- package/docs/adr/0001-fork-rpiv-ask-user-question-as-zero-dep-pi-ask-popup.md +90 -0
- package/package.json +48 -0
- package/src/ask-user-question.ts +474 -0
- package/src/config.ts +250 -0
- package/src/events.ts +107 -0
- package/src/index.ts +25 -0
- package/src/reconcile.ts +31 -0
- package/src/rpc-fallback.ts +198 -0
- package/src/state/build-questionnaire.ts +346 -0
- package/src/state/external-editor.ts +94 -0
- package/src/state/key-router.ts +378 -0
- package/src/state/questionnaire-session.ts +382 -0
- package/src/state/row-intent.ts +156 -0
- package/src/state/selectors/contract.ts +40 -0
- package/src/state/selectors/derivations.ts +40 -0
- package/src/state/selectors/focus.ts +17 -0
- package/src/state/selectors/projections.ts +111 -0
- package/src/state/state-reducer.ts +421 -0
- package/src/state/state.ts +110 -0
- package/src/tool/format-answer.ts +28 -0
- package/src/tool/response-envelope.ts +123 -0
- package/src/tool/types.ts +193 -0
- package/src/tool/validate-questionnaire.ts +74 -0
- package/src/view/component-binding.ts +51 -0
- package/src/view/components/inline-input.ts +66 -0
- package/src/view/components/multi-select-view.ts +208 -0
- package/src/view/components/option-list-view.ts +77 -0
- package/src/view/components/preview/markdown-content-cache.ts +76 -0
- package/src/view/components/preview/preview-block-renderer.ts +116 -0
- package/src/view/components/preview/preview-box-renderer.ts +88 -0
- package/src/view/components/preview/preview-layout-decider.ts +219 -0
- package/src/view/components/preview/preview-pane.ts +240 -0
- package/src/view/components/submit-picker.ts +66 -0
- package/src/view/components/tab-bar.ts +70 -0
- package/src/view/components/wrapping-select.ts +313 -0
- package/src/view/dialog-builder.ts +325 -0
- package/src/view/props-adapter.ts +124 -0
- package/src/view/stateful-view.ts +20 -0
- package/src/view/tab-components.ts +16 -0
- 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
|
+
}
|