@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +22 -0
  3. package/README.md +282 -0
  4. package/docs/README.md +33 -0
  5. package/docs/configuration.md +406 -0
  6. package/docs/contract.md +309 -0
  7. package/docs/remote-events.md +187 -0
  8. package/package.json +130 -0
  9. package/skills/ask-user/SKILL.md +110 -0
  10. package/src/answer-commands.ts +361 -0
  11. package/src/answer-extraction.ts +354 -0
  12. package/src/ask-payload-store.ts +86 -0
  13. package/src/ask-settings-command.ts +14 -0
  14. package/src/ask-tool-helpers.ts +172 -0
  15. package/src/ask-tool.ts +84 -0
  16. package/src/config/defaults.ts +216 -0
  17. package/src/config/migrate.ts +70 -0
  18. package/src/config/migrations/index.ts +139 -0
  19. package/src/config/migrations/types.ts +10 -0
  20. package/src/config/schema.ts +287 -0
  21. package/src/config/store.ts +227 -0
  22. package/src/constants/keymaps.ts +721 -0
  23. package/src/constants/text.ts +12 -0
  24. package/src/constants/ui.ts +22 -0
  25. package/src/index.ts +30 -0
  26. package/src/math.ts +3 -0
  27. package/src/notifications.ts +119 -0
  28. package/src/remote-ask.ts +563 -0
  29. package/src/result-format.ts +157 -0
  30. package/src/result.ts +23 -0
  31. package/src/schema.ts +74 -0
  32. package/src/state/answers.ts +251 -0
  33. package/src/state/create.ts +18 -0
  34. package/src/state/editor.ts +70 -0
  35. package/src/state/navigation.ts +86 -0
  36. package/src/state/normalize.ts +326 -0
  37. package/src/state/question-type.ts +128 -0
  38. package/src/state/result.ts +263 -0
  39. package/src/state/selectors.ts +135 -0
  40. package/src/state/transitions.ts +330 -0
  41. package/src/state/view.ts +28 -0
  42. package/src/text.ts +98 -0
  43. package/src/types.ts +169 -0
  44. package/src/ui/auto-submit.ts +36 -0
  45. package/src/ui/autocomplete.ts +52 -0
  46. package/src/ui/controller.ts +645 -0
  47. package/src/ui/dismiss-guard.ts +26 -0
  48. package/src/ui/input.ts +160 -0
  49. package/src/ui/render-frame.ts +235 -0
  50. package/src/ui/render-helpers.ts +385 -0
  51. package/src/ui/render-question.ts +288 -0
  52. package/src/ui/render-submit.ts +168 -0
  53. package/src/ui/render-types.ts +33 -0
  54. package/src/ui/render.ts +53 -0
  55. package/src/ui/review-shortcuts.ts +43 -0
  56. package/src/ui/settings-list.ts +461 -0
  57. package/src/ui/show-settings.ts +37 -0
  58. package/src/ui/view-models/question.ts +203 -0
  59. 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
+ }