@fadhelhaidar/pi-ask-user 0.0.0-stage → 1.0.2
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 +21 -0
- package/NOTICE.md +43 -0
- package/README.md +111 -2
- package/dist/chunks/external-editor-5wd19nyg.js +51 -0
- package/dist/chunks/index-bx2tbz93.js +5 -0
- package/dist/chunks/index-x7fb685n.js +226 -0
- package/dist/chunks/questionnaire-session-gdq5zqtt.js +2190 -0
- package/dist/events.d.ts +53 -0
- package/dist/events.js +8 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +538 -0
- package/dist/locales/de.json +31 -0
- package/dist/locales/en.json +29 -0
- package/dist/locales/es.json +31 -0
- package/dist/locales/fr.json +31 -0
- package/dist/locales/pt-BR.json +31 -0
- package/dist/locales/pt.json +31 -0
- package/dist/locales/ru.json +31 -0
- package/dist/locales/uk.json +31 -0
- package/dist/locales/zh.json +33 -0
- package/docs/configuration.md +121 -0
- package/docs/hosts.md +97 -0
- package/docs/keyboard.md +115 -0
- package/docs/localization.md +87 -0
- package/docs/tool-schema.md +127 -0
- package/package.json +64 -4
|
@@ -0,0 +1,2190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_COLLAPSE_KEY,
|
|
3
|
+
COLLAPSE_KEY_OFF,
|
|
4
|
+
formatKeySpecForDisplay,
|
|
5
|
+
ROW_INTENT_META,
|
|
6
|
+
t,
|
|
7
|
+
displayLabel,
|
|
8
|
+
formatAnswerScalar
|
|
9
|
+
} from "./index-x7fb685n.js";
|
|
10
|
+
|
|
11
|
+
// view/dialog-builder.ts
|
|
12
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Container as Container2, Spacer as Spacer2 } from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
// view/tab-content-strategy.ts
|
|
16
|
+
import { Container, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
var NOTES_HEADER = "Notes:";
|
|
18
|
+
var GLOBAL_NOTES_HEADER = "Global note:";
|
|
19
|
+
var REVIEW_GLOBAL_HINT = "n to add a note";
|
|
20
|
+
var REVIEW_NOTE_LABEL = "Note";
|
|
21
|
+
|
|
22
|
+
class OneLineClippedText {
|
|
23
|
+
text;
|
|
24
|
+
paddingLeft;
|
|
25
|
+
constructor(text, paddingLeft = 0) {
|
|
26
|
+
this.text = text;
|
|
27
|
+
this.paddingLeft = paddingLeft;
|
|
28
|
+
}
|
|
29
|
+
render(width) {
|
|
30
|
+
const pad = " ".repeat(this.paddingLeft);
|
|
31
|
+
const avail = Math.max(0, width - this.paddingLeft);
|
|
32
|
+
return [pad + truncateToWidth(this.text, avail, "…", false)];
|
|
33
|
+
}
|
|
34
|
+
invalidate() {}
|
|
35
|
+
handleInput(_data) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class QuestionTabStrategy {
|
|
39
|
+
config;
|
|
40
|
+
footerRowCount = 2;
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
}
|
|
44
|
+
headingRows(state) {
|
|
45
|
+
const out = [];
|
|
46
|
+
const question = this.config.questions[state.currentTab];
|
|
47
|
+
if (!this.config.isMulti && question?.header && question.header.length > 0) {
|
|
48
|
+
out.push(new Text(this.config.theme.bg("selectedBg", ` ${question.header} `), 1, 0));
|
|
49
|
+
out.push(new Spacer(1));
|
|
50
|
+
}
|
|
51
|
+
if (question) {
|
|
52
|
+
out.push(new Text(this.config.theme.bold(question.question), 1, 0));
|
|
53
|
+
out.push(new Spacer(1));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
bodyComponent(state) {
|
|
58
|
+
const question = this.config.questions[state.currentTab];
|
|
59
|
+
const mso = this.config.tabsByIndex[state.currentTab]?.multiSelect;
|
|
60
|
+
if (question?.multiSelect === true && mso)
|
|
61
|
+
return mso;
|
|
62
|
+
return this.config.getPreviewPane();
|
|
63
|
+
}
|
|
64
|
+
bodyHeight(width, _state) {
|
|
65
|
+
return this.config.getCurrentBodyHeight(width);
|
|
66
|
+
}
|
|
67
|
+
midRows(state) {
|
|
68
|
+
if (!state.notesVisible)
|
|
69
|
+
return [];
|
|
70
|
+
return [
|
|
71
|
+
new Text(this.config.theme.fg("muted", t("notes.header", NOTES_HEADER)), 1, 0),
|
|
72
|
+
this.config.notesInput,
|
|
73
|
+
new Spacer(1)
|
|
74
|
+
];
|
|
75
|
+
}
|
|
76
|
+
footerRows(state) {
|
|
77
|
+
const question = this.config.questions[state.currentTab];
|
|
78
|
+
return [
|
|
79
|
+
new Spacer(1),
|
|
80
|
+
new OneLineClippedText(this.config.theme.fg("dim", buildHintText(question, this.config.isMulti, state, this.config.collapseKey)), 1)
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
focusedItemRowRange(width, state) {
|
|
84
|
+
const question = this.config.questions[state.currentTab];
|
|
85
|
+
const mso = this.config.tabsByIndex[state.currentTab]?.multiSelect;
|
|
86
|
+
if (question?.multiSelect === true && mso)
|
|
87
|
+
return mso.focusedItemRowRange(width);
|
|
88
|
+
return this.config.getPreviewPane().focusedItemRowRange(width);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class SubmitTabStrategy {
|
|
93
|
+
config;
|
|
94
|
+
footerRowCount = 5;
|
|
95
|
+
constructor(config) {
|
|
96
|
+
this.config = config;
|
|
97
|
+
}
|
|
98
|
+
headingRows(_state) {
|
|
99
|
+
return [
|
|
100
|
+
new Text(this.config.theme.bold(this.config.theme.fg("accent", t("review.heading", REVIEW_HEADING))), 1, 0),
|
|
101
|
+
new Spacer(1)
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
bodyComponent(state) {
|
|
105
|
+
const c = new Container;
|
|
106
|
+
for (let i = 0;i < this.config.questions.length; i++) {
|
|
107
|
+
const q = this.config.questions[i];
|
|
108
|
+
const a = state.answers.get(i);
|
|
109
|
+
if (!a)
|
|
110
|
+
continue;
|
|
111
|
+
const label = q.header && q.header.length > 0 ? q.header : `Q${i + 1}`;
|
|
112
|
+
const answerText = formatAnswerScalar(a, "summary");
|
|
113
|
+
c.addChild(new Text(this.config.theme.fg("muted", ` ● ${label}`), 1, 0));
|
|
114
|
+
c.addChild(new Text(` ${this.config.theme.fg("muted", "→")} ${this.config.theme.fg("text", answerText)}`, 1, 0));
|
|
115
|
+
if (a.notes && a.notes.length > 0) {
|
|
116
|
+
c.addChild(new Text(this.config.theme.fg("dim", ` notes: ${a.notes}`), 1, 0));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const globalNote = state.notesByTab.get(this.config.questions.length);
|
|
120
|
+
if (!state.notesVisible && globalNote && globalNote.length > 0) {
|
|
121
|
+
c.addChild(new Text(this.config.theme.fg("muted", ` ● ${t("review.note_label", REVIEW_NOTE_LABEL)}`), 1, 0));
|
|
122
|
+
c.addChild(new Text(` ${this.config.theme.fg("muted", "→")} ${this.config.theme.fg("text", globalNote)}`, 1, 0));
|
|
123
|
+
}
|
|
124
|
+
return c;
|
|
125
|
+
}
|
|
126
|
+
bodyHeight(width, state) {
|
|
127
|
+
return this.bodyComponent(state).render(width).length;
|
|
128
|
+
}
|
|
129
|
+
midRows(state) {
|
|
130
|
+
if (!state.notesVisible)
|
|
131
|
+
return [];
|
|
132
|
+
return [
|
|
133
|
+
new Text(this.config.theme.fg("muted", t("notes.global_header", GLOBAL_NOTES_HEADER)), 1, 0),
|
|
134
|
+
this.config.notesInput,
|
|
135
|
+
new Spacer(1)
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
footerRows(state) {
|
|
139
|
+
const missing = [];
|
|
140
|
+
for (let i = 0;i < this.config.questions.length; i++) {
|
|
141
|
+
const q = this.config.questions[i];
|
|
142
|
+
if (!state.answers.has(i)) {
|
|
143
|
+
missing.push(q.header && q.header.length > 0 ? q.header : `Q${i + 1}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const promptText = missing.length === 0 ? this.config.theme.fg("muted", t("review.ready", READY_PROMPT)) : this.config.theme.fg("warning", `${t("review.incomplete", INCOMPLETE_WARNING_PREFIX)} ${missing.join(", ")}`);
|
|
147
|
+
const out = [new Spacer(1), new Text(promptText, 1, 0)];
|
|
148
|
+
if (this.config.submitPicker) {
|
|
149
|
+
out.push(this.config.submitPicker);
|
|
150
|
+
} else {
|
|
151
|
+
out.push(new Spacer(1));
|
|
152
|
+
out.push(new Spacer(1));
|
|
153
|
+
}
|
|
154
|
+
out.push(new OneLineClippedText(this.config.theme.fg("dim", buildSubmitHintText(state)), 1));
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
focusedItemRowRange(_width, _state) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function buildHintText(question, isMulti, state, collapseKey) {
|
|
162
|
+
const enterHint = question?.multiSelect === true && !state.notesVisible ? t("hint.enter_toggle", HINT_PART_TOGGLE) : t("hint.enter", HINT_PART_ENTER);
|
|
163
|
+
const parts = [enterHint, t("hint.navigate", HINT_PART_NAV)];
|
|
164
|
+
if (question && !state.notesVisible && !state.inputMode)
|
|
165
|
+
parts.push(t("hint.notes", HINT_PART_NOTES));
|
|
166
|
+
if (isMulti)
|
|
167
|
+
parts.push(t("hint.tab", HINT_PART_TAB));
|
|
168
|
+
parts.push(t("hint.cancel", HINT_PART_CANCEL));
|
|
169
|
+
if (collapseKey !== COLLAPSE_KEY_OFF) {
|
|
170
|
+
parts.push(t("hint.collapse", HINT_PART_COLLAPSE_TEMPLATE).replace(KEY_PLACEHOLDER, formatKeySpecForDisplay(collapseKey)));
|
|
171
|
+
}
|
|
172
|
+
if (state.notesVisible || state.inputMode)
|
|
173
|
+
parts.push(t("hint.newline", HINT_PART_NEW_LINE));
|
|
174
|
+
if (state.inputMode)
|
|
175
|
+
parts.push(t("hint.clear", HINT_PART_CLEAR));
|
|
176
|
+
return parts.join(" · ");
|
|
177
|
+
}
|
|
178
|
+
function buildSubmitHintText(state) {
|
|
179
|
+
const parts = [t("hint.enter", HINT_PART_ENTER), t("hint.navigate", HINT_PART_NAV)];
|
|
180
|
+
if (!state.notesVisible)
|
|
181
|
+
parts.push(t("review.global_hint", REVIEW_GLOBAL_HINT));
|
|
182
|
+
parts.push(t("hint.cancel", HINT_PART_CANCEL));
|
|
183
|
+
if (state.notesVisible)
|
|
184
|
+
parts.push(t("hint.newline", HINT_PART_NEW_LINE));
|
|
185
|
+
return parts.join(" · ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// view/dialog-builder.ts
|
|
189
|
+
var HINT_PART_ENTER = "Enter to select";
|
|
190
|
+
var HINT_PART_NAV = "↑/↓ to navigate";
|
|
191
|
+
var HINT_PART_NEW_LINE = "Shift+Enter for newline";
|
|
192
|
+
var HINT_PART_CLEAR = "Ctrl+U to clear";
|
|
193
|
+
var HINT_PART_TOGGLE = "Enter to toggle";
|
|
194
|
+
var HINT_PART_NOTES = "n to add notes";
|
|
195
|
+
var HINT_PART_TAB = "Tab to switch questions";
|
|
196
|
+
var HINT_PART_CANCEL = "Esc to cancel";
|
|
197
|
+
var KEY_PLACEHOLDER = "{key}";
|
|
198
|
+
var HINT_PART_COLLAPSE_TEMPLATE = `${KEY_PLACEHOLDER} to collapse`;
|
|
199
|
+
var HINT_PART_EXPAND_TEMPLATE = `${KEY_PLACEHOLDER} to expand`;
|
|
200
|
+
var HINT_PART_COLLAPSE = HINT_PART_COLLAPSE_TEMPLATE.replace(KEY_PLACEHOLDER, formatKeySpecForDisplay(DEFAULT_COLLAPSE_KEY));
|
|
201
|
+
var HINT_SINGLE = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_CANCEL].join(" · ");
|
|
202
|
+
var HINT_MULTI = [HINT_PART_ENTER, HINT_PART_NAV, HINT_PART_NOTES, HINT_PART_TAB, HINT_PART_CANCEL].join(" · ");
|
|
203
|
+
var COLLAPSED_HINT_TEMPLATE = [HINT_PART_EXPAND_TEMPLATE, HINT_PART_CANCEL].join(" · ");
|
|
204
|
+
var REVIEW_HEADING = "Review your answers";
|
|
205
|
+
var READY_PROMPT = "Ready to submit your answers?";
|
|
206
|
+
var INCOMPLETE_WARNING_PREFIX = "⚠ Answer remaining questions before submitting:";
|
|
207
|
+
var OVERFLOW_UP = "↑";
|
|
208
|
+
var OVERFLOW_DOWN = "↓";
|
|
209
|
+
var OVERFLOW_BOTH = "↕";
|
|
210
|
+
function renderFitsTerminal(natural, spacerRows) {
|
|
211
|
+
return spacerRows > 0 ? [...natural, ...Array(spacerRows).fill("")] : natural;
|
|
212
|
+
}
|
|
213
|
+
function renderChromeOnly(natural, topFixed, bottomFixed, termRows) {
|
|
214
|
+
const chromeOnly = [...natural.slice(0, topFixed), ...natural.slice(natural.length - bottomFixed)];
|
|
215
|
+
return chromeOnly.length > termRows ? chromeOnly.slice(0, termRows) : chromeOnly;
|
|
216
|
+
}
|
|
217
|
+
function computeScrollStart(bodyRange, headingCount, availableMiddle, middleRows) {
|
|
218
|
+
if (!bodyRange)
|
|
219
|
+
return 0;
|
|
220
|
+
const focusedRowInMiddle = headingCount + bodyRange[0];
|
|
221
|
+
const focusedHeight = bodyRange[1] - bodyRange[0];
|
|
222
|
+
const idealStart = focusedRowInMiddle - Math.floor(Math.max(0, availableMiddle - focusedHeight) / 2);
|
|
223
|
+
return Math.max(0, Math.min(idealStart, middleRows - availableMiddle));
|
|
224
|
+
}
|
|
225
|
+
function decorateOverflow(scrollableMiddle, hasUp, hasDown, theme) {
|
|
226
|
+
if (hasUp && hasDown && scrollableMiddle.length === 1) {
|
|
227
|
+
scrollableMiddle[0] = theme.fg("dim", OVERFLOW_BOTH);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (hasUp && scrollableMiddle.length > 0) {
|
|
231
|
+
scrollableMiddle[0] = theme.fg("dim", OVERFLOW_UP);
|
|
232
|
+
}
|
|
233
|
+
if (hasDown && scrollableMiddle.length > 0) {
|
|
234
|
+
scrollableMiddle[scrollableMiddle.length - 1] = theme.fg("dim", OVERFLOW_DOWN);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
class DialogView {
|
|
239
|
+
liveProps;
|
|
240
|
+
config;
|
|
241
|
+
questionStrategy;
|
|
242
|
+
submitStrategy;
|
|
243
|
+
maxFooterRowCount;
|
|
244
|
+
constructor(config, initialProps) {
|
|
245
|
+
this.config = config;
|
|
246
|
+
this.liveProps = initialProps;
|
|
247
|
+
this.questionStrategy = new QuestionTabStrategy({
|
|
248
|
+
theme: config.theme,
|
|
249
|
+
questions: config.questions,
|
|
250
|
+
getPreviewPane: () => this.liveProps.activePreviewPane,
|
|
251
|
+
tabsByIndex: config.tabsByIndex,
|
|
252
|
+
notesInput: config.notesInput,
|
|
253
|
+
isMulti: config.isMulti,
|
|
254
|
+
getCurrentBodyHeight: config.getCurrentBodyHeight,
|
|
255
|
+
collapseKey: config.collapseKey
|
|
256
|
+
});
|
|
257
|
+
this.submitStrategy = config.isMulti ? new SubmitTabStrategy({
|
|
258
|
+
theme: config.theme,
|
|
259
|
+
questions: config.questions,
|
|
260
|
+
submitPicker: config.submitPicker,
|
|
261
|
+
notesInput: config.notesInput
|
|
262
|
+
}) : undefined;
|
|
263
|
+
this.maxFooterRowCount = Math.max(this.questionStrategy.footerRowCount, this.submitStrategy?.footerRowCount ?? 0);
|
|
264
|
+
}
|
|
265
|
+
setProps(props) {
|
|
266
|
+
this.liveProps = props;
|
|
267
|
+
}
|
|
268
|
+
handleInput(_data) {}
|
|
269
|
+
invalidate() {}
|
|
270
|
+
render(width) {
|
|
271
|
+
const state = this.liveProps.state;
|
|
272
|
+
const onSubmit = this.config.isMulti && state.currentTab === this.config.questions.length;
|
|
273
|
+
const strategy = onSubmit && this.submitStrategy ? this.submitStrategy : this.questionStrategy;
|
|
274
|
+
const headingRowCache = strategy.headingRows(state);
|
|
275
|
+
const headingCount = headingRowCache.length;
|
|
276
|
+
const natural = this.buildContainerFromStrategy(strategy, headingRowCache).render(width);
|
|
277
|
+
const topFixed = 1 + (this.config.isMulti && this.config.tabBar ? 2 : 0) + 1;
|
|
278
|
+
const bottomFixed = 1 + strategy.footerRowCount;
|
|
279
|
+
const middleRows = natural.length - topFixed - bottomFixed;
|
|
280
|
+
const spacerRows = Math.max(0, this.config.getBodyHeight(width) + this.maxFooterRowCount - strategy.bodyHeight(width, state) - strategy.footerRowCount);
|
|
281
|
+
const termRows = this.config.getTerminalRows();
|
|
282
|
+
if (natural.length + spacerRows <= termRows) {
|
|
283
|
+
return renderFitsTerminal(natural, spacerRows);
|
|
284
|
+
}
|
|
285
|
+
const availableMiddle = Math.max(0, termRows - topFixed - bottomFixed);
|
|
286
|
+
if (availableMiddle === 0) {
|
|
287
|
+
return renderChromeOnly(natural, topFixed, bottomFixed, termRows);
|
|
288
|
+
}
|
|
289
|
+
const scrollStart = computeScrollStart(strategy.focusedItemRowRange(width, state), headingCount, availableMiddle, middleRows);
|
|
290
|
+
const scrollableMiddle = natural.slice(topFixed + scrollStart, topFixed + scrollStart + availableMiddle);
|
|
291
|
+
decorateOverflow(scrollableMiddle, scrollStart > 0, scrollStart + availableMiddle < middleRows, this.config.theme);
|
|
292
|
+
const result = [
|
|
293
|
+
...natural.slice(0, topFixed),
|
|
294
|
+
...scrollableMiddle,
|
|
295
|
+
...natural.slice(natural.length - bottomFixed)
|
|
296
|
+
];
|
|
297
|
+
return result.length > termRows ? result.slice(0, termRows) : result;
|
|
298
|
+
}
|
|
299
|
+
buildContainerFromStrategy(strategy, headingRowCache) {
|
|
300
|
+
const { theme, isMulti, tabBar } = this.config;
|
|
301
|
+
const state = this.liveProps.state;
|
|
302
|
+
const container = new Container2;
|
|
303
|
+
const border = () => new DynamicBorder((s) => theme.fg("accent", s));
|
|
304
|
+
container.addChild(border());
|
|
305
|
+
if (isMulti && tabBar)
|
|
306
|
+
container.addChild(tabBar);
|
|
307
|
+
container.addChild(new Spacer2(1));
|
|
308
|
+
for (const c of headingRowCache)
|
|
309
|
+
container.addChild(c);
|
|
310
|
+
container.addChild(strategy.bodyComponent(state));
|
|
311
|
+
container.addChild(new Spacer2(1));
|
|
312
|
+
for (const c of strategy.midRows(state))
|
|
313
|
+
container.addChild(c);
|
|
314
|
+
container.addChild(border());
|
|
315
|
+
for (const c of strategy.footerRows(state))
|
|
316
|
+
container.addChild(c);
|
|
317
|
+
return container;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// state/build-questionnaire.ts
|
|
322
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
323
|
+
import { Editor } from "@earendil-works/pi-tui";
|
|
324
|
+
|
|
325
|
+
// view/component-binding.ts
|
|
326
|
+
function globalBinding(spec) {
|
|
327
|
+
return {
|
|
328
|
+
apply: (state, ctx) => spec.component.setProps(spec.select(state, ctx)),
|
|
329
|
+
invalidate: () => spec.component.invalidate()
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function perTabBinding(spec) {
|
|
333
|
+
return {
|
|
334
|
+
apply: (state, ctx) => {
|
|
335
|
+
if (spec.predicate && !spec.predicate(state, ctx))
|
|
336
|
+
return;
|
|
337
|
+
spec.resolve(ctx.tab)?.setProps(spec.select(state, ctx));
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// view/components/multi-select-view.ts
|
|
343
|
+
import { truncateToWidth as truncateToWidth2, visibleWidth, wrapTextWithAnsi as wrapTextWithAnsi2 } from "@earendil-works/pi-tui";
|
|
344
|
+
|
|
345
|
+
// view/components/inline-input.ts
|
|
346
|
+
import { CURSOR_MARKER, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
347
|
+
var graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
348
|
+
function resolveCursorOffset(buffer, requested) {
|
|
349
|
+
if (requested !== undefined && requested >= 0 && requested <= buffer.length)
|
|
350
|
+
return requested;
|
|
351
|
+
return buffer.length;
|
|
352
|
+
}
|
|
353
|
+
function buildCursorRaw(buffer, offset) {
|
|
354
|
+
const before = buffer.slice(0, offset);
|
|
355
|
+
const [firstGrapheme] = graphemeSegmenter.segment(buffer.slice(offset));
|
|
356
|
+
const rawAt = firstGrapheme ? firstGrapheme.segment : "";
|
|
357
|
+
const cursorAtLineEnd = rawAt === `
|
|
358
|
+
`;
|
|
359
|
+
const atCursor = rawAt === "" || rawAt === " " || cursorAtLineEnd ? " " : rawAt;
|
|
360
|
+
const after = buffer.slice(offset + (cursorAtLineEnd ? 0 : rawAt.length));
|
|
361
|
+
return `${before}${CURSOR_MARKER}\x1B[7m${atCursor}\x1B[27m${after}`;
|
|
362
|
+
}
|
|
363
|
+
function renderInlineInputRow(opts) {
|
|
364
|
+
const { buffer, cursorOffset, rowPrefix, continuationPrefix, contentWidth, selectedText } = opts;
|
|
365
|
+
const raw = buildCursorRaw(buffer, resolveCursorOffset(buffer, cursorOffset));
|
|
366
|
+
return wrapTextWithAnsi(raw, contentWidth).map((segment, index) => {
|
|
367
|
+
const prefix = index === 0 ? rowPrefix : continuationPrefix;
|
|
368
|
+
return selectedText(`${prefix}${segment}`);
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// view/components/multi-select-view.ts
|
|
373
|
+
var ACTIVE_POINTER = "❯ ";
|
|
374
|
+
var INACTIVE_POINTER = " ";
|
|
375
|
+
var CHECKED = "[✔]";
|
|
376
|
+
var UNCHECKED = "[ ]";
|
|
377
|
+
var NUMBER_SEPARATOR = ". ";
|
|
378
|
+
var BOX_LABEL_GAP = " ";
|
|
379
|
+
var CONTINUATION_INDENT = " ";
|
|
380
|
+
var MULTI_SUBMIT_LABEL = "Submit";
|
|
381
|
+
|
|
382
|
+
class MultiSelectView {
|
|
383
|
+
theme;
|
|
384
|
+
question;
|
|
385
|
+
props;
|
|
386
|
+
cachedLayout;
|
|
387
|
+
constructor(theme, question) {
|
|
388
|
+
this.theme = theme;
|
|
389
|
+
this.question = question;
|
|
390
|
+
this.props = {
|
|
391
|
+
rows: [],
|
|
392
|
+
other: { active: false, inputMode: false, inputBuffer: "", inputCursorOffset: undefined },
|
|
393
|
+
nextActive: false,
|
|
394
|
+
nextLabel: displayLabel("next")
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
setProps(props) {
|
|
398
|
+
this.props = props;
|
|
399
|
+
this.cachedLayout = undefined;
|
|
400
|
+
}
|
|
401
|
+
handleInput(_data) {}
|
|
402
|
+
invalidate() {
|
|
403
|
+
this.cachedLayout = undefined;
|
|
404
|
+
}
|
|
405
|
+
render(width) {
|
|
406
|
+
return this.layout(width).lines;
|
|
407
|
+
}
|
|
408
|
+
focusedItemRowRange(width) {
|
|
409
|
+
return this.layout(width).focusedRange;
|
|
410
|
+
}
|
|
411
|
+
naturalHeight(width) {
|
|
412
|
+
return this.layout(width).lines.length;
|
|
413
|
+
}
|
|
414
|
+
layout(width) {
|
|
415
|
+
if (this.cachedLayout?.width === width)
|
|
416
|
+
return this.cachedLayout.value;
|
|
417
|
+
const build = { lines: [], focusedRange: [0, 0] };
|
|
418
|
+
const contentWidth = Math.max(1, width - this.prefixVisibleWidth());
|
|
419
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
420
|
+
this.appendOptionRows(build, width, contentWidth, numberWidth);
|
|
421
|
+
const otherStart = build.lines.length;
|
|
422
|
+
build.lines.push(...this.renderOtherRow(contentWidth, numberWidth));
|
|
423
|
+
if (this.props.other.active)
|
|
424
|
+
build.focusedRange = [otherStart, build.lines.length];
|
|
425
|
+
this.appendNextRow(build, width);
|
|
426
|
+
const value = { lines: build.lines, focusedRange: build.focusedRange };
|
|
427
|
+
this.cachedLayout = { width, value };
|
|
428
|
+
return value;
|
|
429
|
+
}
|
|
430
|
+
appendOptionRows(build, width, contentWidth, numberWidth) {
|
|
431
|
+
for (let i = 0;i < this.question.options.length; i++) {
|
|
432
|
+
const opt = this.question.options[i];
|
|
433
|
+
const row = this.props.rows[i];
|
|
434
|
+
if (!opt || !row)
|
|
435
|
+
continue;
|
|
436
|
+
const start = build.lines.length;
|
|
437
|
+
const pointer = row.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
438
|
+
const box = row.checked ? this.theme.fg("accent", CHECKED) : this.theme.fg("muted", UNCHECKED);
|
|
439
|
+
const label = truncateToWidth2(opt.label, contentWidth, "…");
|
|
440
|
+
const styledLabel = row.active ? this.theme.fg("accent", this.theme.bold(label)) : label;
|
|
441
|
+
const number = String(i + 1).padStart(numberWidth, " ");
|
|
442
|
+
build.lines.push(truncateToWidth2(`${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}${styledLabel}`, width, ""));
|
|
443
|
+
if (opt.description) {
|
|
444
|
+
for (const segment of wrapTextWithAnsi2(opt.description, contentWidth)) {
|
|
445
|
+
build.lines.push(CONTINUATION_INDENT + this.theme.fg("muted", segment));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (row.active)
|
|
449
|
+
build.focusedRange = [start, build.lines.length];
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
appendNextRow(build, width) {
|
|
453
|
+
const nextStart = build.lines.length;
|
|
454
|
+
const nextPointer = this.props.nextActive ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
455
|
+
const nextLabel = this.props.nextActive ? this.theme.fg("accent", this.theme.bold(this.props.nextLabel)) : this.props.nextLabel;
|
|
456
|
+
build.lines.push(truncateToWidth2(`${nextPointer}${nextLabel}`, width, ""));
|
|
457
|
+
if (this.props.nextActive)
|
|
458
|
+
build.focusedRange = [nextStart, build.lines.length];
|
|
459
|
+
}
|
|
460
|
+
renderOtherRow(contentWidth, numberWidth) {
|
|
461
|
+
const other = this.props.other;
|
|
462
|
+
const pointer = other.active ? this.theme.fg("accent", ACTIVE_POINTER) : INACTIVE_POINTER;
|
|
463
|
+
const box = other.checked ? this.theme.fg("accent", CHECKED) : this.theme.fg("muted", UNCHECKED);
|
|
464
|
+
const number = String(this.question.options.length + 1).padStart(numberWidth, " ");
|
|
465
|
+
const rowPrefix = `${pointer}${number}${NUMBER_SEPARATOR}${box}${BOX_LABEL_GAP}`;
|
|
466
|
+
const continuationPrefix = " ".repeat(visibleWidth(rowPrefix));
|
|
467
|
+
const selectedText = (text) => this.theme.fg("accent", this.theme.bold(text));
|
|
468
|
+
if (other.active && other.inputMode) {
|
|
469
|
+
return renderInlineInputRow({
|
|
470
|
+
buffer: other.inputBuffer,
|
|
471
|
+
cursorOffset: other.inputCursorOffset,
|
|
472
|
+
rowPrefix,
|
|
473
|
+
continuationPrefix,
|
|
474
|
+
contentWidth,
|
|
475
|
+
selectedText
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
return wrapTextWithAnsi2(other.inputBuffer || displayLabel("other"), contentWidth).map((segment, index) => {
|
|
479
|
+
const line = `${index === 0 ? rowPrefix : continuationPrefix}${segment}`;
|
|
480
|
+
return other.active ? selectedText(line) : line;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
prefixVisibleWidth() {
|
|
484
|
+
const numberWidth = String(Math.max(1, this.question.options.length + 1)).length;
|
|
485
|
+
return visibleWidth(INACTIVE_POINTER) + numberWidth + visibleWidth(`${NUMBER_SEPARATOR}${UNCHECKED}${BOX_LABEL_GAP}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// view/components/wrapping-select.ts
|
|
490
|
+
import { visibleWidth as visibleWidth2, wrapTextWithAnsi as wrapTextWithAnsi3 } from "@earendil-works/pi-tui";
|
|
491
|
+
class WrappingSelect {
|
|
492
|
+
static ACTIVE_POINTER = "❯ ";
|
|
493
|
+
static INACTIVE_POINTER = " ";
|
|
494
|
+
static NUMBER_SEPARATOR = ". ";
|
|
495
|
+
static CONFIRMED_MARK = " ✔";
|
|
496
|
+
static MIN_CONTENT_WIDTH = 1;
|
|
497
|
+
items;
|
|
498
|
+
maxVisible;
|
|
499
|
+
theme;
|
|
500
|
+
numberStartOffset;
|
|
501
|
+
totalItemsForNumbering;
|
|
502
|
+
selectedIndex = 0;
|
|
503
|
+
focused = true;
|
|
504
|
+
inputBuffer = "";
|
|
505
|
+
inputCursorOffset = undefined;
|
|
506
|
+
confirmedIndex = undefined;
|
|
507
|
+
confirmedLabelOverride = undefined;
|
|
508
|
+
constructor(items, maxVisible, theme, options = {}) {
|
|
509
|
+
this.items = items;
|
|
510
|
+
this.maxVisible = Math.max(1, maxVisible);
|
|
511
|
+
this.theme = theme;
|
|
512
|
+
this.numberStartOffset = options.numberStartOffset ?? 0;
|
|
513
|
+
this.totalItemsForNumbering = options.totalItemsForNumbering ?? items.length;
|
|
514
|
+
}
|
|
515
|
+
setNumbering(numberStartOffset, totalItemsForNumbering) {
|
|
516
|
+
this.numberStartOffset = numberStartOffset;
|
|
517
|
+
this.totalItemsForNumbering = Math.max(1, totalItemsForNumbering);
|
|
518
|
+
}
|
|
519
|
+
setSelectedIndex(index) {
|
|
520
|
+
this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
|
|
521
|
+
}
|
|
522
|
+
setFocused(focused) {
|
|
523
|
+
this.focused = focused;
|
|
524
|
+
}
|
|
525
|
+
setConfirmedIndex(index, labelOverride) {
|
|
526
|
+
if (index === undefined) {
|
|
527
|
+
this.confirmedIndex = undefined;
|
|
528
|
+
this.confirmedLabelOverride = undefined;
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
this.confirmedIndex = Math.max(0, Math.min(index, this.items.length - 1));
|
|
532
|
+
this.confirmedLabelOverride = labelOverride;
|
|
533
|
+
}
|
|
534
|
+
setInputBuffer(text) {
|
|
535
|
+
this.inputBuffer = text;
|
|
536
|
+
}
|
|
537
|
+
setInputCursorOffset(offset) {
|
|
538
|
+
this.inputCursorOffset = offset;
|
|
539
|
+
}
|
|
540
|
+
handleInput(_data) {}
|
|
541
|
+
invalidate() {}
|
|
542
|
+
render(width) {
|
|
543
|
+
if (this.items.length === 0)
|
|
544
|
+
return [];
|
|
545
|
+
const { startIndex, endIndex } = this.computeVisibleWindow();
|
|
546
|
+
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
547
|
+
const lines = [];
|
|
548
|
+
for (let i = startIndex;i < endIndex; i++) {
|
|
549
|
+
const item = this.items[i];
|
|
550
|
+
if (!item)
|
|
551
|
+
continue;
|
|
552
|
+
const isActive = i === this.selectedIndex && this.focused;
|
|
553
|
+
lines.push(...this.renderItem(item, i, isActive, width, numberWidth));
|
|
554
|
+
}
|
|
555
|
+
if (this.hasItemsOutsideWindow(startIndex, endIndex)) {
|
|
556
|
+
lines.push(this.theme.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
|
|
557
|
+
}
|
|
558
|
+
return lines;
|
|
559
|
+
}
|
|
560
|
+
focusedItemRowRange(width) {
|
|
561
|
+
if (this.items.length === 0)
|
|
562
|
+
return [0, 0];
|
|
563
|
+
const { startIndex, endIndex } = this.computeVisibleWindow();
|
|
564
|
+
const numberWidth = String(Math.max(1, this.totalItemsForNumbering)).length;
|
|
565
|
+
let row = 0;
|
|
566
|
+
for (let i = startIndex;i < endIndex; i++) {
|
|
567
|
+
const item = this.items[i];
|
|
568
|
+
if (!item)
|
|
569
|
+
continue;
|
|
570
|
+
const isActive = i === this.selectedIndex && this.focused;
|
|
571
|
+
const itemRowCount = this.computeItemRowCount(item, i, isActive, width, numberWidth);
|
|
572
|
+
if (i === this.selectedIndex) {
|
|
573
|
+
return [row, row + itemRowCount];
|
|
574
|
+
}
|
|
575
|
+
row += itemRowCount;
|
|
576
|
+
}
|
|
577
|
+
return [0, 1];
|
|
578
|
+
}
|
|
579
|
+
computeItemRowCount(item, index, isActive, width, numberWidth) {
|
|
580
|
+
return this.renderItem(item, index, isActive, width, numberWidth).length;
|
|
581
|
+
}
|
|
582
|
+
computeVisibleWindow() {
|
|
583
|
+
const half = Math.floor(this.maxVisible / 2);
|
|
584
|
+
const startIndex = Math.max(0, Math.min(this.selectedIndex - half, this.items.length - this.maxVisible));
|
|
585
|
+
const endIndex = Math.min(startIndex + this.maxVisible, this.items.length);
|
|
586
|
+
return { startIndex, endIndex };
|
|
587
|
+
}
|
|
588
|
+
hasItemsOutsideWindow(startIndex, endIndex) {
|
|
589
|
+
return startIndex > 0 || endIndex < this.items.length;
|
|
590
|
+
}
|
|
591
|
+
renderItem(item, index, isActive, width, numberWidth) {
|
|
592
|
+
const rowPrefix = this.buildRowPrefix(index, isActive, numberWidth);
|
|
593
|
+
const continuationPrefix = " ".repeat(visibleWidth2(rowPrefix));
|
|
594
|
+
const contentWidth = Math.max(WrappingSelect.MIN_CONTENT_WIDTH, width - visibleWidth2(rowPrefix));
|
|
595
|
+
if (this.shouldRenderAsInlineInput(item, isActive)) {
|
|
596
|
+
return this.renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth);
|
|
597
|
+
}
|
|
598
|
+
const { label, isConfirmed } = this.deriveConfirmedState(item, index);
|
|
599
|
+
const applySelectedStyle = isActive || isConfirmed;
|
|
600
|
+
return [
|
|
601
|
+
...this.renderLabelBlock(label, rowPrefix, continuationPrefix, contentWidth, applySelectedStyle),
|
|
602
|
+
...this.renderDescriptionBlock(item.description, continuationPrefix, contentWidth)
|
|
603
|
+
];
|
|
604
|
+
}
|
|
605
|
+
deriveConfirmedState(item, index) {
|
|
606
|
+
const customDraft = item.kind === "other" ? this.inputBuffer : undefined;
|
|
607
|
+
const customDraftDiffersFromConfirmed = item.kind === "other" && customDraft !== "" && index === this.confirmedIndex && customDraft !== (this.confirmedLabelOverride ?? "");
|
|
608
|
+
const isConfirmed = index === this.confirmedIndex && !customDraftDiffersFromConfirmed;
|
|
609
|
+
const baseLabel = customDraft ? customDraft : item.label;
|
|
610
|
+
const label = isConfirmed ? `${this.confirmedLabelOverride ?? baseLabel}${WrappingSelect.CONFIRMED_MARK}` : baseLabel;
|
|
611
|
+
return { label, isConfirmed };
|
|
612
|
+
}
|
|
613
|
+
buildRowPrefix(index, isActive, numberWidth) {
|
|
614
|
+
const pointer = isActive ? WrappingSelect.ACTIVE_POINTER : WrappingSelect.INACTIVE_POINTER;
|
|
615
|
+
const displayNumber = this.numberStartOffset + index + 1;
|
|
616
|
+
const paddedNumber = String(displayNumber).padStart(numberWidth, " ");
|
|
617
|
+
return `${pointer}${paddedNumber}${WrappingSelect.NUMBER_SEPARATOR}`;
|
|
618
|
+
}
|
|
619
|
+
shouldRenderAsInlineInput(item, isActive) {
|
|
620
|
+
return item.kind === "other" && isActive;
|
|
621
|
+
}
|
|
622
|
+
renderInlineInputRow(rowPrefix, continuationPrefix, contentWidth) {
|
|
623
|
+
return renderInlineInputRow({
|
|
624
|
+
buffer: this.inputBuffer,
|
|
625
|
+
cursorOffset: this.inputCursorOffset,
|
|
626
|
+
rowPrefix,
|
|
627
|
+
continuationPrefix,
|
|
628
|
+
contentWidth,
|
|
629
|
+
selectedText: this.theme.selectedText
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
renderLabelBlock(label, rowPrefix, continuationPrefix, contentWidth, applySelectedStyle) {
|
|
633
|
+
const wrapped = wrapTextWithAnsi3(label, contentWidth);
|
|
634
|
+
return wrapped.map((segment, index) => {
|
|
635
|
+
const prefix = index === 0 ? rowPrefix : continuationPrefix;
|
|
636
|
+
const line = `${prefix}${segment}`;
|
|
637
|
+
return applySelectedStyle ? this.theme.selectedText(line) : line;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
renderDescriptionBlock(description, continuationPrefix, contentWidth) {
|
|
641
|
+
if (!description)
|
|
642
|
+
return [];
|
|
643
|
+
const wrapped = wrapTextWithAnsi3(description, contentWidth);
|
|
644
|
+
return wrapped.map((segment) => `${continuationPrefix}${this.theme.description(segment)}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// view/components/option-list-view.ts
|
|
649
|
+
var MAX_VISIBLE_OPTIONS = 10;
|
|
650
|
+
|
|
651
|
+
class OptionListView {
|
|
652
|
+
select;
|
|
653
|
+
constructor(config) {
|
|
654
|
+
this.select = new WrappingSelect(config.items, Math.min(config.items.length, MAX_VISIBLE_OPTIONS), config.theme, {
|
|
655
|
+
numberStartOffset: 0,
|
|
656
|
+
totalItemsForNumbering: config.items.length
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
setProps(props) {
|
|
660
|
+
this.select.setSelectedIndex(props.selectedIndex);
|
|
661
|
+
this.select.setFocused(props.focused);
|
|
662
|
+
this.select.setConfirmedIndex(props.confirmed?.index, props.confirmed?.labelOverride);
|
|
663
|
+
this.select.setInputBuffer(props.inputBuffer);
|
|
664
|
+
this.select.setInputCursorOffset(props.inputCursorOffset);
|
|
665
|
+
}
|
|
666
|
+
handleInput(_data) {}
|
|
667
|
+
invalidate() {
|
|
668
|
+
this.select.invalidate();
|
|
669
|
+
}
|
|
670
|
+
render(width) {
|
|
671
|
+
return this.select.render(width);
|
|
672
|
+
}
|
|
673
|
+
focusedItemRowRange(width) {
|
|
674
|
+
return this.select.focusedItemRowRange(width);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// view/components/preview/markdown-content-cache.ts
|
|
679
|
+
import { Markdown, visibleWidth as visibleWidth4 } from "@earendil-works/pi-tui";
|
|
680
|
+
|
|
681
|
+
// view/components/preview/preview-box-renderer.ts
|
|
682
|
+
import { truncateToWidth as truncateToWidth3, visibleWidth as visibleWidth3 } from "@earendil-works/pi-tui";
|
|
683
|
+
var ANSI_SGR_RE = /\x1b\[[0-9;]*m/g;
|
|
684
|
+
var ANSI_OSC8_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
685
|
+
var FENCE_MARKER_RE = /^`{3}/;
|
|
686
|
+
var BORDER_VERTICAL_OVERHEAD = 2;
|
|
687
|
+
var BORDER_HORIZONTAL_OVERHEAD = 2;
|
|
688
|
+
var BORDER_INNER_PADDING_HORIZONTAL = 1;
|
|
689
|
+
var BOX_MIN_CONTENT_WIDTH = 40;
|
|
690
|
+
function stripFenceMarkers(lines) {
|
|
691
|
+
return lines.filter((line) => {
|
|
692
|
+
const clean = line.replace(ANSI_SGR_RE, "").replace(ANSI_OSC8_RE, "");
|
|
693
|
+
return !FENCE_MARKER_RE.test(clean);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
function renderBorderedBox(lines, width, colorFn, hidden = 0) {
|
|
697
|
+
const dashSpan = Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD);
|
|
698
|
+
const contentInner = Math.max(1, dashSpan - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
699
|
+
const pad = " ".repeat(BORDER_INNER_PADDING_HORIZONTAL);
|
|
700
|
+
const top = colorFn(`┌${"─".repeat(dashSpan)}┐`);
|
|
701
|
+
const out = [top];
|
|
702
|
+
for (const line of lines) {
|
|
703
|
+
const padded = truncateToWidth3(line, contentInner, "", true);
|
|
704
|
+
out.push(`${colorFn("│")}${pad}${padded}${pad}${colorFn("│")}`);
|
|
705
|
+
}
|
|
706
|
+
if (hidden > 0) {
|
|
707
|
+
const indicator = ` ✂ ── ${hidden} lines hidden ── `;
|
|
708
|
+
const space = dashSpan - indicator.length;
|
|
709
|
+
const leftFill = "─".repeat(Math.max(0, Math.floor(space / 2)));
|
|
710
|
+
const rightFill = "─".repeat(Math.max(0, dashSpan - leftFill.length - indicator.length));
|
|
711
|
+
out.push(colorFn(`└${leftFill}${indicator}${rightFill}┘`));
|
|
712
|
+
} else {
|
|
713
|
+
out.push(colorFn(`└${"─".repeat(dashSpan)}┘`));
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
function computeBoxDimensions(contentLines, maxInnerWidth) {
|
|
718
|
+
let widest = Math.min(BOX_MIN_CONTENT_WIDTH, maxInnerWidth);
|
|
719
|
+
for (const line of contentLines) {
|
|
720
|
+
const w = visibleWidth3(line.replace(/\s+$/, ""));
|
|
721
|
+
if (w > widest)
|
|
722
|
+
widest = w;
|
|
723
|
+
}
|
|
724
|
+
const innerWidth = Math.min(widest, maxInnerWidth);
|
|
725
|
+
const boxWidth = innerWidth + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL;
|
|
726
|
+
return { innerWidth, boxWidth };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// view/components/preview/markdown-content-cache.ts
|
|
730
|
+
var MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE = 20;
|
|
731
|
+
var MAX_PREVIEW_HEIGHT_STACKED = 15;
|
|
732
|
+
var NO_PREVIEW_TEXT = "No preview available";
|
|
733
|
+
var NOTES_AFFORDANCE_OVERHEAD = 2;
|
|
734
|
+
|
|
735
|
+
class MarkdownContentCache {
|
|
736
|
+
previewTexts;
|
|
737
|
+
markdownCache;
|
|
738
|
+
cachedWidth;
|
|
739
|
+
theme;
|
|
740
|
+
markdownTheme;
|
|
741
|
+
constructor(question, theme, markdownTheme) {
|
|
742
|
+
this.theme = theme;
|
|
743
|
+
this.markdownTheme = markdownTheme;
|
|
744
|
+
this.previewTexts = new Map;
|
|
745
|
+
for (let i = 0;i < question.options.length; i++) {
|
|
746
|
+
const raw = question.options[i]?.preview;
|
|
747
|
+
if (raw && raw.length > 0)
|
|
748
|
+
this.previewTexts.set(i, raw);
|
|
749
|
+
}
|
|
750
|
+
this.markdownCache = new Map;
|
|
751
|
+
}
|
|
752
|
+
hasAnyPreview() {
|
|
753
|
+
return this.previewTexts.size > 0;
|
|
754
|
+
}
|
|
755
|
+
has(optionIndex) {
|
|
756
|
+
return this.previewTexts.has(optionIndex);
|
|
757
|
+
}
|
|
758
|
+
bodyFor(optionIndex, innerWidth) {
|
|
759
|
+
if (this.cachedWidth !== innerWidth) {
|
|
760
|
+
for (const md of this.markdownCache.values())
|
|
761
|
+
md.invalidate();
|
|
762
|
+
this.cachedWidth = innerWidth;
|
|
763
|
+
}
|
|
764
|
+
const text = this.previewTexts.get(optionIndex);
|
|
765
|
+
if (!text) {
|
|
766
|
+
const placeholder = this.theme.fg("dim", t("preview.no_preview", NO_PREVIEW_TEXT));
|
|
767
|
+
const pad = Math.max(0, innerWidth - visibleWidth4(placeholder));
|
|
768
|
+
return [placeholder + " ".repeat(pad)];
|
|
769
|
+
}
|
|
770
|
+
let md = this.markdownCache.get(optionIndex);
|
|
771
|
+
if (!md) {
|
|
772
|
+
md = new Markdown(text, 0, 0, this.markdownTheme);
|
|
773
|
+
this.markdownCache.set(optionIndex, md);
|
|
774
|
+
}
|
|
775
|
+
return stripFenceMarkers(md.render(innerWidth));
|
|
776
|
+
}
|
|
777
|
+
invalidate() {
|
|
778
|
+
for (const md of this.markdownCache.values())
|
|
779
|
+
md.invalidate();
|
|
780
|
+
this.cachedWidth = undefined;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// view/components/preview/preview-block-renderer.ts
|
|
785
|
+
var NOTES_AFFORDANCE_TEXT = "Notes: press n to add notes";
|
|
786
|
+
function contentBudgetFor(mode) {
|
|
787
|
+
const cap = mode === "side-by-side" ? MAX_PREVIEW_HEIGHT_SIDE_BY_SIDE : MAX_PREVIEW_HEIGHT_STACKED;
|
|
788
|
+
return Math.max(1, cap - BORDER_VERTICAL_OVERHEAD - NOTES_AFFORDANCE_OVERHEAD);
|
|
789
|
+
}
|
|
790
|
+
function innerWidthFor(width) {
|
|
791
|
+
return Math.max(1, width - BORDER_HORIZONTAL_OVERHEAD - 2 * BORDER_INNER_PADDING_HORIZONTAL);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
class PreviewBlockRenderer {
|
|
795
|
+
theme;
|
|
796
|
+
cache;
|
|
797
|
+
constructor(config) {
|
|
798
|
+
this.theme = config.theme;
|
|
799
|
+
this.cache = new MarkdownContentCache(config.question, config.theme, config.markdownTheme);
|
|
800
|
+
}
|
|
801
|
+
hasAnyPreview() {
|
|
802
|
+
return this.cache.hasAnyPreview();
|
|
803
|
+
}
|
|
804
|
+
has(optionIndex) {
|
|
805
|
+
return this.cache.has(optionIndex);
|
|
806
|
+
}
|
|
807
|
+
invalidate() {
|
|
808
|
+
this.cache.invalidate();
|
|
809
|
+
}
|
|
810
|
+
blockHeight(width, optionIndex, mode) {
|
|
811
|
+
const contentBudget = contentBudgetFor(mode);
|
|
812
|
+
const innerWidth = innerWidthFor(width);
|
|
813
|
+
const rawRows = this.cache.bodyFor(optionIndex, innerWidth).length;
|
|
814
|
+
const contentRows = Math.min(rawRows, contentBudget);
|
|
815
|
+
return BORDER_VERTICAL_OVERHEAD + contentRows + NOTES_AFFORDANCE_OVERHEAD;
|
|
816
|
+
}
|
|
817
|
+
renderBlock(width, optionIndex, mode, focused, notesVisible) {
|
|
818
|
+
const contentBudget = contentBudgetFor(mode);
|
|
819
|
+
const maxInnerWidth = innerWidthFor(width);
|
|
820
|
+
const raw = this.cache.bodyFor(optionIndex, maxInnerWidth);
|
|
821
|
+
const truncated = raw.length > contentBudget;
|
|
822
|
+
const hidden = truncated ? raw.length - contentBudget : 0;
|
|
823
|
+
const contentLines = truncated ? raw.slice(0, contentBudget) : raw;
|
|
824
|
+
const { boxWidth } = computeBoxDimensions(contentLines, maxInnerWidth);
|
|
825
|
+
const colorFn = (s) => this.theme.fg("accent", s);
|
|
826
|
+
const boxedLines = renderBorderedBox(contentLines, boxWidth, colorFn, hidden);
|
|
827
|
+
const showAffordance = focused && !notesVisible && this.cache.has(optionIndex);
|
|
828
|
+
const affordance = showAffordance ? this.theme.fg("muted", t("preview.notes_affordance", NOTES_AFFORDANCE_TEXT)) : "";
|
|
829
|
+
return [...boxedLines, "", affordance];
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// view/components/preview/preview-layout-decider.ts
|
|
834
|
+
import { visibleWidth as visibleWidth5 } from "@earendil-works/pi-tui";
|
|
835
|
+
var PREVIEW_MIN_WIDTH = 100;
|
|
836
|
+
var PREVIEW_COLUMN_GAP = 2;
|
|
837
|
+
var PREVIEW_PADDING_LEFT = 1;
|
|
838
|
+
var STACKED_GAP_ROWS = 1;
|
|
839
|
+
var MIN_LEFT = 30;
|
|
840
|
+
var MAX_LEFT_RATIO = 0.5;
|
|
841
|
+
var MIN_PREVIEW_WIDTH = 45;
|
|
842
|
+
var CONFIRMED_OVERHEAD = 2;
|
|
843
|
+
function decideLayout(terminalWidth, paneWidth) {
|
|
844
|
+
return terminalWidth >= PREVIEW_MIN_WIDTH && paneWidth >= PREVIEW_MIN_WIDTH ? "side-by-side" : "stacked";
|
|
845
|
+
}
|
|
846
|
+
function adaptiveLeftWidth(items, totalForNumbering, paneWidth) {
|
|
847
|
+
const prefixW = String(Math.max(1, totalForNumbering)).length + 4;
|
|
848
|
+
const confirmedOverhead = CONFIRMED_OVERHEAD;
|
|
849
|
+
let maxLabel = 0;
|
|
850
|
+
for (const item of items) {
|
|
851
|
+
const w = visibleWidth5(item.label);
|
|
852
|
+
if (w > maxLabel)
|
|
853
|
+
maxLabel = w;
|
|
854
|
+
}
|
|
855
|
+
const desired = maxLabel + prefixW + confirmedOverhead;
|
|
856
|
+
const ratioCapped = Math.min(desired, Math.floor(paneWidth * MAX_LEFT_RATIO));
|
|
857
|
+
const available = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
|
|
858
|
+
return Math.max(MIN_LEFT, Math.min(ratioCapped, Math.max(1, available)));
|
|
859
|
+
}
|
|
860
|
+
function crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth) {
|
|
861
|
+
let max = MIN_LEFT;
|
|
862
|
+
for (let i = 0;i < tabs.length; i++) {
|
|
863
|
+
const items = itemsByTab[i] ?? [];
|
|
864
|
+
const totalForNumbering = items.length;
|
|
865
|
+
const tabWidth = adaptiveLeftWidth(items, totalForNumbering, paneWidth);
|
|
866
|
+
if (tabWidth > max)
|
|
867
|
+
max = tabWidth;
|
|
868
|
+
}
|
|
869
|
+
return max;
|
|
870
|
+
}
|
|
871
|
+
function previewSourceWidth(question) {
|
|
872
|
+
let max = 0;
|
|
873
|
+
for (const option of question.options) {
|
|
874
|
+
const text = option.preview;
|
|
875
|
+
if (!text)
|
|
876
|
+
continue;
|
|
877
|
+
for (const line of text.split(`
|
|
878
|
+
`)) {
|
|
879
|
+
const w = visibleWidth5(line);
|
|
880
|
+
if (w > max)
|
|
881
|
+
max = w;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return max;
|
|
885
|
+
}
|
|
886
|
+
function crossTabPreviewBudget(questions, paneWidth) {
|
|
887
|
+
let max = MIN_PREVIEW_WIDTH;
|
|
888
|
+
for (const question of questions) {
|
|
889
|
+
const rawWidth = previewSourceWidth(question);
|
|
890
|
+
const capped = Math.min(rawWidth, paneWidth - PREVIEW_COLUMN_GAP - MIN_LEFT);
|
|
891
|
+
const budget = capped + BORDER_HORIZONTAL_OVERHEAD + 2 * BORDER_INNER_PADDING_HORIZONTAL + PREVIEW_PADDING_LEFT;
|
|
892
|
+
if (budget > max)
|
|
893
|
+
max = budget;
|
|
894
|
+
}
|
|
895
|
+
return max;
|
|
896
|
+
}
|
|
897
|
+
function crossTabLeftWidthWithDonation(tabs, itemsByTab, questions, paneWidth) {
|
|
898
|
+
const labelDriven = crossTabMaxLeftWidth(tabs, itemsByTab, paneWidth);
|
|
899
|
+
const previewBudget = crossTabPreviewBudget(questions, paneWidth);
|
|
900
|
+
const slackDonation = paneWidth - PREVIEW_COLUMN_GAP - previewBudget;
|
|
901
|
+
const previewSafetyCeiling = paneWidth - PREVIEW_COLUMN_GAP - MIN_PREVIEW_WIDTH;
|
|
902
|
+
const ratioCeiling = Math.floor(paneWidth * MAX_LEFT_RATIO);
|
|
903
|
+
const ceiling = Math.min(previewSafetyCeiling, ratioCeiling);
|
|
904
|
+
return Math.min(Math.max(labelDriven, slackDonation), Math.max(1, ceiling));
|
|
905
|
+
}
|
|
906
|
+
function columnWidths(paneWidth, adaptiveLeft) {
|
|
907
|
+
const gap = PREVIEW_COLUMN_GAP;
|
|
908
|
+
const leftWidth = Math.min(adaptiveLeft, Math.max(1, paneWidth - gap - 1));
|
|
909
|
+
const rightWidth = Math.max(1, paneWidth - leftWidth - gap);
|
|
910
|
+
return { leftWidth, rightWidth, gap };
|
|
911
|
+
}
|
|
912
|
+
function bodyWidths(paneWidth, mode, adaptiveLeft) {
|
|
913
|
+
if (mode === "stacked")
|
|
914
|
+
return { optionsWidth: paneWidth, previewWidth: paneWidth };
|
|
915
|
+
const { leftWidth, rightWidth } = columnWidths(paneWidth, adaptiveLeft);
|
|
916
|
+
return { optionsWidth: leftWidth, previewWidth: Math.max(1, rightWidth - PREVIEW_PADDING_LEFT) };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// view/components/preview/preview-pane.ts
|
|
920
|
+
import { truncateToWidth as truncateToWidth4, visibleWidth as visibleWidth6 } from "@earendil-works/pi-tui";
|
|
921
|
+
class PreviewPane {
|
|
922
|
+
question;
|
|
923
|
+
getTerminalWidth;
|
|
924
|
+
optionListView;
|
|
925
|
+
previewBlock;
|
|
926
|
+
props;
|
|
927
|
+
globalLeftWidth = () => {
|
|
928
|
+
throw new Error("PreviewPane.setGlobalLeftWidth must be called before render()");
|
|
929
|
+
};
|
|
930
|
+
constructor(config) {
|
|
931
|
+
this.question = config.question;
|
|
932
|
+
this.getTerminalWidth = config.getTerminalWidth;
|
|
933
|
+
this.optionListView = config.optionListView;
|
|
934
|
+
this.previewBlock = config.previewBlock;
|
|
935
|
+
this.props = { notesVisible: false, selectedIndex: 0, focused: false, inputMode: false };
|
|
936
|
+
}
|
|
937
|
+
setGlobalLeftWidth(getter) {
|
|
938
|
+
this.globalLeftWidth = getter;
|
|
939
|
+
}
|
|
940
|
+
getAdaptiveLeft(paneWidth) {
|
|
941
|
+
return this.globalLeftWidth(paneWidth);
|
|
942
|
+
}
|
|
943
|
+
setProps(props) {
|
|
944
|
+
this.props = props;
|
|
945
|
+
}
|
|
946
|
+
handleInput(_data) {}
|
|
947
|
+
invalidate() {
|
|
948
|
+
this.previewBlock.invalidate();
|
|
949
|
+
this.optionListView.invalidate();
|
|
950
|
+
}
|
|
951
|
+
render(width) {
|
|
952
|
+
if (this.question.multiSelect === true)
|
|
953
|
+
return this.optionListView.render(width);
|
|
954
|
+
if (!this.previewBlock.hasAnyPreview())
|
|
955
|
+
return this.optionListView.render(width);
|
|
956
|
+
if (this.props.inputMode)
|
|
957
|
+
return this.optionListView.render(width);
|
|
958
|
+
const mode = decideLayout(this.getTerminalWidth(), width);
|
|
959
|
+
if (mode === "side-by-side")
|
|
960
|
+
return this.renderSideBySide(width, mode);
|
|
961
|
+
return [
|
|
962
|
+
...this.optionListView.render(width),
|
|
963
|
+
...Array(STACKED_GAP_ROWS).fill(""),
|
|
964
|
+
...this.previewBlock.renderBlock(width, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible)
|
|
965
|
+
];
|
|
966
|
+
}
|
|
967
|
+
focusedItemRowRange(width) {
|
|
968
|
+
if (this.question.multiSelect === true)
|
|
969
|
+
return this.optionListView.focusedItemRowRange(width);
|
|
970
|
+
if (!this.previewBlock.hasAnyPreview())
|
|
971
|
+
return this.optionListView.focusedItemRowRange(width);
|
|
972
|
+
if (this.props.inputMode)
|
|
973
|
+
return this.optionListView.focusedItemRowRange(width);
|
|
974
|
+
const mode = decideLayout(this.getTerminalWidth(), width);
|
|
975
|
+
if (mode === "stacked")
|
|
976
|
+
return this.optionListView.focusedItemRowRange(width);
|
|
977
|
+
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
978
|
+
const { leftWidth } = columnWidths(width, adaptiveLeft);
|
|
979
|
+
return this.optionListView.focusedItemRowRange(leftWidth);
|
|
980
|
+
}
|
|
981
|
+
naturalHeight(width) {
|
|
982
|
+
if (this.question.multiSelect === true)
|
|
983
|
+
return this.optionListView.render(width).length;
|
|
984
|
+
if (!this.previewBlock.hasAnyPreview())
|
|
985
|
+
return this.optionListView.render(width).length;
|
|
986
|
+
if (this.props.inputMode)
|
|
987
|
+
return this.optionListView.render(width).length;
|
|
988
|
+
const mode = decideLayout(this.getTerminalWidth(), width);
|
|
989
|
+
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
990
|
+
const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
|
|
991
|
+
const optionsHeight = this.optionListView.render(optionsWidth).length;
|
|
992
|
+
const previewBlockHeight = this.previewBlock.blockHeight(previewWidth, this.props.selectedIndex, mode);
|
|
993
|
+
if (mode === "side-by-side")
|
|
994
|
+
return Math.max(optionsHeight, previewBlockHeight);
|
|
995
|
+
return optionsHeight + STACKED_GAP_ROWS + previewBlockHeight;
|
|
996
|
+
}
|
|
997
|
+
maxNaturalHeight(width) {
|
|
998
|
+
if (this.question.multiSelect === true)
|
|
999
|
+
return this.optionListView.render(width).length;
|
|
1000
|
+
if (!this.previewBlock.hasAnyPreview())
|
|
1001
|
+
return this.optionListView.render(width).length;
|
|
1002
|
+
if (this.props.inputMode)
|
|
1003
|
+
return this.optionListView.render(width).length;
|
|
1004
|
+
const mode = decideLayout(this.getTerminalWidth(), width);
|
|
1005
|
+
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
1006
|
+
const { optionsWidth, previewWidth } = bodyWidths(width, mode, adaptiveLeft);
|
|
1007
|
+
const optionsHeight = this.optionListView.render(optionsWidth).length;
|
|
1008
|
+
let maxPreviewBlock = 0;
|
|
1009
|
+
for (let i = 0;i < this.question.options.length; i++) {
|
|
1010
|
+
const h = this.previewBlock.blockHeight(previewWidth, i, mode);
|
|
1011
|
+
if (h > maxPreviewBlock)
|
|
1012
|
+
maxPreviewBlock = h;
|
|
1013
|
+
}
|
|
1014
|
+
if (mode === "side-by-side")
|
|
1015
|
+
return Math.max(optionsHeight, maxPreviewBlock);
|
|
1016
|
+
return optionsHeight + STACKED_GAP_ROWS + maxPreviewBlock;
|
|
1017
|
+
}
|
|
1018
|
+
renderSideBySide(width, mode) {
|
|
1019
|
+
const adaptiveLeft = this.getAdaptiveLeft(width);
|
|
1020
|
+
const { leftWidth, rightWidth, gap } = columnWidths(width, adaptiveLeft);
|
|
1021
|
+
const leftLines = this.optionListView.render(leftWidth);
|
|
1022
|
+
const rightLines = this.renderPaddedPreviewLines(rightWidth, mode);
|
|
1023
|
+
const rows = Math.max(leftLines.length, rightLines.length);
|
|
1024
|
+
const gapStr = " ".repeat(gap);
|
|
1025
|
+
const out = [];
|
|
1026
|
+
for (let i = 0;i < rows; i++) {
|
|
1027
|
+
const leftRaw = leftLines[i] ?? "";
|
|
1028
|
+
const rightRaw = rightLines[i] ?? "";
|
|
1029
|
+
const leftClamped = truncateToWidth4(leftRaw, leftWidth, "");
|
|
1030
|
+
const leftPad = " ".repeat(Math.max(0, leftWidth - visibleWidth6(leftClamped)));
|
|
1031
|
+
const joined = `${leftClamped}${leftPad}${gapStr}${rightRaw}`;
|
|
1032
|
+
out.push(truncateToWidth4(joined, width, ""));
|
|
1033
|
+
}
|
|
1034
|
+
return out;
|
|
1035
|
+
}
|
|
1036
|
+
renderPaddedPreviewLines(colWidth, mode) {
|
|
1037
|
+
const inner = Math.max(1, colWidth - PREVIEW_PADDING_LEFT);
|
|
1038
|
+
const contentLines = this.previewBlock.renderBlock(inner, this.props.selectedIndex, mode, this.props.focused, this.props.notesVisible);
|
|
1039
|
+
const boxWidth = Math.max(1, visibleWidth6(contentLines[0] ?? ""));
|
|
1040
|
+
const boxAlignedPad = Math.max(PREVIEW_PADDING_LEFT, colWidth - boxWidth);
|
|
1041
|
+
return contentLines.map((line) => {
|
|
1042
|
+
if (line === "")
|
|
1043
|
+
return "";
|
|
1044
|
+
const pad = Math.max(PREVIEW_PADDING_LEFT, Math.min(boxAlignedPad, colWidth - visibleWidth6(line)));
|
|
1045
|
+
return `${" ".repeat(pad)}${truncateToWidth4(line, colWidth - pad, "")}`;
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// view/components/submit-picker.ts
|
|
1051
|
+
import { truncateToWidth as truncateToWidth5 } from "@earendil-works/pi-tui";
|
|
1052
|
+
var ACTIVE_POINTER2 = "❯ ";
|
|
1053
|
+
var INACTIVE_POINTER2 = " ";
|
|
1054
|
+
var NUMBER_SEPARATOR2 = ". ";
|
|
1055
|
+
var SUBMIT_LABEL = "Submit answers";
|
|
1056
|
+
var CANCEL_LABEL = "Cancel";
|
|
1057
|
+
|
|
1058
|
+
class SubmitPicker {
|
|
1059
|
+
theme;
|
|
1060
|
+
props;
|
|
1061
|
+
constructor(theme) {
|
|
1062
|
+
this.theme = theme;
|
|
1063
|
+
this.props = { rows: [{ active: false }, { active: false }] };
|
|
1064
|
+
}
|
|
1065
|
+
setProps(props) {
|
|
1066
|
+
this.props = props;
|
|
1067
|
+
}
|
|
1068
|
+
handleInput(_data) {}
|
|
1069
|
+
invalidate() {}
|
|
1070
|
+
naturalHeight(_width) {
|
|
1071
|
+
return 2;
|
|
1072
|
+
}
|
|
1073
|
+
render(width) {
|
|
1074
|
+
const lines = [];
|
|
1075
|
+
for (let i = 0;i < 2; i++) {
|
|
1076
|
+
const text = i === 0 ? t("submit.label", SUBMIT_LABEL) : t("submit.cancel", CANCEL_LABEL);
|
|
1077
|
+
const active = this.props.rows[i]?.active ?? false;
|
|
1078
|
+
const pointer = active ? ACTIVE_POINTER2 : INACTIVE_POINTER2;
|
|
1079
|
+
const number = `${i + 1}${NUMBER_SEPARATOR2}`;
|
|
1080
|
+
const label = active ? this.theme.fg("accent", this.theme.bold(text)) : this.theme.fg("text", text);
|
|
1081
|
+
lines.push(truncateToWidth5(`${pointer}${number}${label}`, width, ""));
|
|
1082
|
+
}
|
|
1083
|
+
return lines;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// view/components/tab-bar.ts
|
|
1088
|
+
import { truncateToWidth as truncateToWidth6 } from "@earendil-works/pi-tui";
|
|
1089
|
+
|
|
1090
|
+
class TabBar {
|
|
1091
|
+
theme;
|
|
1092
|
+
props;
|
|
1093
|
+
constructor(theme) {
|
|
1094
|
+
this.theme = theme;
|
|
1095
|
+
this.props = { tabs: [], submit: { active: false, allAnswered: false } };
|
|
1096
|
+
}
|
|
1097
|
+
setProps(props) {
|
|
1098
|
+
this.props = props;
|
|
1099
|
+
}
|
|
1100
|
+
handleInput(_data) {}
|
|
1101
|
+
invalidate() {}
|
|
1102
|
+
render(width) {
|
|
1103
|
+
const pieces = [" ← "];
|
|
1104
|
+
for (const tab of this.props.tabs) {
|
|
1105
|
+
const box = tab.answered ? "■" : "□";
|
|
1106
|
+
const rawSeg = ` ${box} ${tab.label} `;
|
|
1107
|
+
const styled = tab.active ? this.theme.bg("selectedBg", this.theme.fg("text", rawSeg)) : this.theme.fg(tab.answered ? "success" : "muted", rawSeg);
|
|
1108
|
+
pieces.push(styled);
|
|
1109
|
+
pieces.push(" ");
|
|
1110
|
+
}
|
|
1111
|
+
const submitText = " ✓ Submit ";
|
|
1112
|
+
const submitStyled = this.props.submit.active ? this.theme.bg("selectedBg", this.theme.fg("text", submitText)) : this.theme.fg(this.props.submit.allAnswered ? "success" : "dim", submitText);
|
|
1113
|
+
pieces.push(submitStyled);
|
|
1114
|
+
pieces.push(" →");
|
|
1115
|
+
const tabLine = truncateToWidth6(pieces.join(""), width, "");
|
|
1116
|
+
return [tabLine, ""];
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// state/selectors/derivations.ts
|
|
1121
|
+
function selectConfirmedIndicator(questions, currentTab, answers, items) {
|
|
1122
|
+
const q = questions[currentTab];
|
|
1123
|
+
if (!q || q.multiSelect === true)
|
|
1124
|
+
return;
|
|
1125
|
+
const prior = answers.get(currentTab);
|
|
1126
|
+
if (!prior)
|
|
1127
|
+
return;
|
|
1128
|
+
if (prior.kind === "custom") {
|
|
1129
|
+
const otherIndex = items.findIndex((it) => it.kind === "other");
|
|
1130
|
+
if (otherIndex < 0)
|
|
1131
|
+
return;
|
|
1132
|
+
return { index: otherIndex, labelOverride: prior.answer ?? "" };
|
|
1133
|
+
}
|
|
1134
|
+
if (prior.kind !== "option" || typeof prior.answer !== "string")
|
|
1135
|
+
return;
|
|
1136
|
+
const index = items.findIndex((it) => it.kind === "option" && it.label === prior.answer);
|
|
1137
|
+
if (index < 0)
|
|
1138
|
+
return;
|
|
1139
|
+
return { index };
|
|
1140
|
+
}
|
|
1141
|
+
function selectActivePreviewPaneIndex(currentTab, totalQuestions) {
|
|
1142
|
+
if (totalQuestions <= 0)
|
|
1143
|
+
return 0;
|
|
1144
|
+
return Math.min(currentTab, totalQuestions - 1);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// state/selectors/focus.ts
|
|
1148
|
+
function selectActiveView(state, totalQuestions) {
|
|
1149
|
+
if (state.notesVisible)
|
|
1150
|
+
return "notes";
|
|
1151
|
+
if (state.currentTab === totalQuestions)
|
|
1152
|
+
return "submit";
|
|
1153
|
+
return "options";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// view/props-adapter.ts
|
|
1157
|
+
function getInputCursorOffset(input) {
|
|
1158
|
+
const lines = input.getLines();
|
|
1159
|
+
const cursor = input.getCursor();
|
|
1160
|
+
let offset = cursor.col;
|
|
1161
|
+
for (let i = 0;i < cursor.line; i++)
|
|
1162
|
+
offset += (lines[i]?.length ?? 0) + 1;
|
|
1163
|
+
return offset;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
class QuestionnairePropsAdapter {
|
|
1167
|
+
tui;
|
|
1168
|
+
questions;
|
|
1169
|
+
itemsByTab;
|
|
1170
|
+
tabsByIndex;
|
|
1171
|
+
inlineInput;
|
|
1172
|
+
globalBindings;
|
|
1173
|
+
perTabBindings;
|
|
1174
|
+
extraInvalidatables;
|
|
1175
|
+
constructor(config) {
|
|
1176
|
+
this.tui = config.tui;
|
|
1177
|
+
this.questions = config.questions;
|
|
1178
|
+
this.itemsByTab = config.itemsByTab;
|
|
1179
|
+
this.tabsByIndex = config.tabsByIndex;
|
|
1180
|
+
this.inlineInput = config.inlineInput;
|
|
1181
|
+
this.globalBindings = config.globalBindings;
|
|
1182
|
+
this.perTabBindings = config.perTabBindings;
|
|
1183
|
+
this.extraInvalidatables = config.extraInvalidatables ?? [];
|
|
1184
|
+
}
|
|
1185
|
+
apply(state) {
|
|
1186
|
+
const totalQuestions = this.questions.length;
|
|
1187
|
+
const activeView = selectActiveView(state, totalQuestions);
|
|
1188
|
+
const paneIndex = selectActivePreviewPaneIndex(state.currentTab, totalQuestions);
|
|
1189
|
+
const activePreviewPane = this.tabsByIndex[paneIndex]?.preview ?? this.tabsByIndex[0].preview;
|
|
1190
|
+
const ctx = {
|
|
1191
|
+
questions: this.questions,
|
|
1192
|
+
itemsByTab: this.itemsByTab,
|
|
1193
|
+
totalQuestions,
|
|
1194
|
+
activeView,
|
|
1195
|
+
inputBuffer: this.inlineInput.getText(),
|
|
1196
|
+
inputCursorOffset: getInputCursorOffset(this.inlineInput),
|
|
1197
|
+
activePreviewPane
|
|
1198
|
+
};
|
|
1199
|
+
for (const binding of this.globalBindings) {
|
|
1200
|
+
binding.apply(state, ctx);
|
|
1201
|
+
}
|
|
1202
|
+
for (let i = 0;i < this.tabsByIndex.length; i++) {
|
|
1203
|
+
const tab = this.tabsByIndex[i];
|
|
1204
|
+
const tabCtx = { ...ctx, tab, i };
|
|
1205
|
+
for (const binding of this.perTabBindings) {
|
|
1206
|
+
binding.apply(state, tabCtx);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
this.tui.requestRender();
|
|
1210
|
+
}
|
|
1211
|
+
invalidate() {
|
|
1212
|
+
for (const b of this.globalBindings)
|
|
1213
|
+
b.invalidate();
|
|
1214
|
+
for (const tab of this.tabsByIndex) {
|
|
1215
|
+
tab.optionList.invalidate();
|
|
1216
|
+
tab.preview.invalidate();
|
|
1217
|
+
tab.multiSelect?.invalidate();
|
|
1218
|
+
}
|
|
1219
|
+
for (const x of this.extraInvalidatables)
|
|
1220
|
+
x.invalidate();
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// state/selectors/projections.ts
|
|
1225
|
+
function emptyMultiSelectProps(ctx) {
|
|
1226
|
+
return {
|
|
1227
|
+
rows: [],
|
|
1228
|
+
other: {
|
|
1229
|
+
active: false,
|
|
1230
|
+
inputMode: false,
|
|
1231
|
+
inputBuffer: ctx.inputBuffer,
|
|
1232
|
+
inputCursorOffset: ctx.inputCursorOffset
|
|
1233
|
+
},
|
|
1234
|
+
nextActive: false,
|
|
1235
|
+
nextLabel: displayLabel("next")
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
function nextLabelFor(ctx) {
|
|
1239
|
+
const isLastQuestion = ctx.i === ctx.questions.length - 1;
|
|
1240
|
+
return isLastQuestion ? MULTI_SUBMIT_LABEL : displayLabel("next");
|
|
1241
|
+
}
|
|
1242
|
+
var selectMultiSelectProps = (state, ctx) => {
|
|
1243
|
+
const question = ctx.questions[ctx.i];
|
|
1244
|
+
if (!question) {
|
|
1245
|
+
return emptyMultiSelectProps(ctx);
|
|
1246
|
+
}
|
|
1247
|
+
const focused = ctx.activeView === "options";
|
|
1248
|
+
const rows = [];
|
|
1249
|
+
for (let i = 0;i < question.options.length; i++) {
|
|
1250
|
+
rows.push({
|
|
1251
|
+
checked: state.multiSelectChecked.has(i),
|
|
1252
|
+
active: focused && i === state.optionIndex
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
const otherActive = focused && state.optionIndex === question.options.length;
|
|
1256
|
+
const nextActive = focused && state.optionIndex === question.options.length + 1;
|
|
1257
|
+
return {
|
|
1258
|
+
rows,
|
|
1259
|
+
other: {
|
|
1260
|
+
active: otherActive,
|
|
1261
|
+
checked: state.multiSelectChecked.has(question.options.length),
|
|
1262
|
+
inputMode: state.inputMode,
|
|
1263
|
+
inputBuffer: ctx.inputBuffer,
|
|
1264
|
+
inputCursorOffset: ctx.inputCursorOffset
|
|
1265
|
+
},
|
|
1266
|
+
nextActive,
|
|
1267
|
+
nextLabel: nextLabelFor(ctx)
|
|
1268
|
+
};
|
|
1269
|
+
};
|
|
1270
|
+
var selectOptionListProps = (state, ctx) => {
|
|
1271
|
+
const items = ctx.itemsByTab[ctx.i] ?? [];
|
|
1272
|
+
const focused = ctx.activeView === "options";
|
|
1273
|
+
const confirmed = selectConfirmedIndicator(ctx.questions, state.currentTab, state.answers, items);
|
|
1274
|
+
return {
|
|
1275
|
+
selectedIndex: state.optionIndex,
|
|
1276
|
+
focused,
|
|
1277
|
+
inputBuffer: ctx.inputBuffer,
|
|
1278
|
+
inputCursorOffset: ctx.inputCursorOffset,
|
|
1279
|
+
...confirmed ? { confirmed } : {}
|
|
1280
|
+
};
|
|
1281
|
+
};
|
|
1282
|
+
var selectSubmitPickerProps = (state, ctx) => {
|
|
1283
|
+
const focused = ctx.activeView === "submit";
|
|
1284
|
+
return {
|
|
1285
|
+
rows: [
|
|
1286
|
+
{ active: focused && state.submitChoiceIndex === 0 },
|
|
1287
|
+
{ active: focused && state.submitChoiceIndex === 1 }
|
|
1288
|
+
]
|
|
1289
|
+
};
|
|
1290
|
+
};
|
|
1291
|
+
var selectPreviewPaneProps = (state, ctx) => ({
|
|
1292
|
+
notesVisible: state.notesVisible,
|
|
1293
|
+
selectedIndex: state.optionIndex,
|
|
1294
|
+
focused: ctx.activeView === "options",
|
|
1295
|
+
inputMode: state.inputMode
|
|
1296
|
+
});
|
|
1297
|
+
var selectTabBarProps = (state, ctx) => {
|
|
1298
|
+
const tabs = ctx.questions.map((q, i) => ({
|
|
1299
|
+
label: q.header && q.header.length > 0 ? q.header : `Q${i + 1}`,
|
|
1300
|
+
answered: state.answers.has(i),
|
|
1301
|
+
active: i === state.currentTab
|
|
1302
|
+
}));
|
|
1303
|
+
return {
|
|
1304
|
+
tabs,
|
|
1305
|
+
submit: {
|
|
1306
|
+
active: state.currentTab === ctx.questions.length,
|
|
1307
|
+
allAnswered: state.answers.size === ctx.questions.length && ctx.questions.length > 0
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
};
|
|
1311
|
+
var selectDialogProps = (state, ctx) => ({
|
|
1312
|
+
state,
|
|
1313
|
+
activePreviewPane: ctx.activePreviewPane
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
// state/build-questionnaire.ts
|
|
1317
|
+
function previewBodyHeights(pane) {
|
|
1318
|
+
return (width) => {
|
|
1319
|
+
const current = pane.naturalHeight(width);
|
|
1320
|
+
return { current, max: Math.max(current, pane.maxNaturalHeight(width)) };
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
function editorTheme(theme) {
|
|
1324
|
+
return {
|
|
1325
|
+
borderColor: (text) => theme.fg("borderMuted", text),
|
|
1326
|
+
selectList: {
|
|
1327
|
+
selectedPrefix: (text) => theme.bg("selectedBg", theme.fg("accent", text)),
|
|
1328
|
+
selectedText: (text) => theme.bg("selectedBg", theme.bold(text)),
|
|
1329
|
+
description: (text) => theme.fg("muted", text),
|
|
1330
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
1331
|
+
noMatch: (text) => theme.fg("warning", text)
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function multiSelectBodyHeights(view) {
|
|
1336
|
+
return (width) => {
|
|
1337
|
+
const h = view.naturalHeight(width);
|
|
1338
|
+
return { current: h, max: h };
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
var isActiveTab = (s, ctx) => ctx.i === selectActivePreviewPaneIndex(s.currentTab, ctx.totalQuestions);
|
|
1342
|
+
function buildQuestionnaire(config) {
|
|
1343
|
+
return new QuestionnaireBuilder(config).build();
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
class QuestionnaireBuilder {
|
|
1347
|
+
tui;
|
|
1348
|
+
theme;
|
|
1349
|
+
questions;
|
|
1350
|
+
itemsByTab;
|
|
1351
|
+
isMulti;
|
|
1352
|
+
initialState;
|
|
1353
|
+
getCurrentTab;
|
|
1354
|
+
collapseKey;
|
|
1355
|
+
selectTheme;
|
|
1356
|
+
markdownTheme = getMarkdownTheme();
|
|
1357
|
+
notesInput;
|
|
1358
|
+
inlineInput;
|
|
1359
|
+
getTerminalWidth = () => this.tui.terminal.columns;
|
|
1360
|
+
getTerminalRows = () => this.tui.terminal.rows;
|
|
1361
|
+
constructor(config) {
|
|
1362
|
+
this.tui = config.tui;
|
|
1363
|
+
this.theme = config.theme;
|
|
1364
|
+
this.questions = config.questions;
|
|
1365
|
+
this.itemsByTab = config.itemsByTab;
|
|
1366
|
+
this.isMulti = config.isMulti;
|
|
1367
|
+
this.initialState = config.initialState;
|
|
1368
|
+
this.getCurrentTab = config.getCurrentTab;
|
|
1369
|
+
this.collapseKey = config.collapseKey;
|
|
1370
|
+
this.selectTheme = this.makeSelectTheme();
|
|
1371
|
+
const textEditorTheme = editorTheme(this.theme);
|
|
1372
|
+
this.notesInput = new Editor(this.tui, textEditorTheme);
|
|
1373
|
+
this.inlineInput = new Editor(this.tui, textEditorTheme);
|
|
1374
|
+
this.notesInput.disableSubmit = true;
|
|
1375
|
+
this.inlineInput.disableSubmit = true;
|
|
1376
|
+
}
|
|
1377
|
+
build() {
|
|
1378
|
+
const tabs = this.buildTabComponents();
|
|
1379
|
+
this.injectGlobalLeftWidth(tabs);
|
|
1380
|
+
const submitPicker = this.buildSubmitPicker();
|
|
1381
|
+
const tabBar = this.buildTabBar();
|
|
1382
|
+
const heights = this.buildHeightComputers(tabs);
|
|
1383
|
+
const dialog = this.buildDialog(tabs, submitPicker, tabBar, heights);
|
|
1384
|
+
const globalBindings = this.buildGlobalBindings(dialog, submitPicker, tabBar);
|
|
1385
|
+
const perTabBindings = this.buildPerTabBindings();
|
|
1386
|
+
const adapter = this.buildAdapter(tabs, globalBindings, perTabBindings);
|
|
1387
|
+
return this.handle(adapter, dialog);
|
|
1388
|
+
}
|
|
1389
|
+
makeSelectTheme() {
|
|
1390
|
+
const t = this.theme;
|
|
1391
|
+
return {
|
|
1392
|
+
selectedText: (s) => t.fg("accent", t.bold(s)),
|
|
1393
|
+
description: (s) => t.fg("muted", s),
|
|
1394
|
+
scrollInfo: (s) => t.fg("dim", s)
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
buildTabComponents() {
|
|
1398
|
+
return this.questions.map((q, i) => this.buildTabFor(q, i));
|
|
1399
|
+
}
|
|
1400
|
+
buildTabFor(question, index) {
|
|
1401
|
+
const optionList = new OptionListView({
|
|
1402
|
+
items: this.itemsByTab[index] ?? [],
|
|
1403
|
+
theme: this.selectTheme
|
|
1404
|
+
});
|
|
1405
|
+
const previewBlock = new PreviewBlockRenderer({
|
|
1406
|
+
question,
|
|
1407
|
+
theme: this.theme,
|
|
1408
|
+
markdownTheme: this.markdownTheme
|
|
1409
|
+
});
|
|
1410
|
+
const preview = new PreviewPane({
|
|
1411
|
+
question,
|
|
1412
|
+
getTerminalWidth: this.getTerminalWidth,
|
|
1413
|
+
optionListView: optionList,
|
|
1414
|
+
previewBlock
|
|
1415
|
+
});
|
|
1416
|
+
const multiSelect = question.multiSelect ? new MultiSelectView(this.theme, question) : undefined;
|
|
1417
|
+
const bodyHeights = this.buildBodyHeights(question, preview, multiSelect);
|
|
1418
|
+
return { optionList, preview, multiSelect, bodyHeights };
|
|
1419
|
+
}
|
|
1420
|
+
buildBodyHeights(question, preview, multiSelect) {
|
|
1421
|
+
return question.multiSelect ? multiSelectBodyHeights(multiSelect) : previewBodyHeights(preview);
|
|
1422
|
+
}
|
|
1423
|
+
injectGlobalLeftWidth(tabs) {
|
|
1424
|
+
const questions = this.questions;
|
|
1425
|
+
const itemsByTab = this.itemsByTab;
|
|
1426
|
+
const tabsDescriptor = questions.map((q) => ({ multiSelect: q.multiSelect }));
|
|
1427
|
+
const globalLeftWidth = (paneWidth) => crossTabLeftWidthWithDonation(tabsDescriptor, itemsByTab, questions, paneWidth);
|
|
1428
|
+
for (const tab of tabs) {
|
|
1429
|
+
tab.preview.setGlobalLeftWidth(globalLeftWidth);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
buildSubmitPicker() {
|
|
1433
|
+
return this.isMulti ? new SubmitPicker(this.theme) : undefined;
|
|
1434
|
+
}
|
|
1435
|
+
buildTabBar() {
|
|
1436
|
+
return this.isMulti ? new TabBar(this.theme) : undefined;
|
|
1437
|
+
}
|
|
1438
|
+
buildHeightComputers(tabs) {
|
|
1439
|
+
const global = (width) => {
|
|
1440
|
+
let max = 0;
|
|
1441
|
+
for (const tab of tabs) {
|
|
1442
|
+
const h = tab.bodyHeights(width).max;
|
|
1443
|
+
if (h > max)
|
|
1444
|
+
max = h;
|
|
1445
|
+
}
|
|
1446
|
+
return Math.max(1, max);
|
|
1447
|
+
};
|
|
1448
|
+
const current = (width) => {
|
|
1449
|
+
const idx = Math.min(this.getCurrentTab(), tabs.length - 1);
|
|
1450
|
+
return Math.max(0, tabs[idx]?.bodyHeights(width).current ?? 0);
|
|
1451
|
+
};
|
|
1452
|
+
return { global, current };
|
|
1453
|
+
}
|
|
1454
|
+
pickInitialActivePreview(tabs) {
|
|
1455
|
+
const idx = selectActivePreviewPaneIndex(this.initialState.currentTab, this.questions.length);
|
|
1456
|
+
return tabs[idx]?.preview ?? tabs[0].preview;
|
|
1457
|
+
}
|
|
1458
|
+
buildDialog(tabs, submitPicker, tabBar, heights) {
|
|
1459
|
+
return new DialogView({
|
|
1460
|
+
theme: this.theme,
|
|
1461
|
+
questions: this.questions,
|
|
1462
|
+
tabBar,
|
|
1463
|
+
notesInput: this.notesInput,
|
|
1464
|
+
isMulti: this.isMulti,
|
|
1465
|
+
tabsByIndex: tabs,
|
|
1466
|
+
submitPicker,
|
|
1467
|
+
getBodyHeight: heights.global,
|
|
1468
|
+
getCurrentBodyHeight: heights.current,
|
|
1469
|
+
getTerminalRows: this.getTerminalRows,
|
|
1470
|
+
collapseKey: this.collapseKey
|
|
1471
|
+
}, { state: this.initialState, activePreviewPane: this.pickInitialActivePreview(tabs) });
|
|
1472
|
+
}
|
|
1473
|
+
buildGlobalBindings(dialog, submitPicker, tabBar) {
|
|
1474
|
+
return [
|
|
1475
|
+
globalBinding({ component: dialog, select: selectDialogProps }),
|
|
1476
|
+
...submitPicker ? [globalBinding({ component: submitPicker, select: selectSubmitPickerProps })] : [],
|
|
1477
|
+
...tabBar ? [globalBinding({ component: tabBar, select: selectTabBarProps })] : []
|
|
1478
|
+
];
|
|
1479
|
+
}
|
|
1480
|
+
buildPerTabBindings() {
|
|
1481
|
+
return [
|
|
1482
|
+
perTabBinding({
|
|
1483
|
+
resolve: (tab) => tab.optionList,
|
|
1484
|
+
predicate: isActiveTab,
|
|
1485
|
+
select: selectOptionListProps
|
|
1486
|
+
}),
|
|
1487
|
+
perTabBinding({
|
|
1488
|
+
resolve: (tab) => tab.preview,
|
|
1489
|
+
predicate: isActiveTab,
|
|
1490
|
+
select: selectPreviewPaneProps
|
|
1491
|
+
}),
|
|
1492
|
+
perTabBinding({
|
|
1493
|
+
resolve: (tab) => tab.multiSelect,
|
|
1494
|
+
select: selectMultiSelectProps
|
|
1495
|
+
})
|
|
1496
|
+
];
|
|
1497
|
+
}
|
|
1498
|
+
buildAdapter(tabs, globalBindings, perTabBindings) {
|
|
1499
|
+
return new QuestionnairePropsAdapter({
|
|
1500
|
+
tui: this.tui,
|
|
1501
|
+
questions: this.questions,
|
|
1502
|
+
itemsByTab: this.itemsByTab,
|
|
1503
|
+
tabsByIndex: tabs,
|
|
1504
|
+
inlineInput: this.inlineInput,
|
|
1505
|
+
globalBindings,
|
|
1506
|
+
perTabBindings,
|
|
1507
|
+
extraInvalidatables: [this.notesInput]
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
handle(adapter, dialog) {
|
|
1511
|
+
return {
|
|
1512
|
+
adapter,
|
|
1513
|
+
notesInput: this.notesInput,
|
|
1514
|
+
inlineInput: this.inlineInput,
|
|
1515
|
+
render: (w) => dialog.render(w),
|
|
1516
|
+
invalidate: () => adapter.invalidate()
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// state/key-router.ts
|
|
1522
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
1523
|
+
var KEYBIND_UP = "tui.select.up";
|
|
1524
|
+
var KEYBIND_DOWN = "tui.select.down";
|
|
1525
|
+
var KEYBIND_CONFIRM = "tui.select.confirm";
|
|
1526
|
+
var KEYBIND_SUBMIT = "tui.input.submit";
|
|
1527
|
+
var KEYBIND_CANCEL = "tui.select.cancel";
|
|
1528
|
+
var KEYBIND_NEW_LINE = "tui.input.newLine";
|
|
1529
|
+
var KEYBIND_EDITOR_UP = "tui.editor.cursorUp";
|
|
1530
|
+
var KEYBIND_EDITOR_DOWN = "tui.editor.cursorDown";
|
|
1531
|
+
var KEYBIND_CLEAR = "tui.editor.deleteToLineStart";
|
|
1532
|
+
var KEYBIND_EXTERNAL_EDITOR = "app.editor.external";
|
|
1533
|
+
var NOTES_ACTIVATE_KEY = "n";
|
|
1534
|
+
function isConfirm(kb, data) {
|
|
1535
|
+
return kb.matches(data, KEYBIND_CONFIRM) || kb.matches(data, KEYBIND_SUBMIT);
|
|
1536
|
+
}
|
|
1537
|
+
function wrapTab(index, total) {
|
|
1538
|
+
if (total <= 0)
|
|
1539
|
+
return 0;
|
|
1540
|
+
return (index % total + total) % total;
|
|
1541
|
+
}
|
|
1542
|
+
function totalTabs(runtime) {
|
|
1543
|
+
return runtime.isMulti ? runtime.questions.length + 1 : 1;
|
|
1544
|
+
}
|
|
1545
|
+
function computeAutoAdvanceTab(state, runtime) {
|
|
1546
|
+
if (!runtime.isMulti)
|
|
1547
|
+
return;
|
|
1548
|
+
if (state.currentTab < runtime.questions.length - 1)
|
|
1549
|
+
return state.currentTab + 1;
|
|
1550
|
+
return runtime.questions.length;
|
|
1551
|
+
}
|
|
1552
|
+
function buildSingleSelectAnswer(state, runtime) {
|
|
1553
|
+
const q = runtime.questions[state.currentTab];
|
|
1554
|
+
if (!q)
|
|
1555
|
+
return null;
|
|
1556
|
+
const item = runtime.currentItem;
|
|
1557
|
+
if (state.inputMode) {
|
|
1558
|
+
const label = runtime.inputBuffer;
|
|
1559
|
+
return {
|
|
1560
|
+
questionIndex: state.currentTab,
|
|
1561
|
+
question: q.question,
|
|
1562
|
+
kind: "custom",
|
|
1563
|
+
answer: label.length > 0 ? label : null
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
if (!item)
|
|
1567
|
+
return null;
|
|
1568
|
+
if (item.kind === "other") {
|
|
1569
|
+
return null;
|
|
1570
|
+
}
|
|
1571
|
+
if (item.kind === "next") {
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
return {
|
|
1575
|
+
questionIndex: state.currentTab,
|
|
1576
|
+
question: q.question,
|
|
1577
|
+
kind: "option",
|
|
1578
|
+
answer: item.label
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
function buildMultiSelected(state, runtime) {
|
|
1582
|
+
const q = runtime.questions[state.currentTab];
|
|
1583
|
+
if (!q)
|
|
1584
|
+
return [];
|
|
1585
|
+
const out = [];
|
|
1586
|
+
for (let i = 0;i < q.options.length; i++) {
|
|
1587
|
+
if (state.multiSelectChecked.has(i)) {
|
|
1588
|
+
const label = q.options[i]?.label;
|
|
1589
|
+
if (typeof label === "string")
|
|
1590
|
+
out.push(label);
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return out;
|
|
1594
|
+
}
|
|
1595
|
+
function tabSwitchAction(data, state, runtime) {
|
|
1596
|
+
if (!runtime.isMulti)
|
|
1597
|
+
return null;
|
|
1598
|
+
const total = totalTabs(runtime);
|
|
1599
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
1600
|
+
return { kind: "tab_switch", nextTab: wrapTab(state.currentTab + 1, total) };
|
|
1601
|
+
}
|
|
1602
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
1603
|
+
return { kind: "tab_switch", nextTab: wrapTab(state.currentTab - 1, total) };
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
function nextNavOnDown(state, runtime) {
|
|
1608
|
+
return {
|
|
1609
|
+
kind: "nav",
|
|
1610
|
+
nextIndex: wrapTab(state.optionIndex + 1, Math.max(1, runtime.items.length)),
|
|
1611
|
+
inputValue: runtime.inputBuffer
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function prevNavOnUp(state, runtime) {
|
|
1615
|
+
return {
|
|
1616
|
+
kind: "nav",
|
|
1617
|
+
nextIndex: wrapTab(state.optionIndex - 1, Math.max(1, runtime.items.length)),
|
|
1618
|
+
inputValue: runtime.inputBuffer
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
function routeCollapsed(kb, data) {
|
|
1622
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1623
|
+
return { kind: "cancel" };
|
|
1624
|
+
return { kind: "ignore" };
|
|
1625
|
+
}
|
|
1626
|
+
function routeNotesMode(kb, data) {
|
|
1627
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1628
|
+
return { kind: "notes_exit" };
|
|
1629
|
+
if (kb.matches(data, KEYBIND_NEW_LINE))
|
|
1630
|
+
return { kind: "notes_forward", data };
|
|
1631
|
+
if (isConfirm(kb, data))
|
|
1632
|
+
return { kind: "notes_exit" };
|
|
1633
|
+
return { kind: "notes_forward", data };
|
|
1634
|
+
}
|
|
1635
|
+
function routeInputMode(kb, data, state, runtime) {
|
|
1636
|
+
if (kb.matches(data, KEYBIND_NEW_LINE))
|
|
1637
|
+
return { kind: "ignore" };
|
|
1638
|
+
if (isConfirm(kb, data)) {
|
|
1639
|
+
if (runtime.questions[state.currentTab]?.multiSelect) {
|
|
1640
|
+
return { kind: "toggle", index: state.optionIndex, inputValue: runtime.inputBuffer };
|
|
1641
|
+
}
|
|
1642
|
+
const answer = buildSingleSelectAnswer(state, runtime);
|
|
1643
|
+
if (!answer)
|
|
1644
|
+
return { kind: "ignore" };
|
|
1645
|
+
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
1646
|
+
}
|
|
1647
|
+
if (kb.matches(data, KEYBIND_CLEAR))
|
|
1648
|
+
return { kind: "input_clear" };
|
|
1649
|
+
if (kb.matches(data, KEYBIND_EXTERNAL_EDITOR))
|
|
1650
|
+
return { kind: "input_edit", value: runtime.inputBuffer };
|
|
1651
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1652
|
+
return { kind: "cancel" };
|
|
1653
|
+
if (kb.matches(data, KEYBIND_EDITOR_UP) && runtime.canMoveInputUp)
|
|
1654
|
+
return { kind: "ignore" };
|
|
1655
|
+
if (kb.matches(data, KEYBIND_EDITOR_DOWN) && runtime.canMoveInputDown)
|
|
1656
|
+
return { kind: "ignore" };
|
|
1657
|
+
if (kb.matches(data, KEYBIND_UP))
|
|
1658
|
+
return prevNavOnUp(state, runtime);
|
|
1659
|
+
if (kb.matches(data, KEYBIND_DOWN))
|
|
1660
|
+
return nextNavOnDown(state, runtime);
|
|
1661
|
+
return { kind: "ignore" };
|
|
1662
|
+
}
|
|
1663
|
+
function routeSubmitTab(kb, data, state, runtime) {
|
|
1664
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1665
|
+
return { kind: "cancel" };
|
|
1666
|
+
const tab = tabSwitchAction(data, state, runtime);
|
|
1667
|
+
if (tab)
|
|
1668
|
+
return tab;
|
|
1669
|
+
if (kb.matches(data, KEYBIND_UP) || kb.matches(data, KEYBIND_DOWN)) {
|
|
1670
|
+
const delta = kb.matches(data, KEYBIND_DOWN) ? 1 : -1;
|
|
1671
|
+
const next = wrapTab(state.submitChoiceIndex + delta, 2);
|
|
1672
|
+
return { kind: "submit_nav", nextIndex: next === 1 ? 1 : 0 };
|
|
1673
|
+
}
|
|
1674
|
+
if (isConfirm(kb, data)) {
|
|
1675
|
+
return state.submitChoiceIndex === 1 ? { kind: "cancel" } : { kind: "submit" };
|
|
1676
|
+
}
|
|
1677
|
+
if (data === NOTES_ACTIVATE_KEY) {
|
|
1678
|
+
return { kind: "notes_enter" };
|
|
1679
|
+
}
|
|
1680
|
+
return { kind: "ignore" };
|
|
1681
|
+
}
|
|
1682
|
+
function routeMultiSelectTab(kb, data, state, runtime) {
|
|
1683
|
+
const focusedKind = runtime.currentItem?.kind;
|
|
1684
|
+
const focusedMeta = focusedKind ? ROW_INTENT_META[focusedKind] : undefined;
|
|
1685
|
+
if (isConfirm(kb, data)) {
|
|
1686
|
+
if (focusedKind === "other") {
|
|
1687
|
+
return { kind: "toggle", index: state.optionIndex, inputValue: runtime.inputBuffer };
|
|
1688
|
+
}
|
|
1689
|
+
if (!focusedMeta?.autoSubmitsInMulti)
|
|
1690
|
+
return { kind: "toggle", index: state.optionIndex };
|
|
1691
|
+
return {
|
|
1692
|
+
kind: "multi_confirm",
|
|
1693
|
+
selected: buildMultiSelected(state, runtime),
|
|
1694
|
+
autoAdvanceTab: computeAutoAdvanceTab(state, runtime)
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1698
|
+
return { kind: "cancel" };
|
|
1699
|
+
return { kind: "ignore" };
|
|
1700
|
+
}
|
|
1701
|
+
function routeSingleSelectTab(kb, data, state, runtime) {
|
|
1702
|
+
if (isConfirm(kb, data)) {
|
|
1703
|
+
const answer = buildSingleSelectAnswer(state, runtime);
|
|
1704
|
+
if (!answer)
|
|
1705
|
+
return { kind: "ignore" };
|
|
1706
|
+
return { kind: "confirm", answer, autoAdvanceTab: computeAutoAdvanceTab(state, runtime) };
|
|
1707
|
+
}
|
|
1708
|
+
if (kb.matches(data, KEYBIND_CANCEL))
|
|
1709
|
+
return { kind: "cancel" };
|
|
1710
|
+
return { kind: "ignore" };
|
|
1711
|
+
}
|
|
1712
|
+
function routeKey(data, state, runtime) {
|
|
1713
|
+
const kb = runtime.keybindings;
|
|
1714
|
+
if (typeof runtime.collapseKey === "string" && runtime.collapseKey !== "off" && matchesKey(data, runtime.collapseKey)) {
|
|
1715
|
+
return { kind: "toggle_collapsed" };
|
|
1716
|
+
}
|
|
1717
|
+
if (state.collapsed)
|
|
1718
|
+
return routeCollapsed(kb, data);
|
|
1719
|
+
if (state.notesVisible)
|
|
1720
|
+
return routeNotesMode(kb, data);
|
|
1721
|
+
if (state.inputMode)
|
|
1722
|
+
return routeInputMode(kb, data, state, runtime);
|
|
1723
|
+
if (runtime.isMulti && state.currentTab === runtime.questions.length) {
|
|
1724
|
+
return routeSubmitTab(kb, data, state, runtime);
|
|
1725
|
+
}
|
|
1726
|
+
const tab = tabSwitchAction(data, state, runtime);
|
|
1727
|
+
if (tab)
|
|
1728
|
+
return tab;
|
|
1729
|
+
const q = runtime.questions[state.currentTab];
|
|
1730
|
+
if (!q)
|
|
1731
|
+
return { kind: "ignore" };
|
|
1732
|
+
if (data === NOTES_ACTIVATE_KEY) {
|
|
1733
|
+
return { kind: "notes_enter" };
|
|
1734
|
+
}
|
|
1735
|
+
if (kb.matches(data, KEYBIND_UP)) {
|
|
1736
|
+
return prevNavOnUp(state, runtime);
|
|
1737
|
+
}
|
|
1738
|
+
if (kb.matches(data, KEYBIND_DOWN)) {
|
|
1739
|
+
return nextNavOnDown(state, runtime);
|
|
1740
|
+
}
|
|
1741
|
+
if (q.multiSelect)
|
|
1742
|
+
return routeMultiSelectTab(kb, data, state, runtime);
|
|
1743
|
+
return routeSingleSelectTab(kb, data, state, runtime);
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// state/state-reducer.ts
|
|
1747
|
+
function orderedAnswers(state, questions) {
|
|
1748
|
+
const out = [];
|
|
1749
|
+
for (let i = 0;i < questions.length; i++) {
|
|
1750
|
+
const a = state.answers.get(i);
|
|
1751
|
+
if (a)
|
|
1752
|
+
out.push(a);
|
|
1753
|
+
}
|
|
1754
|
+
return out;
|
|
1755
|
+
}
|
|
1756
|
+
function syncMultiSelectFromAnswers(answers, questions, tab) {
|
|
1757
|
+
const q = questions[tab];
|
|
1758
|
+
if (!q?.multiSelect)
|
|
1759
|
+
return new Set;
|
|
1760
|
+
const saved = answers.get(tab);
|
|
1761
|
+
const labels = saved?.selected ?? [];
|
|
1762
|
+
const indices = new Set;
|
|
1763
|
+
for (let i = 0;i < q.options.length; i++) {
|
|
1764
|
+
if (labels.includes(q.options[i].label))
|
|
1765
|
+
indices.add(i);
|
|
1766
|
+
}
|
|
1767
|
+
if (saved?.answer)
|
|
1768
|
+
indices.add(q.options.length);
|
|
1769
|
+
return indices;
|
|
1770
|
+
}
|
|
1771
|
+
function persistMultiSelectAnswer(state, ctx) {
|
|
1772
|
+
const q = ctx.questions[state.currentTab];
|
|
1773
|
+
if (!q?.multiSelect)
|
|
1774
|
+
return state.answers;
|
|
1775
|
+
const selected = [];
|
|
1776
|
+
for (let i = 0;i < q.options.length; i++) {
|
|
1777
|
+
if (state.multiSelectChecked.has(i))
|
|
1778
|
+
selected.push(q.options[i].label);
|
|
1779
|
+
}
|
|
1780
|
+
const out = new Map(state.answers);
|
|
1781
|
+
const customAnswer = state.multiSelectChecked.has(q.options.length) ? customDraftValueFor(state, state.currentTab) : "";
|
|
1782
|
+
if (selected.length === 0 && customAnswer.length === 0) {
|
|
1783
|
+
out.delete(state.currentTab);
|
|
1784
|
+
return out;
|
|
1785
|
+
}
|
|
1786
|
+
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
1787
|
+
out.set(state.currentTab, {
|
|
1788
|
+
questionIndex: state.currentTab,
|
|
1789
|
+
question: q.question,
|
|
1790
|
+
kind: "multi",
|
|
1791
|
+
answer: customAnswer || null,
|
|
1792
|
+
selected,
|
|
1793
|
+
...pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}
|
|
1794
|
+
});
|
|
1795
|
+
return out;
|
|
1796
|
+
}
|
|
1797
|
+
function notesValueFor(state, tab) {
|
|
1798
|
+
return state.notesByTab.get(tab) ?? state.answers.get(tab)?.notes ?? "";
|
|
1799
|
+
}
|
|
1800
|
+
function customDraftValueFor(state, tab) {
|
|
1801
|
+
const draft = state.customDraftsByTab.get(tab);
|
|
1802
|
+
if (draft !== undefined)
|
|
1803
|
+
return draft;
|
|
1804
|
+
const answer = state.answers.get(tab);
|
|
1805
|
+
return (answer?.kind === "custom" || answer?.kind === "multi") && typeof answer.answer === "string" ? answer.answer : "";
|
|
1806
|
+
}
|
|
1807
|
+
function setCustomDraft(state, tab, value) {
|
|
1808
|
+
const drafts = new Map(state.customDraftsByTab);
|
|
1809
|
+
drafts.set(tab, value);
|
|
1810
|
+
return drafts;
|
|
1811
|
+
}
|
|
1812
|
+
function withoutCustomDraft(state, tab) {
|
|
1813
|
+
if (!state.customDraftsByTab.has(tab))
|
|
1814
|
+
return state.customDraftsByTab;
|
|
1815
|
+
const drafts = new Map(state.customDraftsByTab);
|
|
1816
|
+
drafts.delete(tab);
|
|
1817
|
+
return drafts;
|
|
1818
|
+
}
|
|
1819
|
+
function switchTabResult(state, nextTab, ctx) {
|
|
1820
|
+
const notesValue = notesValueFor(state, nextTab);
|
|
1821
|
+
const transitioned = {
|
|
1822
|
+
...state,
|
|
1823
|
+
currentTab: nextTab,
|
|
1824
|
+
optionIndex: 0,
|
|
1825
|
+
inputMode: false,
|
|
1826
|
+
notesVisible: false,
|
|
1827
|
+
submitChoiceIndex: 0,
|
|
1828
|
+
multiSelectChecked: syncMultiSelectFromAnswers(state.answers, ctx.questions, nextTab),
|
|
1829
|
+
notesDraft: notesValue
|
|
1830
|
+
};
|
|
1831
|
+
return {
|
|
1832
|
+
state: transitioned,
|
|
1833
|
+
effects: [
|
|
1834
|
+
{ kind: "set_notes_focused", focused: false },
|
|
1835
|
+
{ kind: "set_notes_value", value: notesValue },
|
|
1836
|
+
{ kind: "set_input_buffer", value: customDraftValueFor(state, nextTab) }
|
|
1837
|
+
]
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
function doneFor(state, ctx, cancelled) {
|
|
1841
|
+
const globalNote = state.notesByTab.get(ctx.questions.length);
|
|
1842
|
+
const result = {
|
|
1843
|
+
answers: orderedAnswers(state, ctx.questions),
|
|
1844
|
+
cancelled,
|
|
1845
|
+
...globalNote && globalNote.length > 0 ? { globalNote } : {}
|
|
1846
|
+
};
|
|
1847
|
+
return { state, effects: [{ kind: "done", result }] };
|
|
1848
|
+
}
|
|
1849
|
+
var navHandler = (state, action, ctx) => {
|
|
1850
|
+
const items = ctx.itemsByTab[state.currentTab] ?? [];
|
|
1851
|
+
const item = items[action.nextIndex];
|
|
1852
|
+
const isMulti = ctx.questions[state.currentTab]?.multiSelect === true;
|
|
1853
|
+
const inputMode = !!item && ROW_INTENT_META[item.kind].activatesInputMode;
|
|
1854
|
+
const customDraftsByTab = state.inputMode ? setCustomDraft(state, state.currentTab, action.inputValue) : state.customDraftsByTab;
|
|
1855
|
+
const intermediate = {
|
|
1856
|
+
...state,
|
|
1857
|
+
optionIndex: action.nextIndex,
|
|
1858
|
+
inputMode,
|
|
1859
|
+
customDraftsByTab
|
|
1860
|
+
};
|
|
1861
|
+
const next = state.inputMode && isMulti ? { ...intermediate, answers: persistMultiSelectAnswer(intermediate, ctx) } : intermediate;
|
|
1862
|
+
if (!inputMode && item?.kind !== "other")
|
|
1863
|
+
return { state: next, effects: [] };
|
|
1864
|
+
return {
|
|
1865
|
+
state: next,
|
|
1866
|
+
effects: [{ kind: "set_input_buffer", value: customDraftValueFor(next, state.currentTab) }]
|
|
1867
|
+
};
|
|
1868
|
+
};
|
|
1869
|
+
var inputClearHandler = (state, _action, _ctx) => ({
|
|
1870
|
+
state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, "") },
|
|
1871
|
+
effects: [{ kind: "clear_input_buffer" }]
|
|
1872
|
+
});
|
|
1873
|
+
var inputEditHandler = (state, action, _ctx) => ({
|
|
1874
|
+
state,
|
|
1875
|
+
effects: [{ kind: "open_input_editor", value: action.value }]
|
|
1876
|
+
});
|
|
1877
|
+
var inputReplaceHandler = (state, action, _ctx) => ({
|
|
1878
|
+
state: { ...state, customDraftsByTab: setCustomDraft(state, state.currentTab, action.value) },
|
|
1879
|
+
effects: [{ kind: "set_input_buffer", value: action.value }]
|
|
1880
|
+
});
|
|
1881
|
+
var tabSwitchHandler = (state, action, ctx) => switchTabResult(state, action.nextTab, ctx);
|
|
1882
|
+
var confirmHandler = (state, action, ctx) => {
|
|
1883
|
+
let answer = action.answer;
|
|
1884
|
+
if (answer.kind === "option" && answer.answer) {
|
|
1885
|
+
const q = ctx.questions[answer.questionIndex];
|
|
1886
|
+
const matched = q?.options.find((o) => o.label === answer.answer);
|
|
1887
|
+
if (matched?.preview && matched.preview.length > 0) {
|
|
1888
|
+
answer = { ...answer, preview: matched.preview };
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
const pendingNotes = state.notesByTab.get(answer.questionIndex);
|
|
1892
|
+
if (pendingNotes && pendingNotes.length > 0) {
|
|
1893
|
+
answer = { ...answer, notes: pendingNotes };
|
|
1894
|
+
}
|
|
1895
|
+
const answers = new Map(state.answers);
|
|
1896
|
+
answers.set(answer.questionIndex, answer);
|
|
1897
|
+
const customDraftsByTab = answer.kind === "custom" ? withoutCustomDraft(state, answer.questionIndex) : state.customDraftsByTab;
|
|
1898
|
+
const next = {
|
|
1899
|
+
...state,
|
|
1900
|
+
answers,
|
|
1901
|
+
customDraftsByTab
|
|
1902
|
+
};
|
|
1903
|
+
if (action.autoAdvanceTab !== undefined)
|
|
1904
|
+
return switchTabResult(next, action.autoAdvanceTab, ctx);
|
|
1905
|
+
return doneFor(next, ctx, false);
|
|
1906
|
+
};
|
|
1907
|
+
var toggleHandler = (state, action, ctx) => {
|
|
1908
|
+
const checked = new Set(state.multiSelectChecked);
|
|
1909
|
+
if (checked.has(action.index))
|
|
1910
|
+
checked.delete(action.index);
|
|
1911
|
+
else
|
|
1912
|
+
checked.add(action.index);
|
|
1913
|
+
const customIndex = ctx.questions[state.currentTab]?.options.length;
|
|
1914
|
+
const customDraftsByTab = action.index === customIndex && action.inputValue !== undefined ? setCustomDraft(state, state.currentTab, action.inputValue) : state.customDraftsByTab;
|
|
1915
|
+
const intermediate = { ...state, multiSelectChecked: checked, customDraftsByTab };
|
|
1916
|
+
const answers = persistMultiSelectAnswer(intermediate, ctx);
|
|
1917
|
+
return { state: { ...intermediate, answers }, effects: [] };
|
|
1918
|
+
};
|
|
1919
|
+
var multiConfirmHandler = (state, action, ctx) => {
|
|
1920
|
+
const q = ctx.questions[state.currentTab];
|
|
1921
|
+
if (!q)
|
|
1922
|
+
return { state, effects: [] };
|
|
1923
|
+
const pendingNotes = state.notesByTab.get(state.currentTab);
|
|
1924
|
+
const answers = new Map(state.answers);
|
|
1925
|
+
answers.set(state.currentTab, {
|
|
1926
|
+
questionIndex: state.currentTab,
|
|
1927
|
+
question: q.question,
|
|
1928
|
+
kind: "multi",
|
|
1929
|
+
answer: state.multiSelectChecked.has(q.options.length) ? customDraftValueFor(state, state.currentTab) || null : null,
|
|
1930
|
+
selected: action.selected,
|
|
1931
|
+
...pendingNotes && pendingNotes.length > 0 ? { notes: pendingNotes } : {}
|
|
1932
|
+
});
|
|
1933
|
+
const synced = {
|
|
1934
|
+
...state,
|
|
1935
|
+
answers,
|
|
1936
|
+
multiSelectChecked: syncMultiSelectFromAnswers(answers, ctx.questions, state.currentTab)
|
|
1937
|
+
};
|
|
1938
|
+
if (action.autoAdvanceTab !== undefined)
|
|
1939
|
+
return switchTabResult(synced, action.autoAdvanceTab, ctx);
|
|
1940
|
+
return doneFor(synced, ctx, false);
|
|
1941
|
+
};
|
|
1942
|
+
var notesEnterHandler = (state, _action, _ctx) => {
|
|
1943
|
+
const value = notesValueFor(state, state.currentTab);
|
|
1944
|
+
return {
|
|
1945
|
+
state: { ...state, notesVisible: true, notesDraft: value },
|
|
1946
|
+
effects: [
|
|
1947
|
+
{ kind: "set_notes_value", value },
|
|
1948
|
+
{ kind: "set_notes_focused", focused: true }
|
|
1949
|
+
]
|
|
1950
|
+
};
|
|
1951
|
+
};
|
|
1952
|
+
var notesExitHandler = (state, _action, _ctx) => {
|
|
1953
|
+
const trimmed = state.notesDraft.trim();
|
|
1954
|
+
const notes = new Map(state.notesByTab);
|
|
1955
|
+
const answers = new Map(state.answers);
|
|
1956
|
+
if (trimmed.length === 0) {
|
|
1957
|
+
notes.delete(state.currentTab);
|
|
1958
|
+
const prev = answers.get(state.currentTab);
|
|
1959
|
+
if (prev?.notes) {
|
|
1960
|
+
const stripped = { ...prev };
|
|
1961
|
+
delete stripped.notes;
|
|
1962
|
+
answers.set(state.currentTab, stripped);
|
|
1963
|
+
}
|
|
1964
|
+
} else {
|
|
1965
|
+
notes.set(state.currentTab, trimmed);
|
|
1966
|
+
const prev = answers.get(state.currentTab);
|
|
1967
|
+
if (prev)
|
|
1968
|
+
answers.set(state.currentTab, { ...prev, notes: trimmed });
|
|
1969
|
+
}
|
|
1970
|
+
return {
|
|
1971
|
+
state: { ...state, notesByTab: notes, answers, notesVisible: false },
|
|
1972
|
+
effects: [{ kind: "set_notes_focused", focused: false }]
|
|
1973
|
+
};
|
|
1974
|
+
};
|
|
1975
|
+
var cancelHandler = (s, _a, c) => doneFor(s, c, true);
|
|
1976
|
+
var submitHandler = (s, _a, c) => doneFor(s, c, false);
|
|
1977
|
+
var submitNavHandler = (s, a, _c) => ({
|
|
1978
|
+
state: { ...s, submitChoiceIndex: a.nextIndex },
|
|
1979
|
+
effects: []
|
|
1980
|
+
});
|
|
1981
|
+
var notesForwardHandler = (s, a, _c) => ({
|
|
1982
|
+
state: s,
|
|
1983
|
+
effects: [{ kind: "forward_notes_keystroke", data: a.data }]
|
|
1984
|
+
});
|
|
1985
|
+
var toggleCollapsedHandler = (s, _a, _c) => ({
|
|
1986
|
+
state: { ...s, collapsed: !s.collapsed },
|
|
1987
|
+
effects: [{ kind: "set_overlay_hidden", hidden: !s.collapsed }]
|
|
1988
|
+
});
|
|
1989
|
+
var ignoreHandler = (s, _a, _c) => ({ state: s, effects: [] });
|
|
1990
|
+
var HANDLERS = {
|
|
1991
|
+
nav: navHandler,
|
|
1992
|
+
input_clear: inputClearHandler,
|
|
1993
|
+
input_edit: inputEditHandler,
|
|
1994
|
+
input_replace: inputReplaceHandler,
|
|
1995
|
+
tab_switch: tabSwitchHandler,
|
|
1996
|
+
confirm: confirmHandler,
|
|
1997
|
+
toggle: toggleHandler,
|
|
1998
|
+
multi_confirm: multiConfirmHandler,
|
|
1999
|
+
cancel: cancelHandler,
|
|
2000
|
+
notes_enter: notesEnterHandler,
|
|
2001
|
+
notes_exit: notesExitHandler,
|
|
2002
|
+
notes_forward: notesForwardHandler,
|
|
2003
|
+
submit: submitHandler,
|
|
2004
|
+
submit_nav: submitNavHandler,
|
|
2005
|
+
toggle_collapsed: toggleCollapsedHandler,
|
|
2006
|
+
ignore: ignoreHandler
|
|
2007
|
+
};
|
|
2008
|
+
function reduce(state, action, ctx) {
|
|
2009
|
+
const handler = HANDLERS[action.kind];
|
|
2010
|
+
return handler(state, action, ctx);
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// state/questionnaire-session.ts
|
|
2014
|
+
function initialState() {
|
|
2015
|
+
return {
|
|
2016
|
+
currentTab: 0,
|
|
2017
|
+
optionIndex: 0,
|
|
2018
|
+
inputMode: false,
|
|
2019
|
+
notesVisible: false,
|
|
2020
|
+
answers: new Map,
|
|
2021
|
+
multiSelectChecked: new Set,
|
|
2022
|
+
customDraftsByTab: new Map,
|
|
2023
|
+
notesByTab: new Map,
|
|
2024
|
+
submitChoiceIndex: 0,
|
|
2025
|
+
notesDraft: "",
|
|
2026
|
+
collapsed: false
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
class QuestionnaireSession {
|
|
2031
|
+
state = initialState();
|
|
2032
|
+
questions;
|
|
2033
|
+
isMulti;
|
|
2034
|
+
itemsByTab;
|
|
2035
|
+
notesInput;
|
|
2036
|
+
inlineInput;
|
|
2037
|
+
viewAdapter;
|
|
2038
|
+
keybindings;
|
|
2039
|
+
editInput;
|
|
2040
|
+
collapseKey;
|
|
2041
|
+
canReopenWhileHidden;
|
|
2042
|
+
inputEditorOpen = false;
|
|
2043
|
+
overlayHandle;
|
|
2044
|
+
tui;
|
|
2045
|
+
done;
|
|
2046
|
+
component;
|
|
2047
|
+
constructor(config) {
|
|
2048
|
+
this.tui = config.tui;
|
|
2049
|
+
this.done = config.done;
|
|
2050
|
+
this.questions = config.params.questions;
|
|
2051
|
+
this.isMulti = this.questions.length > 1;
|
|
2052
|
+
this.itemsByTab = config.itemsByTab;
|
|
2053
|
+
this.keybindings = config.keybindings;
|
|
2054
|
+
this.editInput = config.editInput;
|
|
2055
|
+
this.collapseKey = config.collapseKey;
|
|
2056
|
+
this.canReopenWhileHidden = config.canReopenWhileHidden;
|
|
2057
|
+
const built = buildQuestionnaire({
|
|
2058
|
+
tui: this.tui,
|
|
2059
|
+
theme: config.theme,
|
|
2060
|
+
questions: this.questions,
|
|
2061
|
+
itemsByTab: this.itemsByTab,
|
|
2062
|
+
isMulti: this.isMulti,
|
|
2063
|
+
initialState: this.state,
|
|
2064
|
+
getCurrentTab: () => this.state.currentTab,
|
|
2065
|
+
collapseKey: this.collapseKey
|
|
2066
|
+
});
|
|
2067
|
+
this.notesInput = built.notesInput;
|
|
2068
|
+
this.inlineInput = built.inlineInput;
|
|
2069
|
+
this.viewAdapter = built.adapter;
|
|
2070
|
+
this.component = this.assembleComponent(built, config.theme);
|
|
2071
|
+
this.viewAdapter.apply(this.state);
|
|
2072
|
+
}
|
|
2073
|
+
assembleComponent(built, theme) {
|
|
2074
|
+
const collapsedRender = this.buildCollapsedRender(theme);
|
|
2075
|
+
return {
|
|
2076
|
+
render: (width) => this.state.collapsed ? collapsedRender(width) : built.render(width),
|
|
2077
|
+
invalidate: built.invalidate,
|
|
2078
|
+
handleInput: (data) => this.dispatch(data)
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
buildCollapsedRender(theme) {
|
|
2082
|
+
const collapseKeyDisplay = formatKeySpecForDisplay(this.collapseKey);
|
|
2083
|
+
const collapsedHintLine = () => this.collapseKey === COLLAPSE_KEY_OFF ? t("hint.cancel", HINT_PART_CANCEL) : t("hint.expand_line", COLLAPSED_HINT_TEMPLATE).replace(KEY_PLACEHOLDER, collapseKeyDisplay);
|
|
2084
|
+
return (_width) => [theme.fg("dim", ` ${collapsedHintLine()} `)];
|
|
2085
|
+
}
|
|
2086
|
+
dispatch(data) {
|
|
2087
|
+
if (this.inputEditorOpen)
|
|
2088
|
+
return;
|
|
2089
|
+
const action = routeKey(data, this.state, this.runtime());
|
|
2090
|
+
if (action.kind === "ignore") {
|
|
2091
|
+
this.handleIgnoreInline(data);
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
this.commit(action);
|
|
2095
|
+
}
|
|
2096
|
+
commit(action) {
|
|
2097
|
+
const result = reduce(this.state, action, this.applyContext());
|
|
2098
|
+
this.state = result.state;
|
|
2099
|
+
for (const effect of result.effects)
|
|
2100
|
+
this.runEffect(effect);
|
|
2101
|
+
this.state = this.mirrorNotesDraft(this.state);
|
|
2102
|
+
this.viewAdapter.apply(this.state);
|
|
2103
|
+
}
|
|
2104
|
+
mirrorNotesDraft(s) {
|
|
2105
|
+
const draft = this.notesInput.getExpandedText?.() ?? this.notesInput.getText();
|
|
2106
|
+
return s.notesDraft === draft ? s : { ...s, notesDraft: draft };
|
|
2107
|
+
}
|
|
2108
|
+
runEffect(effect) {
|
|
2109
|
+
switch (effect.kind) {
|
|
2110
|
+
case "set_input_buffer":
|
|
2111
|
+
this.inlineInput.setText(effect.value);
|
|
2112
|
+
return;
|
|
2113
|
+
case "clear_input_buffer":
|
|
2114
|
+
this.inlineInput.setText("");
|
|
2115
|
+
return;
|
|
2116
|
+
case "open_input_editor":
|
|
2117
|
+
this.openInputEditorAsync(effect.value);
|
|
2118
|
+
return;
|
|
2119
|
+
case "set_notes_value":
|
|
2120
|
+
this.notesInput.setText(effect.value);
|
|
2121
|
+
return;
|
|
2122
|
+
case "set_notes_focused":
|
|
2123
|
+
this.notesInput.focused = effect.focused;
|
|
2124
|
+
return;
|
|
2125
|
+
case "forward_notes_keystroke":
|
|
2126
|
+
this.notesInput.handleInput(effect.data);
|
|
2127
|
+
return;
|
|
2128
|
+
case "set_overlay_hidden":
|
|
2129
|
+
if (this.canReopenWhileHidden)
|
|
2130
|
+
this.overlayHandle?.setHidden(effect.hidden);
|
|
2131
|
+
return;
|
|
2132
|
+
case "done":
|
|
2133
|
+
this.done(effect.result);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
openInputEditorAsync(value) {
|
|
2138
|
+
if (this.inputEditorOpen)
|
|
2139
|
+
return;
|
|
2140
|
+
this.inputEditorOpen = true;
|
|
2141
|
+
this.editInput(value).then((edited) => {
|
|
2142
|
+
this.inputEditorOpen = false;
|
|
2143
|
+
if (edited !== undefined)
|
|
2144
|
+
this.commit({ kind: "input_replace", value: edited });
|
|
2145
|
+
}, () => {
|
|
2146
|
+
this.inputEditorOpen = false;
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
handleIgnoreInline(data) {
|
|
2150
|
+
if (!this.state.inputMode)
|
|
2151
|
+
return;
|
|
2152
|
+
this.inlineInput.handleInput(data);
|
|
2153
|
+
this.viewAdapter.apply(this.state);
|
|
2154
|
+
}
|
|
2155
|
+
runtime() {
|
|
2156
|
+
const cursor = this.inlineInput.getCursor();
|
|
2157
|
+
const lastLine = this.inlineInput.getLines().length - 1;
|
|
2158
|
+
return {
|
|
2159
|
+
keybindings: this.keybindings,
|
|
2160
|
+
inputBuffer: this.inlineInput.getExpandedText?.() ?? this.inlineInput.getText(),
|
|
2161
|
+
canMoveInputUp: cursor.line > 0,
|
|
2162
|
+
canMoveInputDown: cursor.line < lastLine,
|
|
2163
|
+
questions: this.questions,
|
|
2164
|
+
isMulti: this.isMulti,
|
|
2165
|
+
currentItem: this.currentItem(),
|
|
2166
|
+
items: this.itemsByTab[this.state.currentTab] ?? [],
|
|
2167
|
+
collapseKey: this.collapseKey
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
applyContext() {
|
|
2171
|
+
return {
|
|
2172
|
+
questions: this.questions,
|
|
2173
|
+
itemsByTab: this.itemsByTab
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
currentItem() {
|
|
2177
|
+
const arr = this.itemsByTab[this.state.currentTab] ?? [];
|
|
2178
|
+
return this.state.optionIndex < arr.length ? arr[this.state.optionIndex] : undefined;
|
|
2179
|
+
}
|
|
2180
|
+
setOverlayHandle(handle) {
|
|
2181
|
+
this.overlayHandle = handle;
|
|
2182
|
+
}
|
|
2183
|
+
toggleCollapsedExternal() {
|
|
2184
|
+
if (!this.inputEditorOpen)
|
|
2185
|
+
this.commit({ kind: "toggle_collapsed" });
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
export {
|
|
2189
|
+
QuestionnaireSession
|
|
2190
|
+
};
|