@mrclrchtr/supi-ask-user 1.3.1 → 1.5.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 (51) hide show
  1. package/README.md +163 -67
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/api.ts +19 -0
  14. package/src/ask-user.ts +71 -131
  15. package/src/index.ts +23 -1
  16. package/src/normalize.ts +153 -142
  17. package/src/render/result.ts +102 -0
  18. package/src/render/transcript.ts +65 -0
  19. package/src/render/tree-summary.ts +10 -0
  20. package/src/schema.ts +41 -38
  21. package/src/session/controller.ts +281 -0
  22. package/src/session/lock.ts +19 -0
  23. package/src/tool/guidance.ts +15 -0
  24. package/src/types.ts +56 -55
  25. package/src/ui/choose-renderer.ts +11 -0
  26. package/src/ui/overlay-actions.ts +42 -0
  27. package/src/ui/overlay-component.ts +400 -0
  28. package/src/ui/overlay-render.ts +219 -0
  29. package/src/ui/overlay-view.ts +313 -0
  30. package/src/ui/overlay.ts +28 -0
  31. package/src/ui/types.ts +38 -0
  32. package/src/flow.ts +0 -224
  33. package/src/format.ts +0 -66
  34. package/src/render/ui-rich-render-editor.ts +0 -51
  35. package/src/render/ui-rich-render-env.ts +0 -15
  36. package/src/render/ui-rich-render-footer.ts +0 -55
  37. package/src/render/ui-rich-render-markdown.ts +0 -33
  38. package/src/render/ui-rich-render-notes.ts +0 -80
  39. package/src/render/ui-rich-render-types.ts +0 -17
  40. package/src/render/ui-rich-render.ts +0 -323
  41. package/src/render.ts +0 -95
  42. package/src/result.ts +0 -90
  43. package/src/ui/ui-rich-handlers.ts +0 -369
  44. package/src/ui/ui-rich-inline.ts +0 -77
  45. package/src/ui/ui-rich-state.ts +0 -179
  46. package/src/ui/ui-rich.ts +0 -144
  47. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  48. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  49. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  50. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  51. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
