@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5

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 (89) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/cli.js +3563 -3540
  3. package/dist/types/async/job-manager.d.ts +8 -0
  4. package/dist/types/config/settings-schema.d.ts +18 -8
  5. package/dist/types/config/settings.d.ts +3 -2
  6. package/dist/types/discovery/helpers.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/types.d.ts +36 -0
  8. package/dist/types/irc/bus.d.ts +4 -0
  9. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
  10. package/dist/types/modes/components/ask-dialog.d.ts +27 -0
  11. package/dist/types/modes/components/index.d.ts +2 -1
  12. package/dist/types/modes/components/model-browser.d.ts +97 -0
  13. package/dist/types/modes/components/model-hub.d.ts +51 -0
  14. package/dist/types/modes/components/pause-screen.d.ts +43 -0
  15. package/dist/types/modes/components/session-selector.d.ts +13 -0
  16. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  17. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -0
  19. package/dist/types/modes/shared.d.ts +1 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/session-context.d.ts +9 -0
  23. package/dist/types/task/executor.d.ts +26 -13
  24. package/dist/types/task/index.d.ts +12 -11
  25. package/dist/types/task/label.d.ts +4 -0
  26. package/dist/types/task/repair-args.d.ts +8 -8
  27. package/dist/types/task/types.d.ts +31 -56
  28. package/dist/types/tools/ask.d.ts +12 -0
  29. package/dist/types/tools/conflict-detect.d.ts +17 -1
  30. package/dist/types/tools/job.d.ts +16 -0
  31. package/package.json +12 -12
  32. package/src/async/job-manager.ts +9 -0
  33. package/src/commit/agentic/tools/analyze-file.ts +2 -3
  34. package/src/config/settings-schema.ts +17 -6
  35. package/src/config/settings.ts +13 -4
  36. package/src/discovery/helpers.ts +3 -4
  37. package/src/extensibility/custom-tools/loader.ts +70 -37
  38. package/src/extensibility/extensions/types.ts +46 -0
  39. package/src/irc/bus.ts +61 -20
  40. package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
  41. package/src/modes/components/advisor-config.ts +30 -22
  42. package/src/modes/components/ask-dialog.ts +888 -0
  43. package/src/modes/components/index.ts +2 -1
  44. package/src/modes/components/model-browser.ts +688 -0
  45. package/src/modes/components/model-hub.ts +1655 -0
  46. package/src/modes/components/pause-screen.ts +208 -0
  47. package/src/modes/components/session-selector.ts +299 -42
  48. package/src/modes/components/tool-execution.ts +2 -0
  49. package/src/modes/controllers/event-controller.ts +8 -2
  50. package/src/modes/controllers/extension-ui-controller.ts +252 -5
  51. package/src/modes/controllers/selector-controller.ts +143 -87
  52. package/src/modes/controllers/tan-command-controller.ts +1 -1
  53. package/src/modes/interactive-mode.ts +3 -0
  54. package/src/modes/shared.ts +1 -1
  55. package/src/modes/theme/theme.ts +3 -3
  56. package/src/modes/types.ts +2 -0
  57. package/src/modes/utils/ui-helpers.ts +31 -4
  58. package/src/prompts/agents/scout.md +0 -1
  59. package/src/prompts/agents/task.md +1 -1
  60. package/src/prompts/system/subagent-system-prompt.md +1 -5
  61. package/src/prompts/system/subagent-yield-reminder.md +10 -0
  62. package/src/prompts/system/task-label.md +23 -0
  63. package/src/prompts/tools/job.md +1 -1
  64. package/src/prompts/tools/task-summary.md +3 -0
  65. package/src/prompts/tools/task.md +17 -18
  66. package/src/session/agent-session.ts +2 -1
  67. package/src/session/session-context.test.ts +73 -0
  68. package/src/session/session-context.ts +43 -26
  69. package/src/slash-commands/builtin-registry.ts +9 -0
  70. package/src/task/agents.ts +2 -0
  71. package/src/task/executor.ts +159 -46
  72. package/src/task/index.ts +377 -239
  73. package/src/task/label.ts +38 -0
  74. package/src/task/render.ts +74 -22
  75. package/src/task/repair-args.ts +20 -31
  76. package/src/task/spawn-policy.test.ts +4 -4
  77. package/src/task/types.ts +46 -66
  78. package/src/tools/ask.ts +233 -40
  79. package/src/tools/conflict-detect.ts +102 -5
  80. package/src/tools/index.ts +1 -0
  81. package/src/tools/irc.ts +20 -11
  82. package/src/tools/job.ts +158 -18
  83. package/src/tools/write.ts +70 -6
  84. package/src/vibe/runtime.ts +1 -1
  85. package/src/web/search/providers/browser-headers.ts +30 -13
  86. package/dist/types/modes/components/model-selector.d.ts +0 -37
  87. package/dist/types/tools/bash-command-fixup.d.ts +0 -3
  88. package/src/modes/components/model-selector.ts +0 -1291
  89. package/src/tools/bash-command-fixup.ts +0 -4
