@geoqiao/pi-ask 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/LICENSE +22 -0
- package/README.md +282 -0
- package/docs/README.md +33 -0
- package/docs/configuration.md +406 -0
- package/docs/contract.md +309 -0
- package/docs/remote-events.md +187 -0
- package/package.json +130 -0
- package/skills/ask-user/SKILL.md +110 -0
- package/src/answer-commands.ts +361 -0
- package/src/answer-extraction.ts +354 -0
- package/src/ask-payload-store.ts +86 -0
- package/src/ask-settings-command.ts +14 -0
- package/src/ask-tool-helpers.ts +172 -0
- package/src/ask-tool.ts +84 -0
- package/src/config/defaults.ts +216 -0
- package/src/config/migrate.ts +70 -0
- package/src/config/migrations/index.ts +139 -0
- package/src/config/migrations/types.ts +10 -0
- package/src/config/schema.ts +287 -0
- package/src/config/store.ts +227 -0
- package/src/constants/keymaps.ts +721 -0
- package/src/constants/text.ts +12 -0
- package/src/constants/ui.ts +22 -0
- package/src/index.ts +30 -0
- package/src/math.ts +3 -0
- package/src/notifications.ts +119 -0
- package/src/remote-ask.ts +563 -0
- package/src/result-format.ts +157 -0
- package/src/result.ts +23 -0
- package/src/schema.ts +74 -0
- package/src/state/answers.ts +251 -0
- package/src/state/create.ts +18 -0
- package/src/state/editor.ts +70 -0
- package/src/state/navigation.ts +86 -0
- package/src/state/normalize.ts +326 -0
- package/src/state/question-type.ts +128 -0
- package/src/state/result.ts +263 -0
- package/src/state/selectors.ts +135 -0
- package/src/state/transitions.ts +330 -0
- package/src/state/view.ts +28 -0
- package/src/text.ts +98 -0
- package/src/types.ts +169 -0
- package/src/ui/auto-submit.ts +36 -0
- package/src/ui/autocomplete.ts +52 -0
- package/src/ui/controller.ts +645 -0
- package/src/ui/dismiss-guard.ts +26 -0
- package/src/ui/input.ts +160 -0
- package/src/ui/render-frame.ts +235 -0
- package/src/ui/render-helpers.ts +385 -0
- package/src/ui/render-question.ts +288 -0
- package/src/ui/render-submit.ts +168 -0
- package/src/ui/render-types.ts +33 -0
- package/src/ui/render.ts +53 -0
- package/src/ui/review-shortcuts.ts +43 -0
- package/src/ui/settings-list.ts +461 -0
- package/src/ui/show-settings.ts +37 -0
- package/src/ui/view-models/question.ts +203 -0
- package/src/ui/view-models/review.ts +100 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { AskConfig } from "../config/schema.ts";
|
|
4
|
+
import {
|
|
5
|
+
type FooterKeymapContext,
|
|
6
|
+
renderFooterKeymaps,
|
|
7
|
+
} from "../constants/keymaps.ts";
|
|
8
|
+
import { NO_PREVIEW_TEXT } from "../constants/text.ts";
|
|
9
|
+
import { UI_DIMENSIONS, UI_TEXT } from "../constants/ui.ts";
|
|
10
|
+
import { clamp } from "../math.ts";
|
|
11
|
+
import { wrapText } from "../text.ts";
|
|
12
|
+
|
|
13
|
+
type Theme = ExtensionContext["ui"]["theme"];
|
|
14
|
+
type ThemeColor =
|
|
15
|
+
| "accent"
|
|
16
|
+
| "muted"
|
|
17
|
+
| "text"
|
|
18
|
+
| "dim"
|
|
19
|
+
| "success"
|
|
20
|
+
| "warning"
|
|
21
|
+
| "syntaxString";
|
|
22
|
+
|
|
23
|
+
const EDITOR_BORDER_PATTERN = /^[┌┐└┘─]+$/;
|
|
24
|
+
const EDITOR_SCROLL_BORDER_PATTERN = /^─── [↑↓] \d+ more ─*$/;
|
|
25
|
+
const ANSI_CONTROL_SEQUENCE = "\u001b[";
|
|
26
|
+
const ANSI_TERMINATOR = "m";
|
|
27
|
+
|
|
28
|
+
export function pushWrappedText(
|
|
29
|
+
lines: string[],
|
|
30
|
+
text: string,
|
|
31
|
+
width: number,
|
|
32
|
+
theme: Theme,
|
|
33
|
+
color: ThemeColor,
|
|
34
|
+
prefix = "",
|
|
35
|
+
continuationPrefix = prefix
|
|
36
|
+
) {
|
|
37
|
+
const availableWidth = Math.max(1, width - visibleWidth(prefix));
|
|
38
|
+
const wrapped = wrapText(text, availableWidth);
|
|
39
|
+
for (let index = 0; index < wrapped.length; index++) {
|
|
40
|
+
const line = wrapped[index];
|
|
41
|
+
const currentPrefix = index === 0 ? prefix : continuationPrefix;
|
|
42
|
+
lines.push(
|
|
43
|
+
truncateToWidth(`${currentPrefix}${theme.fg(color, line)}`, width)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
if (wrapped.length === 0) {
|
|
47
|
+
lines.push(truncateToWidth(prefix, width));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function renderInputLine(
|
|
52
|
+
line: string,
|
|
53
|
+
availableWidth: number,
|
|
54
|
+
theme: Theme,
|
|
55
|
+
color: ThemeColor = "text"
|
|
56
|
+
): string {
|
|
57
|
+
const innerWidth = Math.max(4, availableWidth - 2);
|
|
58
|
+
const truncated = truncateToWidth(line, innerWidth);
|
|
59
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
60
|
+
return theme.bg("selectedBg", ` ${theme.fg(color, truncated)}${padding} `);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderEditorBlock(args: {
|
|
64
|
+
lines: string[];
|
|
65
|
+
editorLines: string[];
|
|
66
|
+
width: number;
|
|
67
|
+
theme: Theme;
|
|
68
|
+
indent: string;
|
|
69
|
+
availableWidth: number;
|
|
70
|
+
placeholder?: string;
|
|
71
|
+
placeholderColor?: ThemeColor;
|
|
72
|
+
contentColor?: ThemeColor;
|
|
73
|
+
isEmpty?: boolean;
|
|
74
|
+
}) {
|
|
75
|
+
const {
|
|
76
|
+
lines,
|
|
77
|
+
editorLines,
|
|
78
|
+
width,
|
|
79
|
+
theme,
|
|
80
|
+
indent,
|
|
81
|
+
availableWidth,
|
|
82
|
+
placeholder,
|
|
83
|
+
placeholderColor = "muted",
|
|
84
|
+
isEmpty = false,
|
|
85
|
+
} = args;
|
|
86
|
+
const innerLines = getEditorContentLines(editorLines);
|
|
87
|
+
|
|
88
|
+
if (isEmpty && placeholder) {
|
|
89
|
+
lines.push(
|
|
90
|
+
truncateToWidth(
|
|
91
|
+
`${indent}${renderInputLine(
|
|
92
|
+
placeholder,
|
|
93
|
+
availableWidth,
|
|
94
|
+
theme,
|
|
95
|
+
placeholderColor
|
|
96
|
+
)}`,
|
|
97
|
+
width
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const editorLine of innerLines) {
|
|
104
|
+
lines.push(
|
|
105
|
+
truncateToWidth(
|
|
106
|
+
`${indent}${renderEditorLine(editorLine, availableWidth, theme)}`,
|
|
107
|
+
width
|
|
108
|
+
)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function renderLabeledEditorBlock(args: {
|
|
114
|
+
lines: string[];
|
|
115
|
+
label: string;
|
|
116
|
+
editorLines: string[];
|
|
117
|
+
width: number;
|
|
118
|
+
theme: Theme;
|
|
119
|
+
indent: string;
|
|
120
|
+
availableWidth: number;
|
|
121
|
+
placeholder?: string;
|
|
122
|
+
placeholderColor?: ThemeColor;
|
|
123
|
+
isEmpty?: boolean;
|
|
124
|
+
}) {
|
|
125
|
+
const {
|
|
126
|
+
lines,
|
|
127
|
+
label,
|
|
128
|
+
editorLines,
|
|
129
|
+
width,
|
|
130
|
+
theme,
|
|
131
|
+
indent,
|
|
132
|
+
availableWidth,
|
|
133
|
+
placeholder,
|
|
134
|
+
placeholderColor = "muted",
|
|
135
|
+
isEmpty = false,
|
|
136
|
+
} = args;
|
|
137
|
+
const contentLines = getEditorContentLines(editorLines);
|
|
138
|
+
const labelText = theme.fg("accent", label);
|
|
139
|
+
const contentIndent = `${indent}${" ".repeat(visibleWidth(label) + 1)}`;
|
|
140
|
+
const editorWidth = Math.max(4, availableWidth - visibleWidth(label) - 1);
|
|
141
|
+
const firstLine =
|
|
142
|
+
isEmpty && placeholder
|
|
143
|
+
? renderInputLine(placeholder, editorWidth, theme, placeholderColor)
|
|
144
|
+
: renderEditorLine(contentLines[0] ?? "", editorWidth, theme);
|
|
145
|
+
|
|
146
|
+
lines.push(truncateToWidth(`${indent}${labelText} ${firstLine}`, width));
|
|
147
|
+
|
|
148
|
+
for (const editorLine of contentLines.slice(1)) {
|
|
149
|
+
lines.push(
|
|
150
|
+
truncateToWidth(
|
|
151
|
+
`${contentIndent}${renderEditorLine(editorLine, availableWidth, theme)}`,
|
|
152
|
+
width
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function getEditorContentLines(editorLines: string[]): string[] {
|
|
159
|
+
if (editorLines.length <= 2) {
|
|
160
|
+
return editorLines;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const contentLines = editorLines.slice(1);
|
|
164
|
+
const trailingBorderIndex = contentLines.findIndex(isEditorBorderLine);
|
|
165
|
+
if (trailingBorderIndex === -1) {
|
|
166
|
+
return contentLines;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return contentLines.filter((_, index) => index !== trailingBorderIndex);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isEditorBorderLine(line: string): boolean {
|
|
173
|
+
const plainText = stripAnsiColorCodes(line).trim();
|
|
174
|
+
if (plainText.length === 0) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
EDITOR_BORDER_PATTERN.test(plainText) ||
|
|
180
|
+
EDITOR_SCROLL_BORDER_PATTERN.test(plainText)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stripAnsiColorCodes(text: string): string {
|
|
185
|
+
let result = text;
|
|
186
|
+
while (true) {
|
|
187
|
+
const start = result.indexOf(ANSI_CONTROL_SEQUENCE);
|
|
188
|
+
if (start === -1) {
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const end = result.indexOf(
|
|
193
|
+
ANSI_TERMINATOR,
|
|
194
|
+
start + ANSI_CONTROL_SEQUENCE.length
|
|
195
|
+
);
|
|
196
|
+
if (end === -1) {
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
result =
|
|
201
|
+
result.slice(0, start) + result.slice(end + ANSI_TERMINATOR.length);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderEditorLine(
|
|
206
|
+
line: string,
|
|
207
|
+
availableWidth: number,
|
|
208
|
+
theme: Theme
|
|
209
|
+
): string {
|
|
210
|
+
const innerWidth = Math.max(4, availableWidth - 2);
|
|
211
|
+
const truncated = truncateToWidth(line, innerWidth);
|
|
212
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
|
213
|
+
return renderPersistentBackground(
|
|
214
|
+
`${truncated}${padding}`,
|
|
215
|
+
theme,
|
|
216
|
+
"selectedBg"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function renderPersistentBackground(
|
|
221
|
+
text: string,
|
|
222
|
+
theme: Theme,
|
|
223
|
+
background: "selectedBg"
|
|
224
|
+
): string {
|
|
225
|
+
const marker = "__PI_BG_MARKER__";
|
|
226
|
+
const wrappedMarker = theme.bg(background, marker);
|
|
227
|
+
const markerIndex = wrappedMarker.indexOf(marker);
|
|
228
|
+
if (markerIndex === -1) {
|
|
229
|
+
return theme.bg(background, ` ${text} `);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const prefix = wrappedMarker.slice(0, markerIndex);
|
|
233
|
+
const suffix = wrappedMarker.slice(markerIndex + marker.length);
|
|
234
|
+
const ansiReset = "\u001b[0m";
|
|
235
|
+
const reopenedText = text.split(ansiReset).join(`${ansiReset}${prefix}`);
|
|
236
|
+
return `${prefix} ${reopenedText} ${suffix}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function renderBox(
|
|
240
|
+
content: Array<{ text: string; color: ThemeColor }>,
|
|
241
|
+
width: number,
|
|
242
|
+
theme: Theme
|
|
243
|
+
): string[] {
|
|
244
|
+
const boxWidth = Math.max(UI_DIMENSIONS.boxMinWidth, width);
|
|
245
|
+
const innerWidth = Math.max(4, boxWidth - 2);
|
|
246
|
+
const top = theme.fg("accent", `┌${"─".repeat(innerWidth)}┐`);
|
|
247
|
+
const bottom = theme.fg("accent", `└${"─".repeat(innerWidth)}┘`);
|
|
248
|
+
const lines = [top];
|
|
249
|
+
for (const item of content) {
|
|
250
|
+
for (const rawLine of wrapText(item.text, innerWidth)) {
|
|
251
|
+
const line = theme.fg(item.color, rawLine);
|
|
252
|
+
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(line)));
|
|
253
|
+
lines.push(
|
|
254
|
+
theme.fg("accent", "│") + line + padding + theme.fg("accent", "│")
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
lines.push(bottom);
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function renderPreviewPaneContent(
|
|
263
|
+
selectedOption:
|
|
264
|
+
| {
|
|
265
|
+
label: string;
|
|
266
|
+
description?: string;
|
|
267
|
+
preview?: string;
|
|
268
|
+
}
|
|
269
|
+
| undefined,
|
|
270
|
+
theme: Theme,
|
|
271
|
+
width: number
|
|
272
|
+
): string[] {
|
|
273
|
+
if (!selectedOption) {
|
|
274
|
+
return renderBox([{ text: NO_PREVIEW_TEXT, color: "dim" }], width, theme);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const content: Array<{ text: string; color: ThemeColor }> = [
|
|
278
|
+
{ text: selectedOption.label, color: "accent" },
|
|
279
|
+
];
|
|
280
|
+
if (selectedOption.description) {
|
|
281
|
+
content.push({ text: selectedOption.description, color: "muted" });
|
|
282
|
+
}
|
|
283
|
+
content.push({ text: "", color: "dim" });
|
|
284
|
+
|
|
285
|
+
for (const previewLine of (selectedOption.preview ?? NO_PREVIEW_TEXT).split(
|
|
286
|
+
"\n"
|
|
287
|
+
)) {
|
|
288
|
+
content.push({
|
|
289
|
+
text: previewLine,
|
|
290
|
+
color: selectedOption.preview ? "text" : "dim",
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return renderBox(content, width, theme);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function mergeColumns(
|
|
297
|
+
left: string[],
|
|
298
|
+
right: string[],
|
|
299
|
+
leftWidth: number,
|
|
300
|
+
width: number
|
|
301
|
+
): string[] {
|
|
302
|
+
const lines: string[] = [];
|
|
303
|
+
const rowCount = Math.max(left.length, right.length);
|
|
304
|
+
for (let index = 0; index < rowCount; index++) {
|
|
305
|
+
const leftLine = left[index] ?? "";
|
|
306
|
+
const rightLine = right[index] ?? "";
|
|
307
|
+
const paddedLeft = padToVisibleWidth(leftLine, leftWidth);
|
|
308
|
+
lines.push(truncateToWidth(`${paddedLeft} ${rightLine}`, width));
|
|
309
|
+
}
|
|
310
|
+
return lines;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function measurePreviewLeftWidth(
|
|
314
|
+
options: Array<{ label: string; description?: string }>,
|
|
315
|
+
width: number
|
|
316
|
+
): number {
|
|
317
|
+
let widest = 0;
|
|
318
|
+
for (let index = 0; index < options.length; index++) {
|
|
319
|
+
const option = options[index];
|
|
320
|
+
widest = Math.max(
|
|
321
|
+
widest,
|
|
322
|
+
visibleWidth(`${index + 1}. ${option.label}`),
|
|
323
|
+
option.description ? visibleWidth(option.description) : 0
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const preferred = widest + 4;
|
|
328
|
+
const maxWidth = Math.min(
|
|
329
|
+
UI_DIMENSIONS.previewLeftMaxWidth,
|
|
330
|
+
Math.floor(width * UI_DIMENSIONS.previewLeftRatio)
|
|
331
|
+
);
|
|
332
|
+
return clamp(
|
|
333
|
+
preferred,
|
|
334
|
+
UI_DIMENSIONS.previewLeftMinWidth,
|
|
335
|
+
Math.max(UI_DIMENSIONS.previewLeftMinWidth, maxWidth)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function getSavedNotePrefixes(
|
|
340
|
+
theme: Theme,
|
|
341
|
+
args: { indent: string; label?: string }
|
|
342
|
+
) {
|
|
343
|
+
const title = args.label
|
|
344
|
+
? `${args.label} ${UI_TEXT.questionNoteTitle}`
|
|
345
|
+
: UI_TEXT.questionNoteTitle;
|
|
346
|
+
return {
|
|
347
|
+
prefix: `${args.indent}${theme.fg("syntaxString", title)} `,
|
|
348
|
+
continuationPrefix: `${args.indent}${" ".repeat(visibleWidth(title) + 1)}`,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function pushSavedNote(args: {
|
|
353
|
+
lines: string[];
|
|
354
|
+
note: string;
|
|
355
|
+
width: number;
|
|
356
|
+
theme: Theme;
|
|
357
|
+
indent: string;
|
|
358
|
+
label?: string;
|
|
359
|
+
}) {
|
|
360
|
+
const { lines, note, width, theme, indent, label } = args;
|
|
361
|
+
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
|
362
|
+
indent,
|
|
363
|
+
label,
|
|
364
|
+
});
|
|
365
|
+
pushWrappedText(
|
|
366
|
+
lines,
|
|
367
|
+
note,
|
|
368
|
+
width,
|
|
369
|
+
theme,
|
|
370
|
+
"muted",
|
|
371
|
+
prefix,
|
|
372
|
+
continuationPrefix
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function renderFooterText(
|
|
377
|
+
config: AskConfig,
|
|
378
|
+
mode: FooterKeymapContext
|
|
379
|
+
): string {
|
|
380
|
+
return renderFooterKeymaps(config, mode);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function padToVisibleWidth(text: string, width: number): string {
|
|
384
|
+
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
|
385
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { UI_DIMENSIONS } from "../constants/ui.ts";
|
|
3
|
+
import {
|
|
4
|
+
measurePreviewLeftWidth,
|
|
5
|
+
mergeColumns,
|
|
6
|
+
pushSavedNote,
|
|
7
|
+
pushWrappedText,
|
|
8
|
+
renderEditorBlock,
|
|
9
|
+
renderPreviewPaneContent,
|
|
10
|
+
} from "./render-helpers.ts";
|
|
11
|
+
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
|
12
|
+
import {
|
|
13
|
+
buildQuestionScreenModel,
|
|
14
|
+
type OptionDetailModel,
|
|
15
|
+
type OptionRowModel,
|
|
16
|
+
} from "./view-models/question.ts";
|
|
17
|
+
|
|
18
|
+
export function renderQuestionScreen(context: QuestionRenderContext) {
|
|
19
|
+
const { lines, question, theme, width } = context;
|
|
20
|
+
const model = buildQuestionScreenModel(context);
|
|
21
|
+
|
|
22
|
+
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
|
|
23
|
+
renderQuestionNote(lines, model.questionNote, context);
|
|
24
|
+
|
|
25
|
+
if (model.mode === "preview") {
|
|
26
|
+
renderPreviewQuestion(context, model);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const row of model.rows) {
|
|
31
|
+
renderStandardOption(lines, row, context);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderQuestionNote(
|
|
36
|
+
lines: string[],
|
|
37
|
+
questionNote: ReturnType<typeof buildQuestionScreenModel>["questionNote"],
|
|
38
|
+
context: QuestionRenderContext
|
|
39
|
+
) {
|
|
40
|
+
if (!questionNote) {
|
|
41
|
+
lines.push("");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (questionNote.kind === "editor") {
|
|
45
|
+
renderEditorWithIndent({
|
|
46
|
+
lines,
|
|
47
|
+
editor: context.editor,
|
|
48
|
+
width: context.width,
|
|
49
|
+
theme: context.theme,
|
|
50
|
+
indent: " ",
|
|
51
|
+
padding: UI_DIMENSIONS.editorContentPadding,
|
|
52
|
+
placeholder: questionNote.placeholder,
|
|
53
|
+
});
|
|
54
|
+
lines.push("");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
pushSavedNote({
|
|
58
|
+
lines,
|
|
59
|
+
note: questionNote.text,
|
|
60
|
+
width: context.width,
|
|
61
|
+
theme: context.theme,
|
|
62
|
+
indent: " ",
|
|
63
|
+
});
|
|
64
|
+
lines.push("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderStandardOption(
|
|
68
|
+
lines: string[],
|
|
69
|
+
row: OptionRowModel,
|
|
70
|
+
context: QuestionRenderContext
|
|
71
|
+
) {
|
|
72
|
+
if (row.isCustom && row.detail?.kind === "editor") {
|
|
73
|
+
renderInteractiveCustomOption(lines, row, context);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pushWrappedText(
|
|
78
|
+
lines,
|
|
79
|
+
formatOptionLabel(row),
|
|
80
|
+
context.width,
|
|
81
|
+
context.theme,
|
|
82
|
+
row.color,
|
|
83
|
+
row.pointer,
|
|
84
|
+
" ".repeat(visibleWidth(row.pointer))
|
|
85
|
+
);
|
|
86
|
+
renderOptionDescription(lines, row.description, context.width, context.theme);
|
|
87
|
+
renderOptionDetail(lines, row.detail, context, {
|
|
88
|
+
suppressLeadingGap: !!row.description,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderPreviewQuestion(
|
|
93
|
+
context: QuestionRenderContext,
|
|
94
|
+
model: ReturnType<typeof buildQuestionScreenModel>
|
|
95
|
+
) {
|
|
96
|
+
if (model.mode !== "preview") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const { lines, width, theme } = context;
|
|
101
|
+
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
|
102
|
+
|
|
103
|
+
if (model.previewLayout === "custom") {
|
|
104
|
+
renderPreviewOptionList(model.rows, theme, width).forEach(add);
|
|
105
|
+
} else if (model.previewLayout === "wide") {
|
|
106
|
+
renderWidePreviewLayout(
|
|
107
|
+
add,
|
|
108
|
+
model.rows,
|
|
109
|
+
theme,
|
|
110
|
+
width,
|
|
111
|
+
model.selectedOption
|
|
112
|
+
);
|
|
113
|
+
} else {
|
|
114
|
+
renderStackedPreviewLayout(
|
|
115
|
+
add,
|
|
116
|
+
model.rows,
|
|
117
|
+
theme,
|
|
118
|
+
width,
|
|
119
|
+
model.selectedOption
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
renderOptionDetail(lines, model.selectedOptionDetail, context);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderWidePreviewLayout(
|
|
127
|
+
add: (text?: string) => void,
|
|
128
|
+
rows: OptionRowModel[],
|
|
129
|
+
theme: Theme,
|
|
130
|
+
width: number,
|
|
131
|
+
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
|
132
|
+
) {
|
|
133
|
+
const leftWidth = measurePreviewLeftWidth(rows, width);
|
|
134
|
+
const rightWidth = Math.max(
|
|
135
|
+
UI_DIMENSIONS.previewMinRightWidth,
|
|
136
|
+
width - leftWidth - 2
|
|
137
|
+
);
|
|
138
|
+
const leftPane = renderPreviewOptionList(rows, theme, leftWidth);
|
|
139
|
+
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
|
|
140
|
+
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
|
|
141
|
+
add(line);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderStackedPreviewLayout(
|
|
146
|
+
add: (text?: string) => void,
|
|
147
|
+
rows: OptionRowModel[],
|
|
148
|
+
theme: Theme,
|
|
149
|
+
width: number,
|
|
150
|
+
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
|
151
|
+
) {
|
|
152
|
+
renderPreviewOptionList(rows, theme, width).forEach(add);
|
|
153
|
+
add("");
|
|
154
|
+
renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderPreviewOptionList(
|
|
158
|
+
rows: OptionRowModel[],
|
|
159
|
+
theme: Theme,
|
|
160
|
+
width: number
|
|
161
|
+
): string[] {
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
for (const row of rows) {
|
|
164
|
+
pushWrappedText(
|
|
165
|
+
lines,
|
|
166
|
+
`${row.index + 1}. ${row.label}`,
|
|
167
|
+
width,
|
|
168
|
+
theme,
|
|
169
|
+
row.color,
|
|
170
|
+
row.pointer,
|
|
171
|
+
" "
|
|
172
|
+
);
|
|
173
|
+
renderOptionDescription(lines, row.description, width, theme);
|
|
174
|
+
}
|
|
175
|
+
return lines;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function renderOptionDetail(
|
|
179
|
+
lines: string[],
|
|
180
|
+
detail: OptionDetailModel | undefined,
|
|
181
|
+
context: QuestionRenderContext,
|
|
182
|
+
options: { indent?: string; suppressLeadingGap?: boolean } = {}
|
|
183
|
+
) {
|
|
184
|
+
if (!detail) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const indent = options.indent ?? " ";
|
|
188
|
+
const padding =
|
|
189
|
+
indent === " "
|
|
190
|
+
? UI_DIMENSIONS.editorContentPadding
|
|
191
|
+
: UI_DIMENSIONS.editorIndentedPadding;
|
|
192
|
+
if (detail.withGap && !options.suppressLeadingGap) {
|
|
193
|
+
lines.push("");
|
|
194
|
+
}
|
|
195
|
+
if (detail.kind === "editor") {
|
|
196
|
+
renderEditorWithIndent({
|
|
197
|
+
lines,
|
|
198
|
+
editor: context.editor,
|
|
199
|
+
width: context.width,
|
|
200
|
+
theme: context.theme,
|
|
201
|
+
indent,
|
|
202
|
+
padding,
|
|
203
|
+
placeholder: detail.placeholder,
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (detail.kind === "saved-note") {
|
|
208
|
+
pushSavedNote({
|
|
209
|
+
lines,
|
|
210
|
+
note: detail.text,
|
|
211
|
+
width: context.width,
|
|
212
|
+
theme: context.theme,
|
|
213
|
+
indent,
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
pushWrappedText(
|
|
218
|
+
lines,
|
|
219
|
+
detail.text,
|
|
220
|
+
context.width,
|
|
221
|
+
context.theme,
|
|
222
|
+
"muted",
|
|
223
|
+
indent,
|
|
224
|
+
indent
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderEditorWithIndent(args: {
|
|
229
|
+
lines: string[];
|
|
230
|
+
editor: QuestionRenderContext["editor"];
|
|
231
|
+
width: number;
|
|
232
|
+
theme: Theme;
|
|
233
|
+
indent: string;
|
|
234
|
+
padding: number;
|
|
235
|
+
placeholder: string;
|
|
236
|
+
}) {
|
|
237
|
+
const { lines, editor, width, theme, indent, padding, placeholder } = args;
|
|
238
|
+
renderEditorBlock({
|
|
239
|
+
lines,
|
|
240
|
+
editorLines: editor.render(
|
|
241
|
+
Math.max(UI_DIMENSIONS.editorMinWidth, width - padding)
|
|
242
|
+
),
|
|
243
|
+
width,
|
|
244
|
+
theme,
|
|
245
|
+
indent,
|
|
246
|
+
availableWidth: width - visibleWidth(indent),
|
|
247
|
+
placeholder,
|
|
248
|
+
isEmpty: editor.getText().length === 0,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function formatOptionLabel(row: OptionRowModel): string {
|
|
253
|
+
return row.isFreeformOnly
|
|
254
|
+
? row.label
|
|
255
|
+
: `${row.index + 1}. ${row.prefix}${row.label}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderInteractiveCustomOption(
|
|
259
|
+
lines: string[],
|
|
260
|
+
row: OptionRowModel,
|
|
261
|
+
context: QuestionRenderContext
|
|
262
|
+
) {
|
|
263
|
+
const indent = row.isFreeformOnly ? " " : row.pointer;
|
|
264
|
+
pushWrappedText(
|
|
265
|
+
lines,
|
|
266
|
+
formatOptionLabel(row),
|
|
267
|
+
context.width,
|
|
268
|
+
context.theme,
|
|
269
|
+
row.color,
|
|
270
|
+
indent,
|
|
271
|
+
" ".repeat(visibleWidth(indent))
|
|
272
|
+
);
|
|
273
|
+
renderOptionDetail(lines, row.detail, context, {
|
|
274
|
+
indent: row.isFreeformOnly ? " " : undefined,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function renderOptionDescription(
|
|
279
|
+
lines: string[],
|
|
280
|
+
description: string | undefined,
|
|
281
|
+
width: number,
|
|
282
|
+
theme: Theme
|
|
283
|
+
) {
|
|
284
|
+
if (!description) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
|
|
288
|
+
}
|