@@ -0,0 +1,400 @@
1
+ import {
2
+ type Component,
3
+ Editor,
4
+ type Focusable,
5
+ Key,
6
+ matchesKey,
7
+ SelectList,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { NormalizedChoiceQuestion } from "../types.ts";
10
+ import { createActionList } from "./overlay-actions.ts";
11
+ import {
12
+ clampIndex,
13
+ currentCustomValue,
14
+ currentPreviewText,
15
+ currentTextValue,
16
+ makeEditorTheme,
17
+ makeSelectListTheme,
18
+ renderOverlayFrame,
19
+ } from "./overlay-render.ts";
20
+ import {
21
+ buildChoiceItems,
22
+ buildChoiceRows,
23
+ type ChoiceRow,
24
+ choiceRowValue,
25
+ defaultChoiceRowIndex,
26
+ type FocusTarget,
27
+ type OverlayAction,
28
+ type OverlayMode,
29
+ previewOptionIndexForRows,
30
+ } from "./overlay-view.ts";
31
+ import type { OverlayArgs } from "./types.ts";
32
+
33
+ export class AskUserOverlay implements Component, Focusable {
34
+ focused = false;
35
+
36
+ private readonly editor: Editor;
37
+ private focus: FocusTarget = "choices";
38
+ private mode: OverlayMode = "choice";
39
+ private closed = false;
40
+ private cachedWidth: number | undefined;
41
+ private cachedLines: string[] | undefined;
42
+ private readonly onAbort: () => void;
43
+
44
+ private choiceRows: ChoiceRow[] = [];
45
+ private choiceRowIndex = 0;
46
+ private previewOptionIndex = 0;
47
+ private choiceList: SelectList | undefined;
48
+ private textActions: Array<{ action: OverlayAction; label: string }> = [];
49
+ private actionIndex = 0;
50
+ private actionList: SelectList | undefined;
51
+
52
+ constructor(private readonly args: OverlayArgs) {
53
+ this.editor = new Editor(args.tui, makeEditorTheme(args.theme));
54
+ this.editor.onSubmit = (value) => this.handleEditorSubmit(value);
55
+ this.syncCurrentQuestion();
56
+ this.onAbort = () => {
57
+ this.args.controller.abort();
58
+ this.finish();
59
+ };
60
+ args.signal?.addEventListener("abort", this.onAbort);
61
+ }
62
+
63
+ render(width: number): string[] {
64
+ this.editor.focused = this.focus === "editor";
65
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
66
+ this.cachedWidth = width;
67
+ this.cachedLines = renderOverlayFrame({
68
+ width,
69
+ theme: this.args.theme,
70
+ controller: this.args.controller,
71
+ mode: this.mode,
72
+ focus: this.focus,
73
+ editor: this.editor,
74
+ choiceRows: this.choiceRows,
75
+ choiceRowIndex: this.choiceRowIndex,
76
+ actionList: this.actionList,
77
+ textActionLabels: this.textActions.map(({ label }) => label),
78
+ previewText: currentPreviewText(
79
+ this.args.controller.currentQuestion,
80
+ this.previewOptionIndex,
81
+ ),
82
+ });
83
+ return this.cachedLines;
84
+ }
85
+
86
+ handleInput(data: string): void {
87
+ if (this.closed || this.args.controller.isTerminal) return;
88
+
89
+ if (this.args.keybindings.matches(data, "app.tools.expand")) {
90
+ this.args.onToggleToolsExpanded?.();
91
+ return;
92
+ }
93
+
94
+ if (this.mode === "note-input" && matchesKey(data, Key.escape)) {
95
+ const question = this.args.controller.currentQuestion;
96
+ const row = this.choiceRows[this.choiceRowIndex];
97
+ if (question.type === "choice" && row?.kind === "option") {
98
+ this.restoreChoiceMode(question, row.optionIndex);
99
+ }
100
+ return;
101
+ }
102
+ if (matchesKey(data, Key.escape)) {
103
+ this.args.controller.cancel();
104
+ this.finish();
105
+ return;
106
+ }
107
+ if (this.mode !== "note-input" && matchesKey(data, Key.left)) {
108
+ if (this.args.controller.goBack()) {
109
+ this.syncCurrentQuestion();
110
+ this.refresh();
111
+ }
112
+ return;
113
+ }
114
+
115
+ switch (this.focus) {
116
+ case "choices":
117
+ this.handleChoiceKey(data);
118
+ return;
119
+ case "actions":
120
+ this.handleActionKey(data);
121
+ return;
122
+ case "editor":
123
+ this.handleEditorKey(data);
124
+ return;
125
+ }
126
+ }
127
+
128
+ invalidate(): void {
129
+ this.cachedLines = undefined;
130
+ this.choiceList?.invalidate();
131
+ this.actionList?.invalidate();
132
+ this.editor.invalidate();
133
+ }
134
+
135
+ dispose(): void {
136
+ this.closed = true;
137
+ this.args.signal?.removeEventListener("abort", this.onAbort);
138
+ }
139
+
140
+ private handleChoiceKey(data: string): void {
141
+ const question = this.args.controller.currentQuestion;
142
+ if (question.type !== "choice" || !this.choiceList) return;
143
+
144
+ if (data === "n") {
145
+ const row = this.choiceRows[this.choiceRowIndex];
146
+ if (row?.kind === "option") this.openNoteEditor(question, row.optionIndex);
147
+ return;
148
+ }
149
+
150
+ if (matchesKey(data, Key.space)) {
151
+ const row = this.choiceRows[this.choiceRowIndex];
152
+ if (row?.kind === "option") this.applyChoiceSelection(question, row.optionIndex, false);
153
+ return;
154
+ }
155
+
156
+ this.choiceList.handleInput(data);
157
+ }
158
+
159
+ private handleActionKey(data: string): void {
160
+ const question = this.args.controller.currentQuestion;
161
+ if (!this.actionList) return;
162
+
163
+ if (question.type === "text") {
164
+ if (matchesKey(data, Key.up) && this.actionIndex === 0) {
165
+ this.focus = "editor";
166
+ this.refresh();
167
+ return;
168
+ }
169
+ } else if (matchesKey(data, Key.up) && this.actionIndex === 0) {
170
+ this.focus = "choices";
171
+ this.refresh();
172
+ return;
173
+ }
174
+
175
+ this.actionList.handleInput(data);
176
+ this.refresh();
177
+ }
178
+
179
+ private handleEditorKey(data: string): void {
180
+ if (this.mode === "text" && matchesKey(data, Key.down) && this.textActions.length > 0) {
181
+ this.focus = "actions";
182
+ this.refresh();
183
+ return;
184
+ }
185
+
186
+ this.editor.handleInput(data);
187
+ this.refresh();
188
+ }
189
+
190
+ private handleEditorSubmit(value: string): void {
191
+ const trimmed = value.trim();
192
+ const question = this.args.controller.currentQuestion;
193
+
194
+ if (this.mode === "discuss-input") {
195
+ this.args.controller.finishDiscuss(trimmed || undefined);
196
+ this.finish();
197
+ return;
198
+ }
199
+ if (this.mode === "note-input") {
200
+ if (question.type !== "choice") return;
201
+ const row = this.choiceRows[this.choiceRowIndex];
202
+ if (row?.kind !== "option") return;
203
+ this.args.controller.setChoiceOptionNote(question, row.optionIndex, trimmed || undefined);
204
+ this.restoreChoiceMode(question, row.optionIndex);
205
+ return;
206
+ }
207
+ if (this.mode === "custom-input") {
208
+ if (trimmed.length === 0) return;
209
+ this.args.controller.setAnswer(question.id, { kind: "custom", value: trimmed });
210
+ this.advanceAfterQuestion();
211
+ return;
212
+ }
213
+ if (trimmed.length === 0) {
214
+ if (question.required) return;
215
+ this.args.controller.clearAnswer(question.id);
216
+ this.advanceAfterQuestion();
217
+ return;
218
+ }
219
+
220
+ this.args.controller.setAnswer(question.id, { kind: "text", value: trimmed });
221
+ this.advanceAfterQuestion();
222
+ }
223
+
224
+ private applyChoiceSelection(
225
+ question: NormalizedChoiceQuestion,
226
+ optionIndex: number,
227
+ submit: boolean,
228
+ ): void {
229
+ if (question.multi) {
230
+ this.toggleMultiChoice(question, optionIndex);
231
+ if (submit && this.args.controller.hasAnswer(question.id)) this.advanceAfterQuestion();
232
+ return;
233
+ }
234
+
235
+ this.args.controller.selectChoiceOption(question, optionIndex);
236
+ this.choiceRowIndex = optionIndex;
237
+ this.previewOptionIndex = optionIndex;
238
+ if (submit) {
239
+ this.advanceAfterQuestion();
240
+ return;
241
+ }
242
+ this.buildChoiceList(question);
243
+ this.refresh();
244
+ }
245
+
246
+ private toggleMultiChoice(question: NormalizedChoiceQuestion, optionIndex: number): void {
247
+ this.args.controller.toggleChoiceOption(question, optionIndex);
248
+ this.choiceRowIndex = optionIndex;
249
+ this.previewOptionIndex = optionIndex;
250
+ this.buildChoiceList(question);
251
+ this.refresh();
252
+ }
253
+
254
+ private openNoteEditor(question: NormalizedChoiceQuestion, optionIndex: number): void {
255
+ const option = question.options[optionIndex];
256
+ if (!option) return;
257
+ this.mode = "note-input";
258
+ this.focus = "editor";
259
+ this.choiceRowIndex = optionIndex;
260
+ this.previewOptionIndex = optionIndex;
261
+ this.editor.setText(this.args.controller.getChoiceOptionNote(question.id, option.value) ?? "");
262
+ this.refresh();
263
+ }
264
+
265
+ private restoreChoiceMode(question: NormalizedChoiceQuestion, optionIndex: number): void {
266
+ this.mode = "choice";
267
+ this.focus = "choices";
268
+ this.editor.setText("");
269
+ this.choiceRows = buildChoiceRows(this.args.controller, question);
270
+ const nextIndex = this.choiceRows.findIndex(
271
+ (row) => row.kind === "option" && row.optionIndex === optionIndex,
272
+ );
273
+ this.choiceRowIndex = clampIndex(
274
+ nextIndex >= 0 ? nextIndex : optionIndex,
275
+ this.choiceRows.length,
276
+ );
277
+ this.previewOptionIndex = optionIndex;
278
+ this.buildChoiceList(question);
279
+ this.refresh();
280
+ }
281
+
282
+ private handleAction(action: OverlayAction): void {
283
+ switch (action) {
284
+ case "other":
285
+ this.mode = "custom-input";
286
+ this.focus = "editor";
287
+ this.editor.setText(currentCustomValue(this.args.controller));
288
+ this.refresh();
289
+ return;
290
+ case "skip":
291
+ this.args.controller.clearAnswer(this.args.controller.currentQuestion.id);
292
+ this.advanceAfterQuestion();
293
+ return;
294
+ case "discuss":
295
+ this.mode = "discuss-input";
296
+ this.focus = "editor";
297
+ this.editor.setText("");
298
+ this.refresh();
299
+ return;
300
+ case "partial":
301
+ this.args.controller.finishPartial();
302
+ this.finish();
303
+ return;
304
+ }
305
+ }
306
+
307
+ private advanceAfterQuestion(): void {
308
+ if (!this.args.controller.goNext()) {
309
+ this.args.controller.finishSubmitted();
310
+ this.finish();
311
+ return;
312
+ }
313
+ this.syncCurrentQuestion();
314
+ this.refresh();
315
+ }
316
+
317
+ private syncCurrentQuestion(): void {
318
+ const question = this.args.controller.currentQuestion;
319
+ if (question.type === "text") {
320
+ this.mode = "text";
321
+ this.focus = "editor";
322
+ this.editor.setText(currentTextValue(this.args.controller, question.initial));
323
+ this.buildTextActions();
324
+ this.choiceRows = [];
325
+ return;
326
+ }
327
+
328
+ this.mode = "choice";
329
+ this.focus = "choices";
330
+ this.textActions = [];
331
+ this.actionList = undefined;
332
+ this.editor.setText("");
333
+ this.choiceRows = buildChoiceRows(this.args.controller, question);
334
+ this.choiceRowIndex = clampIndex(
335
+ defaultChoiceRowIndex(this.args.controller, question, this.choiceRows),
336
+ this.choiceRows.length,
337
+ );
338
+ this.previewOptionIndex =
339
+ previewOptionIndexForRows(this.choiceRows, this.choiceRowIndex, this.previewOptionIndex) ?? 0;
340
+ this.buildChoiceList(question);
341
+ }
342
+
343
+ private buildChoiceList(question: NormalizedChoiceQuestion): void {
344
+ const items = buildChoiceItems(this.args.controller, question, this.choiceRows);
345
+
346
+ const list = new SelectList(
347
+ items,
348
+ Math.min(this.choiceRows.length, 10),
349
+ makeSelectListTheme(this.args.theme),
350
+ );
351
+ list.onSelectionChange = (item) => {
352
+ const nextIndex = this.choiceRows.findIndex((row) => choiceRowValue(row) === item.value);
353
+ if (nextIndex < 0) return;
354
+ this.choiceRowIndex = nextIndex;
355
+ this.previewOptionIndex =
356
+ previewOptionIndexForRows(this.choiceRows, nextIndex, this.previewOptionIndex) ??
357
+ this.previewOptionIndex;
358
+ this.refresh();
359
+ };
360
+ list.onSelect = (item) => {
361
+ const row = this.choiceRows.find((candidate) => choiceRowValue(candidate) === item.value);
362
+ if (!row) return;
363
+ if (row.kind === "option") {
364
+ this.applyChoiceSelection(question, row.optionIndex, true);
365
+ return;
366
+ }
367
+ this.handleAction(row.action);
368
+ };
369
+ list.setSelectedIndex(this.choiceRowIndex);
370
+ this.choiceList = list;
371
+ }
372
+
373
+ private buildTextActions(): void {
374
+ const state = createActionList({
375
+ controller: this.args.controller,
376
+ theme: this.args.theme,
377
+ actionIndex: this.actionIndex,
378
+ onIndexChange: (index) => {
379
+ this.actionIndex = index;
380
+ this.refresh();
381
+ },
382
+ onAction: (action) => this.handleAction(action),
383
+ });
384
+ this.textActions = state.entries;
385
+ this.actionList = state.list;
386
+ this.actionIndex = state.index;
387
+ }
388
+
389
+ private refresh(): void {
390
+ this.cachedLines = undefined;
391
+ this.args.tui.requestRender();
392
+ }
393
+
394
+ private finish(): void {
395
+ if (this.closed) return;
396
+ this.closed = true;
397
+ this.args.signal?.removeEventListener("abort", this.onAbort);
398
+ this.args.done(this.args.controller.outcome());
399
+ }
400
+ }
@@ -0,0 +1,219 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Editor,
4
+ type EditorTheme,
5
+ Markdown,
6
+ type SelectList,
7
+ type SelectListTheme,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { AskUserController } from "../session/controller.ts";
10
+ import type { NormalizedQuestionnaire } from "../types.ts";
11
+ import {
12
+ type ChoiceRow,
13
+ type FocusTarget,
14
+ footerText,
15
+ type OverlayMode,
16
+ renderChoiceList,
17
+ splitColumns,
18
+ } from "./overlay-view.ts";
19
+
20
+ export interface RenderOverlayFrameArgs {
21
+ width: number;
22
+ theme: Theme;
23
+ controller: AskUserController;
24
+ mode: OverlayMode;
25
+ focus: FocusTarget;
26
+ editor: Editor;
27
+ choiceRows: ChoiceRow[];
28
+ choiceRowIndex: number;
29
+ actionList: SelectList | undefined;
30
+ textActionLabels: string[];
31
+ previewText?: string;
32
+ }
33
+
34
+ export function renderOverlayFrame(args: RenderOverlayFrameArgs): string[] {
35
+ const lines: string[] = [];
36
+ lines.push(args.theme.fg("accent", "─".repeat(args.width)));
37
+ lines.push(...renderHeader(args));
38
+ lines.push(...renderPrompt(args.controller.currentQuestion.prompt, args.width));
39
+ lines.push("");
40
+ lines.push(...renderBody(args));
41
+ lines.push("");
42
+ lines.push(
43
+ args.theme.fg(
44
+ "dim",
45
+ footerText({
46
+ controller: args.controller,
47
+ mode: args.mode,
48
+ focus: args.focus,
49
+ hasTextActions: args.textActionLabels.length > 0,
50
+ }),
51
+ ),
52
+ );
53
+ lines.push(args.theme.fg("accent", "─".repeat(args.width)));
54
+ return lines;
55
+ }
56
+
57
+ export function makeEditorTheme(theme: Theme): EditorTheme {
58
+ return {
59
+ borderColor: (text) => theme.fg("accent", text),
60
+ selectList: makeSelectListTheme(theme),
61
+ };
62
+ }
63
+
64
+ export function makeSelectListTheme(theme: Theme): SelectListTheme {
65
+ return {
66
+ selectedPrefix: (text) => theme.fg("accent", text),
67
+ selectedText: (text) => theme.fg("accent", text),
68
+ description: (text) => theme.fg("muted", text),
69
+ scrollInfo: (text) => theme.fg("dim", text),
70
+ noMatch: (text) => theme.fg("warning", text),
71
+ };
72
+ }
73
+
74
+ export function currentPreviewText(
75
+ question: NormalizedQuestionnaire["questions"][number],
76
+ optionIndex: number,
77
+ ): string | undefined {
78
+ return question.type === "choice" ? question.options[optionIndex]?.preview : undefined;
79
+ }
80
+
81
+ export function currentTextValue(
82
+ controller: AskUserController,
83
+ initial: string | undefined,
84
+ ): string {
85
+ const answer = controller.getAnswer(controller.currentQuestion.id);
86
+ return answer?.kind === "text" ? answer.value : (initial ?? "");
87
+ }
88
+
89
+ export function currentCustomValue(controller: AskUserController): string {
90
+ const answer = controller.getAnswer(controller.currentQuestion.id);
91
+ return answer?.kind === "custom" ? answer.value : "";
92
+ }
93
+
94
+ export function clampIndex(index: number, length: number): number {
95
+ if (length === 0) return 0;
96
+ return Math.max(0, Math.min(index, length - 1));
97
+ }
98
+
99
+ function renderHeader(args: RenderOverlayFrameArgs): string[] {
100
+ const lines: string[] = [];
101
+ const { title, intro } = args.controller.questionnaire;
102
+ if (title) lines.push(args.theme.fg("accent", args.theme.bold(title)));
103
+ lines.push(
104
+ args.theme.fg(
105
+ "muted",
106
+ `${args.controller.currentIndex + 1}/${args.controller.questions.length} · ${args.controller.currentQuestion.header}`,
107
+ ),
108
+ );
109
+ if (intro) {
110
+ lines.push("");
111
+ lines.push(...renderPrompt(intro, args.width));
112
+ }
113
+ return lines;
114
+ }
115
+
116
+ function renderBody(args: RenderOverlayFrameArgs): string[] {
117
+ const question = args.controller.currentQuestion;
118
+ if (question.type === "text") {
119
+ return renderTextBody(args);
120
+ }
121
+ return renderChoiceBody(args);
122
+ }
123
+
124
+ function renderChoiceBody(args: RenderOverlayFrameArgs): string[] {
125
+ const question = args.controller.currentQuestion;
126
+ const listWidth = splitLeftWidth(args.width);
127
+
128
+ const leftLines =
129
+ question.type === "choice"
130
+ ? renderChoiceList({
131
+ controller: args.controller,
132
+ question,
133
+ rows: args.choiceRows,
134
+ selectedIndex: args.choiceRowIndex,
135
+ theme: args.theme,
136
+ width: listWidth,
137
+ })
138
+ : [];
139
+
140
+ if (args.mode === "custom-input" || args.mode === "discuss-input" || args.mode === "note-input") {
141
+ const rightLines = renderEditorLines(args, splitRightWidth(args.width));
142
+ if (args.width >= 100) {
143
+ return splitColumns({
144
+ width: args.width,
145
+ theme: args.theme,
146
+ leftLines,
147
+ rightLines,
148
+ });
149
+ }
150
+ return [...leftLines, "", ...rightLines];
151
+ }
152
+
153
+ if (args.previewText && args.width >= 100) {
154
+ const rightLines = [
155
+ args.theme.fg("accent", "Preview"),
156
+ "",
157
+ ...renderPrompt(args.previewText, splitRightWidth(args.width)),
158
+ ];
159
+ return splitColumns({
160
+ width: args.width,
161
+ theme: args.theme,
162
+ leftLines,
163
+ rightLines,
164
+ });
165
+ }
166
+
167
+ if (!args.previewText) return leftLines;
168
+ return [
169
+ ...leftLines,
170
+ "",
171
+ args.theme.fg("accent", "Preview"),
172
+ ...renderPrompt(args.previewText, args.width),
173
+ ];
174
+ }
175
+
176
+ function renderTextBody(args: RenderOverlayFrameArgs): string[] {
177
+ const lines = renderEditorLines(args, args.width);
178
+ if (args.textActionLabels.length === 0) return lines;
179
+
180
+ if (args.focus === "actions") {
181
+ return [...lines, "", ...(args.actionList?.render(args.width) ?? [])];
182
+ }
183
+
184
+ return [...lines, "", ...args.textActionLabels.map((label) => ` ${label}`)];
185
+ }
186
+
187
+ function renderEditorLines(args: RenderOverlayFrameArgs, width: number): string[] {
188
+ const label =
189
+ args.mode === "discuss-input"
190
+ ? "Discuss instead"
191
+ : args.mode === "custom-input"
192
+ ? "Other answer"
193
+ : args.mode === "note-input"
194
+ ? "Option note"
195
+ : "Your answer";
196
+
197
+ const lines = [args.theme.fg("accent", label), ...args.editor.render(Math.max(20, width - 1))];
198
+ const question = args.controller.currentQuestion;
199
+ if (question.type !== "text" || args.editor.getText()) return lines;
200
+
201
+ if (question.initial) {
202
+ lines.push(args.theme.fg("dim", `Initial: ${question.initial}`));
203
+ } else if (question.placeholder) {
204
+ lines.push(args.theme.fg("dim", `Placeholder: ${question.placeholder}`));
205
+ }
206
+ return lines;
207
+ }
208
+
209
+ function renderPrompt(text: string, width: number): string[] {
210
+ return new Markdown(text, 0, 0, getMarkdownTheme()).render(Math.max(1, width));
211
+ }
212
+
213
+ function splitLeftWidth(width: number): number {
214
+ return Math.max(36, Math.floor(width * 0.55));
215
+ }
216
+
217
+ function splitRightWidth(width: number): number {
218
+ return Math.max(24, width - splitLeftWidth(width) - 3);
219
+ }