@@ -0,0 +1,888 @@
1
+ import {
2
+ type Component,
3
+ Ellipsis,
4
+ Markdown,
5
+ type MarkdownTheme,
6
+ matchesKey,
7
+ padding,
8
+ renderInlineMarkdown,
9
+ replaceTabs,
10
+ ScrollView,
11
+ type Tab,
12
+ TabBar,
13
+ Text,
14
+ type TUI,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ wrapTextWithAnsi,
18
+ } from "@oh-my-pi/pi-tui";
19
+ import type {
20
+ ExtensionAskDialogQuestion,
21
+ ExtensionAskDialogResultItem,
22
+ ExtensionAskDialogSubmitResult,
23
+ } from "../../extensibility/extensions";
24
+ import { getTabBarTheme } from "../shared";
25
+ import { getMarkdownTheme, highlightCode, theme } from "../theme/theme";
26
+ import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
27
+ import { CountdownTimer } from "./countdown-timer";
28
+ import { bottomBorder, divider, row, topBorder } from "./overlay-box";
29
+ import { handleTabSwitchKey } from "./selector-helpers";
30
+
31
+ const OTHER_OPTION = "Other (type your own)";
32
+ const SUBMIT_OPTION = "Submit";
33
+
34
+ /** Fraction of the terminal the dialog may occupy. The box height is fixed
35
+ * at spawn from the tallest tab's content (re-measured only on viewport
36
+ * resize) and clamped to this ratio; it rises from the bottom as a stable
37
+ * panel that never resizes on tab switches or cursor moves. */
38
+ const DIALOG_HEIGHT_RATIO = 0.7;
39
+ const MIN_DIALOG_ROWS = 12;
40
+ const MIN_BODY_ROWS = 5;
41
+ const PREVIEW_MIN_WIDTH = 40;
42
+ const SIDE_BY_SIDE_LIST_MIN_WIDTH = 30;
43
+ const SIDE_BY_SIDE_GAP_WIDTH = 3;
44
+ const MAX_HEADER_CHIP_WIDTH = 16;
45
+ /** Maximum number of title lines shown in the prompt editor overlay, so a
46
+ * long or multi-line question cannot push the input row off-screen. Mirrors
47
+ * the bounded-title pattern from the legacy ask path without its option-window
48
+ * coupling. */
49
+ const MAX_PROMPT_TITLE_ROWS = 3;
50
+ /** Border (2) + padX (2) columns consumed by the HookEditor chrome. */
51
+ const PROMPT_TITLE_CHROME_COLUMNS = 4;
52
+ /** Maximum number of wrapped lines for an in-body question header, so a long
53
+ * or multi-line question cannot push the option list off-screen. Mirrors the
54
+ * row-cap pattern used by boundPromptTitle for the prompt editor overlay. */
55
+ const MAX_HEADER_ROWS = 4;
56
+
57
+ function promptTitleContentWidth(): number {
58
+ const cols = process.stdout.columns ?? 80;
59
+ return Math.max(1, cols - PROMPT_TITLE_CHROME_COLUMNS);
60
+ }
61
+
62
+ /** Bound a prompt editor title to a fixed row/width budget so long or
63
+ * multi-line questions stay usable inside the small prompt overlay. */
64
+ export function boundPromptTitle(prefix: string, question: string): string {
65
+ const width = promptTitleContentWidth();
66
+ const flat = normalizedInlineInput(`${prefix}${question}`);
67
+ const wrapped = wrapTextWithAnsi(flat, width);
68
+ if (wrapped.length <= MAX_PROMPT_TITLE_ROWS) return wrapped.join("\n");
69
+ const kept = wrapped.slice(0, MAX_PROMPT_TITLE_ROWS - 1);
70
+ const last = truncateToWidth(wrapped[MAX_PROMPT_TITLE_ROWS - 1] ?? "", width, Ellipsis.Unicode);
71
+ return [...kept, last].join("\n");
72
+ }
73
+
74
+ interface AskDialogCallbacks {
75
+ onSubmit(result: ExtensionAskDialogSubmitResult): void;
76
+ onCancel(): void;
77
+ onPrompt(title: string, prefill?: string): Promise<string | undefined>;
78
+ }
79
+
80
+ interface AskDialogOptions {
81
+ timeout?: number;
82
+ onTimeout?: () => void;
83
+ tui?: TUI;
84
+ }
85
+
86
+ interface QuestionState {
87
+ selectedOptions: Set<string>;
88
+ customInput: string | undefined;
89
+ note: string | undefined;
90
+ noteRowKey: string | undefined;
91
+ cursorIndex: number;
92
+ scrollOffset: number;
93
+ timedOut: boolean;
94
+ }
95
+
96
+ type QuestionRowKind = "option" | "other";
97
+
98
+ interface QuestionRow {
99
+ kind: QuestionRowKind;
100
+ key: string;
101
+ label: string;
102
+ optionIndex: number | undefined;
103
+ }
104
+
105
+ interface RenderedList {
106
+ lines: string[];
107
+ scrollOffset: number;
108
+ indicator: string;
109
+ }
110
+
111
+ interface PreviewSegment {
112
+ kind: "markdown" | "code";
113
+ text: string;
114
+ language: string | undefined;
115
+ }
116
+
117
+ function clamp(value: number, min: number, max: number): number {
118
+ return Math.max(min, Math.min(value, max));
119
+ }
120
+
121
+ function stripRecommendedSuffix(label: string): string {
122
+ const suffix = " (Recommended)";
123
+ return label.endsWith(suffix) ? label.slice(0, -suffix.length) : label;
124
+ }
125
+
126
+ function questionTabLabel(question: ExtensionAskDialogQuestion, index: number): string {
127
+ const base = question.header?.trim() || question.id || `Q${index + 1}`;
128
+ return truncateToWidth(replaceTabs(base), MAX_HEADER_CHIP_WIDTH, Ellipsis.Unicode);
129
+ }
130
+
131
+ function renderQuestionTitle(question: ExtensionAskDialogQuestion, width: number): string[] {
132
+ const mdTheme = getMarkdownTheme();
133
+ const questionText = renderInlineMarkdown(replaceTabs(question.question), mdTheme, t => theme.fg("text", t));
134
+ const wrapped = wrapTextWithAnsi(questionText, Math.max(1, width));
135
+ if (wrapped.length <= MAX_HEADER_ROWS) return wrapped;
136
+ return [
137
+ ...wrapped.slice(0, MAX_HEADER_ROWS - 1),
138
+ truncateToWidth(wrapped.slice(MAX_HEADER_ROWS - 1).join(" "), Math.max(1, width), Ellipsis.Unicode),
139
+ ];
140
+ }
141
+
142
+ function splitPreviewSegments(preview: string): PreviewSegment[] {
143
+ const segments: PreviewSegment[] = [];
144
+ const markdownBuffer: string[] = [];
145
+ let fenceChar: string | undefined;
146
+ let fenceLength = 0;
147
+ let fenceLanguage: string | undefined;
148
+ let codeBuffer: string[] = [];
149
+
150
+ const flushMarkdown = (): void => {
151
+ if (markdownBuffer.length === 0) return;
152
+ segments.push({ kind: "markdown", text: markdownBuffer.join("\n"), language: undefined });
153
+ markdownBuffer.length = 0;
154
+ };
155
+ const flushCode = (): void => {
156
+ segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
157
+ codeBuffer = [];
158
+ fenceChar = undefined;
159
+ fenceLength = 0;
160
+ fenceLanguage = undefined;
161
+ };
162
+
163
+ for (const line of replaceTabs(preview).split("\n")) {
164
+ const fenceMatch = /^(\s{0,3})(`{3,}|~{3,})(.*)$/.exec(line);
165
+ if (fenceChar !== undefined) {
166
+ if (fenceMatch) {
167
+ const marker = fenceMatch[2] ?? "";
168
+ const info = fenceMatch[3]?.trim() ?? "";
169
+ if (marker.startsWith(fenceChar) && marker.length >= fenceLength && info === "") {
170
+ flushCode();
171
+ continue;
172
+ }
173
+ }
174
+ codeBuffer.push(line);
175
+ continue;
176
+ }
177
+ if (fenceMatch) {
178
+ flushMarkdown();
179
+ const marker = fenceMatch[2] ?? "";
180
+ fenceChar = marker[0];
181
+ fenceLength = marker.length;
182
+ fenceLanguage = fenceMatch[3]?.trim().split(/\s+/, 1)[0] || undefined;
183
+ codeBuffer = [];
184
+ continue;
185
+ }
186
+ markdownBuffer.push(line);
187
+ }
188
+
189
+ if (fenceChar !== undefined) {
190
+ segments.push({ kind: "code", text: codeBuffer.join("\n"), language: fenceLanguage });
191
+ } else {
192
+ flushMarkdown();
193
+ }
194
+ return segments;
195
+ }
196
+
197
+ function renderPreviewContent(preview: string, width: number): string[] {
198
+ const out: string[] = [];
199
+ const mdTheme = getMarkdownTheme();
200
+ const accentStyle = { color: (text: string) => theme.fg("muted", text) };
201
+ for (const segment of splitPreviewSegments(preview)) {
202
+ if (segment.kind === "code") {
203
+ const highlighted = highlightCode(segment.text, segment.language);
204
+ const text = new Text(highlighted.join("\n"), 0, 0);
205
+ out.push(...text.render(Math.max(1, width)));
206
+ continue;
207
+ }
208
+ const markdown = new Markdown(segment.text, 0, 0, mdTheme, accentStyle);
209
+ out.push(...markdown.render(Math.max(1, width)));
210
+ }
211
+ return out;
212
+ }
213
+
214
+ function normalizedInlineInput(input: string): string {
215
+ return replaceTabs(input).replace(/\s+/g, " ").trim();
216
+ }
217
+
218
+ function renderAnswerSummary(question: ExtensionAskDialogQuestion, state: QuestionState): string {
219
+ const selected = question.options.map(option => option.label).filter(label => state.selectedOptions.has(label));
220
+ if (question.multi) {
221
+ const answers = [...selected];
222
+ if (state.customInput !== undefined) answers.push(`Other: “${normalizedInlineInput(state.customInput)}”`);
223
+ return answers.length > 0 ? answers.join(", ") : theme.fg("warning", "unanswered");
224
+ }
225
+ if (state.customInput !== undefined) return `“${normalizedInlineInput(state.customInput)}”`;
226
+ if (selected.length === 0) return theme.fg("warning", "unanswered");
227
+ return selected[0] ?? theme.fg("warning", "unanswered");
228
+ }
229
+
230
+ function clearNote(state: QuestionState): void {
231
+ state.note = undefined;
232
+ state.noteRowKey = undefined;
233
+ }
234
+
235
+ function clearNoteIfRow(state: QuestionState, rowKey: string): void {
236
+ if (state.noteRowKey === rowKey) clearNote(state);
237
+ }
238
+
239
+ function clearNoteUnlessRow(state: QuestionState, rowKey: string): void {
240
+ if (state.noteRowKey !== undefined && state.noteRowKey !== rowKey) clearNote(state);
241
+ }
242
+
243
+ function noteForSubmittedAnswer(question: ExtensionAskDialogQuestion, state: QuestionState): string | undefined {
244
+ if (state.note === undefined || state.noteRowKey === undefined) return undefined;
245
+ if (state.noteRowKey === "other") return state.customInput !== undefined ? state.note : undefined;
246
+ const match = /^option:(\d+)$/.exec(state.noteRowKey);
247
+ const optionIndex = match?.[1] === undefined ? Number.NaN : Number.parseInt(match[1], 10);
248
+ const option = Number.isInteger(optionIndex) ? question.options[optionIndex] : undefined;
249
+ return option && state.selectedOptions.has(option.label) ? state.note : undefined;
250
+ }
251
+
252
+ function optionMarker(question: ExtensionAskDialogQuestion, checked: boolean): string {
253
+ if (question.multi) return checked ? theme.checkbox.checked : theme.checkbox.unchecked;
254
+ return checked ? theme.radio.selected : theme.radio.unselected;
255
+ }
256
+
257
+ function renderRowLabel(
258
+ rowItem: QuestionRow,
259
+ question: ExtensionAskDialogQuestion,
260
+ state: QuestionState,
261
+ selected: boolean,
262
+ mdTheme: MarkdownTheme,
263
+ width: number,
264
+ ): string[] {
265
+ const isOption = rowItem.kind === "option";
266
+ const isOther = rowItem.kind === "other";
267
+ const checked = isOption
268
+ ? state.selectedOptions.has(stripRecommendedSuffix(rowItem.label))
269
+ : isOther && state.customInput !== undefined;
270
+ const color = selected ? "accent" : checked ? "toolOutput" : "text";
271
+ const marker = `${theme.fg(checked ? "success" : "dim", optionMarker(question, checked))} `;
272
+ const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : " ";
273
+ const label = renderInlineMarkdown(rowItem.label, mdTheme, t => theme.fg(color, t));
274
+ const noteMarker = state.note && state.noteRowKey === rowItem.key ? theme.fg("success", " ✎ note") : "";
275
+ const firstLine = `${cursor}${marker}${label}${noteMarker}`;
276
+ const lines = [truncateToWidth(firstLine, width, Ellipsis.Unicode)];
277
+ if (rowItem.kind === "option") {
278
+ const option = question.options[rowItem.optionIndex ?? -1];
279
+ if (option?.description?.trim()) {
280
+ const description = renderInlineMarkdown(option.description.trim(), mdTheme, t => theme.fg("muted", t));
281
+ const wrapped = wrapTextWithAnsi(description, Math.max(1, width - 6));
282
+ for (const line of wrapped.slice(0, 2)) {
283
+ lines.push(` ${truncateToWidth(line, Math.max(1, width - 6), Ellipsis.Unicode)}`);
284
+ }
285
+ }
286
+ }
287
+ if (isOther && state.customInput !== undefined) {
288
+ const preview = replaceTabs(state.customInput).replace(/\s+/g, " ").trim();
289
+ lines.push(theme.fg("muted", ` ${truncateToWidth(preview, Math.max(1, width - 6), Ellipsis.Unicode)}`));
290
+ }
291
+ return lines;
292
+ }
293
+
294
+ export class AskDialogComponent implements Component {
295
+ #states: QuestionState[];
296
+ #activeTabIndex = 0;
297
+ #submitScrollOffset = 0;
298
+ #remainingSeconds: number | undefined;
299
+ #countdown: CountdownTimer | undefined;
300
+ #promptActive = false;
301
+ #timeoutExpired = false;
302
+ #closed = false;
303
+ #tabBar: TabBar | undefined;
304
+ #stableHeight: { key: string; total: number } | undefined;
305
+
306
+ constructor(
307
+ private readonly questions: ExtensionAskDialogQuestion[],
308
+ private readonly callbacks: AskDialogCallbacks,
309
+ private readonly options: AskDialogOptions = {},
310
+ ) {
311
+ this.#states = questions.map(question => {
312
+ const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
313
+ const maxIndex = Math.max(0, question.options.length - 1);
314
+ return {
315
+ selectedOptions: new Set<string>(),
316
+ customInput: undefined,
317
+ note: undefined,
318
+ noteRowKey: undefined,
319
+ cursorIndex: clamp(recommended ?? 0, 0, maxIndex),
320
+ scrollOffset: 0,
321
+ timedOut: false,
322
+ };
323
+ });
324
+ if (options.timeout && options.timeout > 0) {
325
+ this.#countdown = new CountdownTimer(
326
+ options.timeout,
327
+ options.tui,
328
+ seconds => {
329
+ this.#remainingSeconds = seconds;
330
+ },
331
+ () => this.#handleTimeout(),
332
+ );
333
+ }
334
+ }
335
+
336
+ invalidate(): void {
337
+ this.#stableHeight = undefined;
338
+ this.#tabBar?.invalidate();
339
+ }
340
+
341
+ dispose(): void {
342
+ this.#closed = true;
343
+ this.#countdown?.dispose();
344
+ }
345
+
346
+ handleInput(keyData: string): void {
347
+ if (this.#closed || this.#promptActive) return;
348
+ // Reset the inactivity countdown on any key that reaches past the
349
+ // closed/prompt guards, matching HookSelector/HookInput semantics.
350
+ this.#countdown?.reset();
351
+ if (matchesSelectCancel(keyData)) {
352
+ this.#finishCancel();
353
+ return;
354
+ }
355
+ if (this.#hasSubmitTab() && handleTabSwitchKey(keyData, direction => this.#switchTab(direction))) {
356
+ this.#requestRender();
357
+ return;
358
+ }
359
+ if (this.#isSubmitTab()) {
360
+ this.#handleSubmitTabInput(keyData);
361
+ return;
362
+ }
363
+ this.#handleQuestionInput(keyData);
364
+ }
365
+
366
+ render(width: number): readonly string[] {
367
+ const innerWidth = Math.max(1, width - 4);
368
+ // Fixed panel height: measured from the tallest tab at spawn and
369
+ // re-measured only when the viewport changes. Tab switches, cursor
370
+ // moves, and later answers never resize the box; content that
371
+ // outgrows it scrolls.
372
+ const totalRows = this.#dialogHeight(innerWidth, process.stdout.rows || 40);
373
+ const headerLines = this.#renderHeader(innerWidth);
374
+ // topBorder(1) + header(N) + divider(1) + divider(1) + footer(1) +
375
+ // bottomBorder(1) = N + 5 fixed rows outside the body. Without the
376
+ // bottomBorder term the dialog overflowed the viewport by one row
377
+ // (PRRT_kwDOQxs0bc6OFbDY).
378
+ const fixedRows = 1 + headerLines.length + 1 + 1 + 1 + 1;
379
+ const bodyRows = Math.max(MIN_BODY_ROWS, totalRows - fixedRows);
380
+ const bodyLines = this.#isSubmitTab()
381
+ ? this.#renderSubmitBody(innerWidth, bodyRows)
382
+ : this.#renderQuestionBody(innerWidth, bodyRows);
383
+ const footer = this.#footerHintText(bodyLines.indicator);
384
+ return [
385
+ topBorder(width, this.#titleText()),
386
+ ...headerLines.map(line => row(line, width)),
387
+ divider(width),
388
+ ...bodyLines.lines.map(line => row(line, width)),
389
+ divider(width),
390
+ row(theme.fg("dim", footer), width),
391
+ bottomBorder(width),
392
+ ];
393
+ }
394
+
395
+ #dialogHeight(width: number, termRows: number): number {
396
+ const key = `${width}:${termRows}`;
397
+ if (this.#stableHeight?.key === key) return this.#stableHeight.total;
398
+ const total = this.#measureHeight(width, termRows);
399
+ this.#stableHeight = { key, total };
400
+ return total;
401
+ }
402
+
403
+ /** Measure the tallest tab's natural content height, clamped to
404
+ * DIALOG_HEIGHT_RATIO of the terminal. Derived from questions and
405
+ * viewport only — never from cursor, tab, or answer state — so the box
406
+ * size is stable for the dialog's lifetime at a given terminal size. */
407
+ #measureHeight(width: number, termRows: number): number {
408
+ const maxHeight = Math.max(MIN_DIALOG_ROWS, Math.floor(termRows * DIALOG_HEIGHT_RATIO));
409
+ const chrome = 5; // topBorder + divider + divider + footer + bottomBorder
410
+ const tabBarRows = this.#hasSubmitTab() ? 1 : 0;
411
+ const mdTheme = getMarkdownTheme();
412
+ let needed = MIN_DIALOG_ROWS;
413
+ for (let index = 0; index < this.questions.length; index++) {
414
+ const question = this.questions[index];
415
+ const state = this.#states[index];
416
+ if (!question || !state) continue;
417
+ const headerRows = tabBarRows + renderQuestionTitle(question, width).length;
418
+ const rowItems = this.#questionRows(question);
419
+ const listRows = (listWidth: number): number => {
420
+ let total = 0;
421
+ for (const rowItem of rowItems) {
422
+ total += renderRowLabel(rowItem, question, state, false, mdTheme, listWidth).length;
423
+ }
424
+ return total;
425
+ };
426
+ let body = listRows(width);
427
+ const previews = question.options.filter(option => option.preview?.trim());
428
+ const sideBySide = width >= SIDE_BY_SIDE_LIST_MIN_WIDTH + PREVIEW_MIN_WIDTH + SIDE_BY_SIDE_GAP_WIDTH;
429
+ if (previews.length > 0 && sideBySide) {
430
+ const previewWidth = Math.max(PREVIEW_MIN_WIDTH, Math.floor(width * 0.45));
431
+ const listWidth = Math.max(1, width - previewWidth - SIDE_BY_SIDE_GAP_WIDTH);
432
+ let pane = 0;
433
+ for (const option of previews) {
434
+ pane = Math.max(pane, renderPreviewContent(option.preview ?? "", Math.max(1, previewWidth - 2)).length);
435
+ }
436
+ body = Math.max(body, listRows(listWidth), pane);
437
+ }
438
+ needed = Math.max(needed, chrome + headerRows + Math.max(MIN_BODY_ROWS, body));
439
+ }
440
+ if (this.#hasSubmitTab()) {
441
+ // Warning line + blank, one summary line per question, blank, and
442
+ // the Submit row; note lines added later scroll within the body.
443
+ const body = 2 + this.questions.length + 2;
444
+ needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
445
+ }
446
+ return Math.min(needed, maxHeight);
447
+ }
448
+
449
+ #titleText(): string {
450
+ return this.#remainingSeconds === undefined ? "Ask" : `Ask (${this.#remainingSeconds}s)`;
451
+ }
452
+
453
+ #hasSubmitTab(): boolean {
454
+ // Multi questions confirm on the Submit tab (Enter toggles, never
455
+ // submits), so any multi question forces the tab even when there is
456
+ // only one question.
457
+ return this.questions.length > 1 || this.questions.some(question => question.multi);
458
+ }
459
+
460
+ #submitTabIndex(): number {
461
+ return this.questions.length;
462
+ }
463
+
464
+ #isSubmitTab(): boolean {
465
+ return this.#hasSubmitTab() && this.#activeTabIndex === this.#submitTabIndex();
466
+ }
467
+
468
+ #currentQuestionIndex(): number {
469
+ return clamp(this.#activeTabIndex, 0, Math.max(0, this.questions.length - 1));
470
+ }
471
+
472
+ #requestRender(): void {
473
+ this.options.tui?.requestRender();
474
+ }
475
+
476
+ #renderHeader(width: number): string[] {
477
+ const lines: string[] = [];
478
+ if (this.#hasSubmitTab()) {
479
+ const tabs: Tab[] = [
480
+ ...this.questions.map((question, index) => ({
481
+ id: String(index),
482
+ label: questionTabLabel(question, index),
483
+ })),
484
+ { id: "submit", label: "Submit" },
485
+ ];
486
+ this.#tabBar = new TabBar("", tabs, getTabBarTheme(), this.#activeTabIndex);
487
+ this.#tabBar.showHint = false;
488
+ lines.push(...this.#tabBar.render(width));
489
+ }
490
+ if (this.#isSubmitTab()) {
491
+ lines.push(theme.bold(theme.fg("accent", "Review answers")));
492
+ return lines;
493
+ }
494
+ const questionIndex = this.#currentQuestionIndex();
495
+ const question = this.questions[questionIndex];
496
+ if (!question) return lines;
497
+ lines.push(...renderQuestionTitle(question, width));
498
+ return lines;
499
+ }
500
+
501
+ #footerHintText(indicator: string): string {
502
+ const scroll = indicator ? ` ${indicator} scroll ·` : "";
503
+ if (this.#isSubmitTab()) {
504
+ return `Enter submit · ↑/↓ scroll ·${scroll} Esc cancel`;
505
+ }
506
+ const question = this.questions[this.#currentQuestionIndex()];
507
+ const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
508
+ const tabs = this.#hasSubmitTab() ? " · Tab/←/→ tabs" : "";
509
+ return `${action} · ↑/↓ move${tabs} ·${scroll} Esc cancel`;
510
+ }
511
+
512
+ #questionRows(question: ExtensionAskDialogQuestion): QuestionRow[] {
513
+ const rows: QuestionRow[] = question.options.map((option, index) => ({
514
+ kind: "option",
515
+ key: `option:${index}`,
516
+ label: this.#optionLabel(question, option.label, index),
517
+ optionIndex: index,
518
+ }));
519
+ rows.push({ kind: "other", key: "other", label: OTHER_OPTION, optionIndex: undefined });
520
+ return rows;
521
+ }
522
+
523
+ #optionLabel(question: ExtensionAskDialogQuestion, label: string, index: number): string {
524
+ return question.recommended === index ? `${label} (Recommended)` : label;
525
+ }
526
+
527
+ #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
528
+ const question = this.questions[this.#currentQuestionIndex()];
529
+ const state = this.#states[this.#currentQuestionIndex()];
530
+ if (!question || !state) return undefined;
531
+ return { question, state };
532
+ }
533
+
534
+ #handleQuestionInput(keyData: string): void {
535
+ const active = this.#activeQuestionState();
536
+ if (!active) return;
537
+ const { question, state } = active;
538
+ const rows = this.#questionRows(question);
539
+ if (matchesSelectUp(keyData)) {
540
+ state.cursorIndex = clamp(state.cursorIndex - 1, 0, Math.max(0, rows.length - 1));
541
+ this.#requestRender();
542
+ return;
543
+ }
544
+ if (matchesSelectDown(keyData)) {
545
+ state.cursorIndex = clamp(state.cursorIndex + 1, 0, Math.max(0, rows.length - 1));
546
+ this.#requestRender();
547
+ return;
548
+ }
549
+ const rowItem = rows[state.cursorIndex];
550
+ if (!rowItem) return;
551
+ if (keyData === "n" || keyData === "N") {
552
+ if (rowItem.kind === "option" || rowItem.kind === "other") {
553
+ void this.#promptForNote(question, state, rowItem);
554
+ }
555
+ return;
556
+ }
557
+ const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
558
+ const isSpace = matchesKey(keyData, "space") || keyData === " ";
559
+ if (!isEnter && !isSpace) return;
560
+ if (rowItem.kind === "other") {
561
+ void this.#promptForCustomInput(question, state, rowItem);
562
+ return;
563
+ }
564
+ const option = question.options[rowItem.optionIndex ?? -1];
565
+ if (!option) return;
566
+ if (question.multi) {
567
+ // Multi is toggle-only: Enter and Space both toggle, and the
568
+ // answer is confirmed from the Submit tab.
569
+ if (state.selectedOptions.has(option.label)) {
570
+ state.selectedOptions.delete(option.label);
571
+ clearNoteIfRow(state, rowItem.key);
572
+ } else {
573
+ state.selectedOptions.add(option.label);
574
+ }
575
+ this.#requestRender();
576
+ return;
577
+ }
578
+ state.selectedOptions = new Set([option.label]);
579
+ state.customInput = undefined;
580
+ clearNoteUnlessRow(state, rowItem.key);
581
+ this.#advanceAfterQuestion();
582
+ }
583
+
584
+ #handleSubmitTabInput(keyData: string): void {
585
+ if (matchesSelectUp(keyData)) {
586
+ this.#submitScrollOffset = Math.max(0, this.#submitScrollOffset - 1);
587
+ this.#requestRender();
588
+ return;
589
+ }
590
+ if (matchesSelectDown(keyData)) {
591
+ // Clamped against the rendered line count in #renderSubmitBody.
592
+ this.#submitScrollOffset += 1;
593
+ this.#requestRender();
594
+ return;
595
+ }
596
+ const isEnter = matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n";
597
+ if (isEnter) this.#finishSubmit();
598
+ }
599
+
600
+ #switchTab(direction: 1 | -1): void {
601
+ const tabCount = this.questions.length + 1;
602
+ this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
603
+ this.#submitScrollOffset = 0;
604
+ }
605
+
606
+ #advanceAfterQuestion(): void {
607
+ const current = this.#currentQuestionIndex();
608
+ if (this.questions.length === 1) {
609
+ this.#finishSubmit();
610
+ return;
611
+ }
612
+ this.#activeTabIndex = current + 1 < this.questions.length ? current + 1 : this.#submitTabIndex();
613
+ this.#submitScrollOffset = 0;
614
+ this.#requestRender();
615
+ }
616
+
617
+ async #promptForCustomInput(
618
+ question: ExtensionAskDialogQuestion,
619
+ state: QuestionState,
620
+ rowItem: QuestionRow,
621
+ ): Promise<void> {
622
+ this.#promptActive = true;
623
+ try {
624
+ const input = await this.callbacks.onPrompt(
625
+ boundPromptTitle("Custom answer: ", question.question),
626
+ state.customInput,
627
+ );
628
+ if (input === undefined || this.#closed) return;
629
+ if (input.trim() === "") {
630
+ // Submitting an empty value unselects the custom answer.
631
+ state.customInput = undefined;
632
+ clearNoteIfRow(state, rowItem.key);
633
+ return;
634
+ }
635
+ state.customInput = input;
636
+ if (!question.multi) {
637
+ state.selectedOptions.clear();
638
+ clearNoteUnlessRow(state, rowItem.key);
639
+ this.#advanceAfterQuestion();
640
+ }
641
+ } finally {
642
+ this.#promptActive = false;
643
+ this.#runDeferredTimeout();
644
+ this.#requestRender();
645
+ }
646
+ }
647
+
648
+ async #promptForNote(
649
+ question: ExtensionAskDialogQuestion,
650
+ state: QuestionState,
651
+ rowItem: QuestionRow,
652
+ ): Promise<void> {
653
+ this.#promptActive = true;
654
+ try {
655
+ const input = await this.callbacks.onPrompt(
656
+ boundPromptTitle(`Note for ${rowItem.label}: `, question.question),
657
+ state.noteRowKey === rowItem.key ? state.note : undefined,
658
+ );
659
+ if (input === undefined || this.#closed) return;
660
+ state.note = input;
661
+ state.noteRowKey = rowItem.key;
662
+ } finally {
663
+ this.#promptActive = false;
664
+ this.#runDeferredTimeout();
665
+ this.#requestRender();
666
+ }
667
+ }
668
+
669
+ #renderQuestionBody(width: number, maxRows: number): RenderedList {
670
+ const active = this.#activeQuestionState();
671
+ if (!active) return { lines: [], scrollOffset: 0, indicator: "" };
672
+ const { question, state } = active;
673
+ const rowItems = this.#questionRows(question);
674
+ state.cursorIndex = clamp(state.cursorIndex, 0, Math.max(0, rowItems.length - 1));
675
+ const selectedRow = rowItems[state.cursorIndex];
676
+ const preview =
677
+ selectedRow?.kind === "option" ? question.options[selectedRow.optionIndex ?? -1]?.preview : undefined;
678
+ // The preview pane exists only while the highlighted option carries a
679
+ // preview; otherwise the list takes the full dialog width.
680
+ if (!preview?.trim()) return this.#renderQuestionList(question, state, rowItems, width, maxRows);
681
+ const sideBySide = width >= SIDE_BY_SIDE_LIST_MIN_WIDTH + PREVIEW_MIN_WIDTH + SIDE_BY_SIDE_GAP_WIDTH;
682
+ if (sideBySide) {
683
+ const previewWidth = Math.max(PREVIEW_MIN_WIDTH, Math.floor(width * 0.45));
684
+ const listWidth = Math.max(1, width - previewWidth - SIDE_BY_SIDE_GAP_WIDTH);
685
+ const list = this.#renderQuestionList(question, state, rowItems, listWidth, maxRows);
686
+ const previewLines = this.#renderPreviewPane(preview, previewWidth, maxRows);
687
+ const lines: string[] = [];
688
+ for (let index = 0; index < maxRows; index++) {
689
+ const left = truncateToWidth(list.lines[index] ?? "", listWidth, Ellipsis.Unicode);
690
+ const right = truncateToWidth(previewLines[index] ?? "", previewWidth, Ellipsis.Unicode);
691
+ const gap = padding(Math.max(1, listWidth - visibleWidth(left)) + 1);
692
+ lines.push(`${left}${gap}${theme.fg("border", "│")} ${right}`);
693
+ }
694
+ return { lines, scrollOffset: list.scrollOffset, indicator: list.indicator };
695
+ }
696
+ const previewLines = this.#renderPreviewPane(preview, width, Math.max(3, Math.min(8, Math.floor(maxRows * 0.4))));
697
+ const listRows = Math.max(3, maxRows - previewLines.length - 1);
698
+ const list = this.#renderQuestionList(question, state, rowItems, width, listRows);
699
+ const lines = [...list.lines, theme.fg("border", "─".repeat(Math.max(1, width))), ...previewLines];
700
+ while (lines.length < maxRows) lines.push("");
701
+ return { lines: lines.slice(0, maxRows), scrollOffset: list.scrollOffset, indicator: list.indicator };
702
+ }
703
+
704
+ #renderQuestionList(
705
+ question: ExtensionAskDialogQuestion,
706
+ state: QuestionState,
707
+ rowItems: QuestionRow[],
708
+ width: number,
709
+ rows: number,
710
+ ): RenderedList {
711
+ const mdTheme = getMarkdownTheme();
712
+ const allLines: string[] = [];
713
+ const lineStartByRow: number[] = [];
714
+ for (let index = 0; index < rowItems.length; index++) {
715
+ lineStartByRow.push(allLines.length);
716
+ const rowItem = rowItems[index];
717
+ if (!rowItem) continue;
718
+ allLines.push(...renderRowLabel(rowItem, question, state, index === state.cursorIndex, mdTheme, width));
719
+ }
720
+ const cursorStart = lineStartByRow[state.cursorIndex] ?? 0;
721
+ state.scrollOffset = this.#scrollOffsetForCursor(state.scrollOffset, cursorStart, rows, allLines.length);
722
+ const scrollView = new ScrollView(allLines, {
723
+ height: rows,
724
+ scrollbar: "auto",
725
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
726
+ });
727
+ scrollView.setScrollOffset(state.scrollOffset);
728
+ const lines = [...scrollView.render(width)];
729
+ while (lines.length < rows) lines.push("");
730
+ return {
731
+ lines: lines.slice(0, rows),
732
+ scrollOffset: state.scrollOffset,
733
+ indicator: this.#clipIndicator(state.scrollOffset, rows, allLines.length),
734
+ };
735
+ }
736
+
737
+ #renderPreviewPane(preview: string, width: number, maxRows: number): string[] {
738
+ const bodyWidth = Math.max(1, width - 2);
739
+ const content = renderPreviewContent(preview, bodyWidth);
740
+ if (content.length <= maxRows) return content;
741
+ const visibleCount = Math.max(1, maxRows - 1);
742
+ const hidden = content.length - visibleCount;
743
+ return [...content.slice(0, visibleCount), theme.fg("dim", `… ${hidden} more lines`)];
744
+ }
745
+
746
+ #renderSubmitBody(width: number, rows: number): RenderedList {
747
+ const allLines: string[] = [];
748
+ const unanswered = this.#unansweredCount();
749
+ if (unanswered > 0) {
750
+ allLines.push(
751
+ theme.fg(
752
+ "warning",
753
+ `${unanswered} unanswered question${unanswered === 1 ? "" : "s"}; Enter still submits.`,
754
+ ),
755
+ );
756
+ allLines.push("");
757
+ }
758
+ for (let index = 0; index < this.questions.length; index++) {
759
+ const question = this.questions[index];
760
+ const state = this.#states[index];
761
+ if (!question || !state) continue;
762
+ const label = questionTabLabel(question, index);
763
+ const answer = renderAnswerSummary(question, state);
764
+ allLines.push(`${theme.fg("dim", `${index + 1}. ${label}:`)} ${answer}`);
765
+ const submittedNote = noteForSubmittedAnswer(question, state);
766
+ if (submittedNote?.trim()) {
767
+ const note = normalizedInlineInput(submittedNote);
768
+ allLines.push(
769
+ theme.fg("muted", ` Note: ${truncateToWidth(note, Math.max(1, width - 9), Ellipsis.Unicode)}`),
770
+ );
771
+ }
772
+ }
773
+ allLines.push("");
774
+ allLines.push(theme.fg("accent", `${theme.nav.cursor} ${SUBMIT_OPTION}`));
775
+ this.#submitScrollOffset = clamp(this.#submitScrollOffset, 0, Math.max(0, allLines.length - rows));
776
+ const scrollView = new ScrollView(allLines, {
777
+ height: rows,
778
+ scrollbar: "auto",
779
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
780
+ });
781
+ scrollView.setScrollOffset(this.#submitScrollOffset);
782
+ const rendered = scrollView.render(width);
783
+ const lines = [...rendered];
784
+ while (lines.length < rows) lines.push("");
785
+ return {
786
+ lines: lines.slice(0, rows),
787
+ scrollOffset: this.#submitScrollOffset,
788
+ indicator: this.#clipIndicator(this.#submitScrollOffset, rows, allLines.length),
789
+ };
790
+ }
791
+
792
+ #scrollOffsetForCursor(currentOffset: number, cursorLine: number, rows: number, totalRows: number): number {
793
+ if (totalRows <= rows) return 0;
794
+ let nextOffset = clamp(currentOffset, 0, Math.max(0, totalRows - rows));
795
+ if (cursorLine < nextOffset) nextOffset = cursorLine;
796
+ if (cursorLine >= nextOffset + rows) nextOffset = cursorLine - rows + 1;
797
+ return clamp(nextOffset, 0, Math.max(0, totalRows - rows));
798
+ }
799
+
800
+ #clipIndicator(offset: number, rows: number, totalRows: number): string {
801
+ const above = offset > 0;
802
+ const below = offset + rows < totalRows;
803
+ if (above && below) return "↕";
804
+ if (above) return "↑";
805
+ if (below) return "↓";
806
+ return "";
807
+ }
808
+
809
+ #unansweredCount(): number {
810
+ let count = 0;
811
+ for (let index = 0; index < this.questions.length; index++) {
812
+ const question = this.questions[index];
813
+ const state = this.#states[index];
814
+ if (!question || !state) continue;
815
+ if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
816
+ }
817
+ return count;
818
+ }
819
+
820
+ #handleTimeout(): void {
821
+ if (this.#closed) return;
822
+ if (this.#promptActive) {
823
+ this.#timeoutExpired = true;
824
+ return;
825
+ }
826
+ this.options.onTimeout?.();
827
+ for (let index = 0; index < this.questions.length; index++) {
828
+ const question = this.questions[index];
829
+ const state = this.#states[index];
830
+ if (!question || !state) continue;
831
+ if (state.selectedOptions.size === 0 && state.customInput === undefined) {
832
+ const noteMatch = /^option:(\d+)$/.exec(state.noteRowKey ?? "");
833
+ const notedIndex = noteMatch ? Number.parseInt(noteMatch[1], 10) : Number.NaN;
834
+ const fallbackIndex =
835
+ Number.isInteger(notedIndex) && question.options[notedIndex]
836
+ ? notedIndex
837
+ : clamp(question.recommended ?? 0, 0, Math.max(0, question.options.length - 1));
838
+ const fallback = question.options[fallbackIndex];
839
+ if (fallback) state.selectedOptions.add(fallback.label);
840
+ state.timedOut = true;
841
+ }
842
+ }
843
+ this.#finishSubmit();
844
+ }
845
+
846
+ #runDeferredTimeout(): void {
847
+ if (!this.#timeoutExpired) return;
848
+ this.#timeoutExpired = false;
849
+ this.#handleTimeout();
850
+ }
851
+
852
+ #finishSubmit(): void {
853
+ if (this.#closed) return;
854
+ this.#closed = true;
855
+ this.#countdown?.dispose();
856
+ this.callbacks.onSubmit({ kind: "submit", results: this.#buildResults() });
857
+ }
858
+
859
+ #finishCancel(): void {
860
+ if (this.#closed) return;
861
+ this.#closed = true;
862
+ this.#countdown?.dispose();
863
+ this.callbacks.onCancel();
864
+ }
865
+
866
+ #buildResults(): ExtensionAskDialogResultItem[] {
867
+ const results: ExtensionAskDialogResultItem[] = [];
868
+ for (let index = 0; index < this.questions.length; index++) {
869
+ const question = this.questions[index];
870
+ const state = this.#states[index];
871
+ if (!question || !state) continue;
872
+ const selectedOptions = question.options
873
+ .map(option => option.label)
874
+ .filter(label => state.selectedOptions.has(label));
875
+ results.push({
876
+ id: question.id,
877
+ question: question.question,
878
+ options: question.options.map(option => option.label),
879
+ multi: question.multi ?? false,
880
+ selectedOptions,
881
+ customInput: state.customInput,
882
+ note: noteForSubmittedAnswer(question, state),
883
+ timedOut: state.timedOut || undefined,
884
+ });
885
+ }
886
+ return results;
887
+ }
888
+ }