@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/assets/badge-dark.svg +18 -0
  4. package/assets/badge-light.svg +18 -0
  5. package/docs/advanced-usage.md +106 -0
  6. package/docs/agent-flow-design.md +306 -0
  7. package/docs/agentic-runtime-prd.md +773 -0
  8. package/docs/architecture.md +368 -0
  9. package/docs/controls.md +140 -0
  10. package/docs/pi-autoresearch-survey.md +50 -0
  11. package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
  12. package/docs/prompt-caching.md +31 -0
  13. package/docs/unified-dashboard.md +286 -0
  14. package/extensions/auditor-selector.ts +74 -0
  15. package/extensions/goal-accounting.ts +107 -0
  16. package/extensions/goal-activity.ts +202 -0
  17. package/extensions/goal-auditor.ts +480 -0
  18. package/extensions/goal-commands.ts +866 -0
  19. package/extensions/goal-compaction.ts +197 -0
  20. package/extensions/goal-completion.ts +173 -0
  21. package/extensions/goal-context.ts +12 -0
  22. package/extensions/goal-contract.ts +58 -0
  23. package/extensions/goal-controls.ts +202 -0
  24. package/extensions/goal-core-tools.ts +549 -0
  25. package/extensions/goal-core.ts +78 -0
  26. package/extensions/goal-detail.ts +65 -0
  27. package/extensions/goal-dialog-controls.ts +31 -0
  28. package/extensions/goal-draft.ts +244 -0
  29. package/extensions/goal-drafting.ts +535 -0
  30. package/extensions/goal-events.ts +692 -0
  31. package/extensions/goal-format.ts +376 -0
  32. package/extensions/goal-interruption.ts +53 -0
  33. package/extensions/goal-ledger-index.ts +68 -0
  34. package/extensions/goal-ledger.ts +1007 -0
  35. package/extensions/goal-live-retention.ts +219 -0
  36. package/extensions/goal-oracle.ts +346 -0
  37. package/extensions/goal-policy.ts +370 -0
  38. package/extensions/goal-pool.ts +103 -0
  39. package/extensions/goal-prompt-cache.ts +80 -0
  40. package/extensions/goal-questionnaire.ts +1073 -0
  41. package/extensions/goal-record.ts +388 -0
  42. package/extensions/goal-recovery.ts +256 -0
  43. package/extensions/goal-runtime.ts +277 -0
  44. package/extensions/goal-scheduler-state.ts +100 -0
  45. package/extensions/goal-scheduler.ts +436 -0
  46. package/extensions/goal-service.ts +843 -0
  47. package/extensions/goal-session-health.ts +130 -0
  48. package/extensions/goal-session-safety.ts +65 -0
  49. package/extensions/goal-settings.ts +1370 -0
  50. package/extensions/goal-state.ts +1069 -0
  51. package/extensions/goal-status.ts +305 -0
  52. package/extensions/goal-task-confirmation.ts +174 -0
  53. package/extensions/goal-task-count.ts +59 -0
  54. package/extensions/goal-task-derive.ts +46 -0
  55. package/extensions/goal-task-index.ts +57 -0
  56. package/extensions/goal-task-tools.ts +595 -0
  57. package/extensions/goal-tool-inputs.ts +102 -0
  58. package/extensions/goal-tool-names.ts +73 -0
  59. package/extensions/goal-tools.ts +16 -0
  60. package/extensions/goal-widget.ts +455 -0
  61. package/extensions/goal.ts +42 -0
  62. package/extensions/network-error-backoff.ts +55 -0
  63. package/extensions/prompts/goal-prompts.ts +321 -0
  64. package/extensions/storage/atomic-rename.ts +52 -0
  65. package/extensions/storage/goal-files.ts +695 -0
  66. package/extensions/storage/goal-lock.ts +131 -0
  67. package/extensions/storage/goal-root.ts +95 -0
  68. package/extensions/widgets/auditor-dashboard-model.ts +174 -0
  69. package/extensions/widgets/dialog-scaffold.ts +26 -0
  70. package/extensions/widgets/goal-controls-dialog.ts +329 -0
  71. package/extensions/widgets/goal-dashboard-model.ts +530 -0
  72. package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
  73. package/extensions/widgets/goal-escape-dialog.ts +148 -0
  74. package/extensions/widgets/goal-notifications.ts +9 -0
  75. package/extensions/widgets/goal-widget.ts +723 -0
  76. package/extensions/widgets/task-list-overlay.ts +409 -0
  77. package/extensions/widgets/text-cache.ts +30 -0
  78. package/package.json +117 -4
  79. package/scripts/recover-session-checkpoints.mjs +289 -0
@@ -0,0 +1,1073 @@
1
+ import { goalDialogControls } from "./goal-dialog-controls.ts";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Editor, type EditorTheme, Text, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { truncateToWidth, wrapTextWithAnsi } from "./widgets/text-cache.ts";
5
+
6
+
7
+ export type GoalDraftingFocus = "goal" | "sisyphus";
8
+
9
+ export interface GoalQuestionnaireQuestion {
10
+ id: string;
11
+ question: string;
12
+ context?: string;
13
+ options: string[];
14
+ recommended?: number;
15
+ allowCustom?: boolean;
16
+ }
17
+
18
+ export interface GoalQuestionnaireAnswer {
19
+ id: string;
20
+ question: string;
21
+ answer: string;
22
+ wasCustom: boolean;
23
+ }
24
+
25
+ export interface GoalQuestionnaireResult {
26
+ questions: GoalQuestionnaireQuestion[];
27
+ answers: GoalQuestionnaireAnswer[];
28
+ cancelled: boolean;
29
+ auditorEnabled?: boolean;
30
+ /**
31
+ * The host could not present the dialog at all, so `cancelled` reflects an
32
+ * absent UI rather than a decision the user made. Hosts that expose a
33
+ * non-terminal UI keep `ctx.hasUI` true while `ctx.ui.custom` stays the
34
+ * SDK's headless default, which resolves to `undefined`.
35
+ */
36
+ unavailable?: boolean;
37
+ }
38
+
39
+ export type ProposalDecision = "confirm" | "continue" | "cancel";
40
+
41
+ /**
42
+ * Bound a custom dialog using the regular renderer's frame cache when
43
+ * available. When the fullscreen renderer does not expose that cache,
44
+ * reserve four rows for host chrome and use the remaining terminal height.
45
+ */
46
+ export function computeDialogLineLimit(args: { terminalRows?: number; baseFrameLines?: number }): number | undefined {
47
+ const rows = args.terminalRows;
48
+ if (!rows || rows <= 0) return undefined;
49
+ if (args.baseFrameLines && args.baseFrameLines > 0) {
50
+ return Math.min(rows, Math.max(10, rows - args.baseFrameLines + 1));
51
+ }
52
+ return Math.min(rows, Math.max(4, rows - 4));
53
+ }
54
+
55
+ /**
56
+ * Proposal-confirmation segment descriptor: absolute line indices of the
57
+ * tasks section (header + every task line) and the start of the options tail
58
+ * inside a rendered questionnaire dialog.
59
+ */
60
+ export interface ProposalPresentationSegments {
61
+ tasksStart: number;
62
+ tasksEnd: number;
63
+ tailStart: number;
64
+ }
65
+
66
+ /** Strip ANSI SGR sequences so structural scans see the plain text. */
67
+ function stripAnsiCodes(value: string): string {
68
+ // eslint-disable-next-line no-control-regex -- deliberate ANSI SGR escape-sequence matching
69
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
70
+ }
71
+
72
+ /**
73
+ * Locate the proposal tasks segment and the options tail in a rendered dialog.
74
+ * Returns null when the dialog is not a proposal-style confirmation (no
75
+ * "Tasks proposed for confirmation:" header / "┌─ TASKS ─" box).
76
+ *
77
+ * The scan is ANSI-aware: every rendered line carries `theme.fg(...)` escape
78
+ * sequences in the real TUI (the mock theme does not), so plain-text regexes
79
+ * must be run against the stripped line or the tasks section collapses to its
80
+ * header and every styled task line is sliced out of the bounded frame. The
81
+ * box-drawn bottom border (`└───┘`) after the last task line is kept so the
82
+ * `┌─ TASKS ─┐` box renders complete.
83
+ *
84
+ * The interactive auditor toggle line ("press 'a' to toggle") renders between
85
+ * the context and the options; `tailStart` is pulled back to it so the bounded
86
+ * fit always keeps the auditor status visible (its status text is the only
87
+ * visible toggle feedback in the dialog).
88
+ */
89
+ export function findProposalPresentationSegments(lines: string[], tailStart: number): ProposalPresentationSegments | null {
90
+ if (tailStart < 0) return null;
91
+ const tasksStart = lines.findIndex((l) => l.includes("Tasks proposed for confirmation:") || l.includes("┌─ TASKS ─"));
92
+ if (tasksStart < 0 || tasksStart >= tailStart) return null;
93
+ let tasksEnd = tasksStart;
94
+ for (let i = tasksStart + 1; i < tailStart; i++) {
95
+ const plain = stripAnsiCodes(lines[i]!);
96
+ if (/^\s*\[[ x~]\]/.test(plain)) {
97
+ tasksEnd = i;
98
+ continue;
99
+ }
100
+ // Box-drawn TASKS section bottom border after the last task line.
101
+ if (tasksEnd > tasksStart && /^[└┐┤┘─]/.test(plain.trim())) tasksEnd = i;
102
+ break;
103
+ }
104
+ // Protect the auditor toggle line: it renders between context and options,
105
+ // so pull the tail start back to it when present.
106
+ let effectiveTail = tailStart;
107
+ for (let i = tailStart - 1; i > tasksEnd; i--) {
108
+ if (/Auditor (enabled|disabled).*to toggle/.test(stripAnsiCodes(lines[i]!))) {
109
+ effectiveTail = i;
110
+ break;
111
+ }
112
+ }
113
+ return { tasksStart, tasksEnd, tailStart: effectiveTail };
114
+ }
115
+
116
+ /**
117
+ * Fit a rendered dialog to the terminal-height bound without ever hiding the
118
+ * question. The protected head (top border + tabs + question line) is always
119
+ * kept; the remaining budget is spent on the tail — options/footer/bottom
120
+ * border — so long context blocks (proposal confirmations) are sliced from
121
+ * their head exactly as the pre-fix tail-slice did (383ae52 surface). The
122
+ * footer hint and bottom border are always the last rendered lines. Never
123
+ * returns more than maxDialogLines.
124
+ *
125
+ * Proposal confirmations (proposal segments given) keep the head, the tasks
126
+ * section (header + every task line), and the options/footer/bottom border in
127
+ * frame; only the objective-box middle is sacrificed in-frame — the full
128
+ * objective is always present in the scrollable transcript presentation
129
+ * (propose_goal_draft renderCall), so nothing of the goal is ever omitted.
130
+ *
131
+ * §options-scroll: when a `scroll` state is given (select-mode question tabs
132
+ * and the submit summary), the dialog becomes a `less`-style viewport over the
133
+ * FULL content — nothing is truncated, nothing is sliced away — and the
134
+ * options are always reachable by scrolling (PageUp/PageDown, Ctrl+↑/↓, and
135
+ * ↑/↓ selection auto-follow). See fitDialogViewport.
136
+ */
137
+ export interface DialogScrollState {
138
+ /** Current viewport offset over the full content lines. */
139
+ scrollTop: number;
140
+ /** When true, the fitter nudges scrollTop so the selection range is visible. */
141
+ needsFollow: boolean;
142
+ /** Line ranges [start, end] of each selectable option in the full content. */
143
+ optionRanges: Array<[number, number]>;
144
+ /** Index of the current selection in optionRanges. */
145
+ followIndex: number;
146
+ }
147
+
148
+ export function fitDialogLines(
149
+ lines: string[],
150
+ maxDialogLines: number,
151
+ protectedHead: number,
152
+ proposal: ProposalPresentationSegments | null = null,
153
+ scroll: DialogScrollState | null = null,
154
+ dimStyle: (s: string) => string = (s) => s,
155
+ scrollHint = "PgUp/PgDn",
156
+ ): string[] {
157
+ if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
158
+ const keepHead = Math.min(protectedHead, maxDialogLines);
159
+ if (proposal) {
160
+ return fitProposalPresentation(lines, maxDialogLines, keepHead, proposal);
161
+ }
162
+ if (scroll) {
163
+ return fitDialogViewport(lines, maxDialogLines, scroll, dimStyle, scrollHint);
164
+ }
165
+ // Context-heavy / input / submit dialogs without a scroll state: keep the
166
+ // head and the tail (options/footer/bottom border) exactly as the pre-fix
167
+ // tail-slice did.
168
+ const budget = maxDialogLines - keepHead;
169
+ if (budget <= 0) return lines.slice(0, keepHead);
170
+ const rest = lines.slice(protectedHead);
171
+ if (rest.length <= budget) return [...lines.slice(0, keepHead), ...rest];
172
+ return [...lines.slice(0, keepHead), ...rest.slice(rest.length - budget)];
173
+ }
174
+
175
+ /**
176
+ * §options-scroll viewport fit: never truncate or drop content — window the
177
+ * full dialog lines with a scrollTop offset so every line (question, context,
178
+ * options, footer, border) stays reachable. The last viewport row is reserved
179
+ * as the bottom edge: the bottom border when the end is reached, otherwise a
180
+ * themed `… +N more · PgUp/PgDn scroll` indicator (mirrors the dashboard's
181
+ * `… +N more task` rows). A themed `▲ N more` indicator replaces the first
182
+ * content row when scrolled down. Selection auto-follow (needsFollow) nudges
183
+ * the window so the selected option's line range is inside it. Output length
184
+ * is exactly maxDialogLines when clipped, never more (churn-guard invariant).
185
+ */
186
+ function fitDialogViewport(
187
+ lines: string[],
188
+ maxDialogLines: number,
189
+ scroll: DialogScrollState,
190
+ dimStyle: (s: string) => string,
191
+ scrollHint: string,
192
+ ): string[] {
193
+ const contentWindow = Math.max(0, maxDialogLines - 1);
194
+ const contentEnd = Math.max(0, lines.length - 1);
195
+ // Selection auto-follow: nudge scrollTop so the selected option's range is
196
+ // inside the content window (minimal scroll; clamp below).
197
+ if (scroll.needsFollow && scroll.optionRanges[scroll.followIndex]) {
198
+ const [os, oe] = scroll.optionRanges[scroll.followIndex]!;
199
+ if (os < scroll.scrollTop || oe >= scroll.scrollTop + contentWindow) {
200
+ scroll.scrollTop = oe >= scroll.scrollTop + contentWindow
201
+ ? Math.min(os, Math.max(0, oe - contentWindow + 1))
202
+ : os;
203
+ }
204
+ scroll.needsFollow = false;
205
+ }
206
+ scroll.scrollTop = Math.max(0, Math.min(scroll.scrollTop, Math.max(0, contentEnd - contentWindow)));
207
+ const s = scroll.scrollTop;
208
+ const hiddenAbove = s;
209
+ const hiddenBelow = contentEnd - (s + contentWindow);
210
+ const viewport = lines.slice(s, s + contentWindow);
211
+ if (hiddenAbove > 0 && viewport.length > 0) {
212
+ viewport[0] = dimStyle(`▲ ${hiddenAbove} more`);
213
+ }
214
+ if (hiddenBelow > 0) {
215
+ // The reserved bottom edge row advertises the scroll affordance — it
216
+ // replaces the border while content is clipped below (mirrors the
217
+ // dashboard's `… +N more task` rows) and gives way to the border at the
218
+ // end. The footer hint sits directly above the border, so it is only
219
+ // visible when the end is reached (never while clipped — geometry).
220
+ viewport.push(dimStyle(`… +${hiddenBelow} more · ${scrollHint} scroll`));
221
+ } else {
222
+ viewport.push(lines[lines.length - 1]!);
223
+ }
224
+ return viewport;
225
+ }
226
+
227
+ /**
228
+ * Proposal confirmation fit: keep the protected head, the tasks section, the
229
+ * interactive auditor toggle line (when present), and the options/footer/
230
+ * bottom border; the objective-box middle is sacrificed in-frame (it stays
231
+ * fully readable in the scrollback presentation). Interior blank spacing lines
232
+ * are dropped first when room is short; task lines are only dropped after
233
+ * that, from the end, when the bound is exhausted (those lines remain in the
234
+ * scrollback presentation). Never exceeds maxDialogLines.
235
+ */
236
+ function fitProposalPresentation(
237
+ lines: string[],
238
+ maxDialogLines: number,
239
+ keepHead: number,
240
+ proposal: ProposalPresentationSegments,
241
+ ): string[] {
242
+ const { tasksStart, tasksEnd, tailStart } = proposal;
243
+ const head = lines.slice(0, keepHead);
244
+ const tasks = lines.slice(Math.max(tasksStart, keepHead), Math.min(tasksEnd, tailStart) + 1);
245
+ const tail = lines.slice(Math.max(tailStart, keepHead));
246
+ const candidate = [...head, ...tasks, ...tail];
247
+ if (candidate.length <= maxDialogLines) return candidate;
248
+ // Tight: drop blank spacing lines below the head (e.g. the blank between
249
+ // the options and the footer hint) before touching any content line.
250
+ const stripped = candidate.filter((l, i) => i < keepHead || l.trim() !== "");
251
+ if (stripped.length <= maxDialogLines) return stripped;
252
+ // Bound still exhausted: keep the head, then the auditor toggle line (the
253
+ // actionable status control — its feedback must stay visible), then spend
254
+ // the room on the tail (options/footer/bottom border — the decision
255
+ // surface — kept from its end so the border and footer never drop), then on
256
+ // the tasks from the start. The dropped task lines remain fully readable in
257
+ // the scrollback presentation. Never exceeds maxDialogLines.
258
+ const tailNoBlanks = tail.filter((l) => l.trim() !== "");
259
+ const hasAuditor = tail.length > 0 && /Auditor (enabled|disabled).*to toggle/.test(stripAnsiCodes(tail[0]!));
260
+ const restTail = hasAuditor ? tailNoBlanks.slice(1) : tailNoBlanks;
261
+ const keepRest = Math.min(restTail.length, Math.max(0, maxDialogLines - keepHead - (hasAuditor ? 1 : 0)));
262
+ const tailKept = [...(hasAuditor ? [tail[0]!] : []), ...restTail.slice(restTail.length - keepRest)];
263
+ const keepTasks = Math.min(tasks.length, Math.max(0, maxDialogLines - keepHead - tailKept.length));
264
+ return [...head, ...tasks.slice(0, keepTasks), ...tailKept];
265
+ }
266
+
267
+ export function normalizeQuestionnaireQuestions(rawQuestions: GoalQuestionnaireQuestion[]): GoalQuestionnaireQuestion[] {
268
+ const seenIds = new Set<string>();
269
+ return rawQuestions.map((q, i) => {
270
+ let id = q.id.trim() || `q${i + 1}`;
271
+ if (seenIds.has(id)) id = `${id}-${i + 1}`;
272
+ seenIds.add(id);
273
+ const options = q.options.filter((option) => option.trim().length > 0);
274
+ const recommended = q.recommended !== undefined && q.recommended >= 0 && q.recommended < options.length
275
+ ? q.recommended
276
+ : undefined;
277
+ return { ...q, id, options, recommended, allowCustom: q.allowCustom ?? true };
278
+ });
279
+ }
280
+
281
+ export function formatQuestionnaireAnswers(result: GoalQuestionnaireResult): string {
282
+ return result.answers.map((answer) => {
283
+ const question = result.questions.find((q) => q.id === answer.id);
284
+ const lines = [`**Q:** ${answer.question}`];
285
+ if (question?.context) lines.push(`\n${question.context}`);
286
+ if (question && question.options.length > 0) lines.push(`\nOptions: ${question.options.join(" / ")}`);
287
+ lines.push(`\n**A:** ${answer.answer}`);
288
+ return lines.join("");
289
+ }).join("\n\n---\n\n");
290
+ }
291
+
292
+ export function shouldAutoConfirmProposal(args: { hasUI: boolean; autoConfirmEnv?: string }): boolean {
293
+ if (args.autoConfirmEnv === "0") return false; // explicit opt-out (benchmarking)
294
+ return !args.hasUI || args.autoConfirmEnv === "1";
295
+ }
296
+
297
+ export function proposalDecisionFromQuestionnaireResult(args: { cancelled: boolean; answer?: string }): ProposalDecision {
298
+ if (args.cancelled) return "continue"; // never trapped; escape keeps refining
299
+ if ((args.answer ?? "").startsWith("Confirm")) return "confirm";
300
+ if ((args.answer ?? "").startsWith("Cancel")) return "cancel";
301
+ return "continue";
302
+ }
303
+
304
+ export function isHeadlessQuestionSufficientForDraft(args: { topic: string; questionText: string }): boolean {
305
+ const topic = args.topic.toLowerCase();
306
+ void args;
307
+ const vagueTopic = topic.trim().length < 20 || /(整理笔记|organize notes|notes|笔记)$/.test(topic.trim());
308
+ return !vagueTopic;
309
+ }
310
+
311
+ const CUSTOM_ANSWER_LABEL = "Write your own answer...";
312
+
313
+ /** Sequential native dialogs for hosts that cannot render terminal components. */
314
+ async function runQuestionnaireWithBasicDialogs(
315
+ ctx: ExtensionContext,
316
+ questions: GoalQuestionnaireQuestion[],
317
+ auditorToggleInit?: { defaultEnabled: boolean },
318
+ ): Promise<GoalQuestionnaireResult> {
319
+ const unavailable = (): GoalQuestionnaireResult => ({ questions: [], answers: [], cancelled: true, unavailable: true, ...(auditorToggleInit ? { auditorEnabled: auditorToggleInit.defaultEnabled } : {}) });
320
+ // Require only the primitives this questionnaire can actually use.
321
+ if ((auditorToggleInit || questions.some(q => q.options.length > 0)) && typeof ctx.ui.select !== "function") return unavailable();
322
+ if (questions.some(q => q.options.length === 0 || q.allowCustom !== false) && typeof ctx.ui.input !== "function") return unavailable();
323
+ let auditorEnabled = auditorToggleInit?.defaultEnabled;
324
+ const cancelled = (): GoalQuestionnaireResult => ({ questions, answers: [], cancelled: true, auditorEnabled });
325
+ if (auditorToggleInit) {
326
+ const enabled = "Enabled — require independent approval";
327
+ const disabled = "Disabled — skip the completion audit";
328
+ const choices = auditorEnabled ? [enabled, disabled] : [disabled, enabled];
329
+ const picked = await ctx.ui.select(`Completion auditor (currently ${auditorEnabled ? "enabled" : "disabled"})`, choices);
330
+ if (picked === undefined) return cancelled();
331
+ if (!choices.includes(picked)) throw new Error("The host returned an unknown auditor choice");
332
+ auditorEnabled = picked === enabled;
333
+ }
334
+ const answers: GoalQuestionnaireAnswer[] = [];
335
+ for (const question of questions) {
336
+ let prompt = question.context ? `${question.question}\n\n${question.context}` : question.question;
337
+ if (auditorToggleInit) prompt += `\n\nAuditor for this goal: ${auditorEnabled ? "enabled (independent approval required)" : "disabled (completion skips the audit)"}.`;
338
+ let answer: string | undefined;
339
+ let wasCustom = question.options.length === 0;
340
+ if (!wasCustom) {
341
+ // Numbered labels keep duplicate/reserved option text unambiguous.
342
+ const labels = question.options.map((option, i) => `${i + 1}. ${option}${question.recommended === i ? " (Recommended)" : ""}`);
343
+ const customLabel = `${labels.length + 1}. ${CUSTOM_ANSWER_LABEL}`;
344
+ const options = question.allowCustom === false ? labels : [...labels, customLabel];
345
+ const picked = await ctx.ui.select(prompt, options);
346
+ if (picked === undefined) return cancelled();
347
+ if (!options.includes(picked)) throw new Error("The host returned an unknown questionnaire choice");
348
+ wasCustom = picked === customLabel && question.allowCustom !== false;
349
+ if (!wasCustom) answer = question.options[labels.indexOf(picked)];
350
+ }
351
+ if (wasCustom) {
352
+ // Match the terminal editor: whitespace alone is not a submitted answer.
353
+ do {
354
+ answer = (await ctx.ui.input(prompt, "Write your answer"))?.trim();
355
+ } while (answer === "");
356
+ }
357
+ if (answer === undefined) return cancelled();
358
+ answers.push({ id: question.id, question: question.question, answer, wasCustom });
359
+ }
360
+ return { questions, answers, cancelled: false, auditorEnabled };
361
+ }
362
+
363
+ export const DIALOG_UNAVAILABLE_HINT = "This host cannot display the required goal-drafting dialogs. Use a host with select/input support or the pi TUI, or explicitly restart with PI_GOAL_AUTO_CONFIRM=1 to confirm proposals without a dialog.";
364
+
365
+ export function proposalDialogFailureMessage(error: unknown): string {
366
+ const detail = error instanceof Error ? error.message : String(error);
367
+ return `Goal draft confirmation failed: ${detail}. The goal was NOT created; drafting remains active.`;
368
+ }
369
+
370
+ /**
371
+ * Shared question UI used by both the agent-callable goal_questionnaire tool and
372
+ * the internal draft-confirm prompt. This keeps pi-goal self-contained and
373
+ * avoids depending on external question/questionnaire packages.
374
+ */
375
+ export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions: GoalQuestionnaireQuestion[], auditorToggleInit?: { defaultEnabled: boolean }): Promise<GoalQuestionnaireResult> {
376
+ if (!ctx.hasUI) {
377
+ return { questions: [], answers: [], cancelled: true };
378
+ }
379
+
380
+ const questions = normalizeQuestionnaireQuestions(rawQuestions);
381
+ // `mode` is not a capability signal: pi-web runs in "rpc" mode and still renders
382
+ // TUI components (headless TUI + line capture + key forwarding), so the rich
383
+ // single-dialog questionnaire works there. The host itself decides — the factory
384
+ // below bails out with done(undefined) when the TUI it passes lacks the required
385
+ // surface, and that path already falls back to one dialog per question (rpc mode
386
+ // additionally degrades on a thrown error — see the `showRichDialog()` call below).
387
+ if (typeof ctx.ui.custom !== "function") {
388
+ return runQuestionnaireWithBasicDialogs(ctx, questions, auditorToggleInit);
389
+ }
390
+ const isMulti = questions.length > 1;
391
+ const totalTabs = questions.length + 1;
392
+
393
+ const controls = goalDialogControls(typeof ctx.cwd === "string" ? ctx.cwd : process.cwd());
394
+ const showRichDialog = async () => await ctx.ui.custom<GoalQuestionnaireResult | undefined>((tui, theme, _kb, done) => {
395
+ // Some web hosts invoke the factory with a render callback in place of a TUI.
396
+ if (!tui || typeof tui.getShowHardwareCursor !== "function" || typeof tui.setShowHardwareCursor !== "function" || typeof tui.requestRender !== "function") {
397
+ done(undefined);
398
+ return { render: () => [], invalidate: () => {} };
399
+ }
400
+ // Suppress hardware cursor during dialog to reduce TUI auto-scroll
401
+ // (the TUI render loop runs at ~60fps and writes ANSI cursor positioning
402
+ // sequences every cycle, which can cause terminal viewport snapping).
403
+ const wasHardwareCursorShown = tui.getShowHardwareCursor();
404
+ tui.setShowHardwareCursor(false);
405
+ // Pause pi's working spinner for the dialog duration: its ~80ms
406
+ // re-renders write output while the user is scrolled up reading the
407
+ // proposal, which snaps the terminal viewport back to the bottom
408
+ // ("terminal scrolls back down after X seconds"). Restored on close.
409
+ ctx.ui.setWorkingVisible(false);
410
+ // Terminal-height bound: the dialog renders in the editor slot, so the opened
411
+ // frame height is (pre-dialog frame - 1) + dialog lines. Bound the dialog so
412
+ // the frame never exceeds the terminal height — without this, closing a dialog
413
+ // taller than the terminal triggers pi-tui's generic shrink full-render
414
+ // (\x1b[2J\x1b[H\x1b[3J), erasing terminal scrollback and yanking the viewport
415
+ // so the window takes ~10s to scroll back to the bottom. The slice keeps
416
+ // the question and the actionable options/footer in view (see
417
+ // fitDialogLines); content that fits renders exactly as the
418
+ // pre-regression (383ae52) UI. Only applies with real TUI dimensions.
419
+ const tuiInfo = tui as unknown as { terminal?: { rows?: number }; previousLines?: string[] };
420
+ const terminalRows = tuiInfo.terminal?.rows;
421
+ const baseFrame = tuiInfo.previousLines?.length;
422
+ const maxDialogLines = computeDialogLineLimit({ terminalRows, baseFrameLines: baseFrame });
423
+ let currentTab = 0;
424
+ let optionIndex = 0;
425
+ let inputMode = false;
426
+ let inputQuestionId: string | null = null;
427
+ // Focusable container state: the TUI sets `focused` when the dialog has
428
+ // focus; we propagate it to the embedded Editor while in input mode so
429
+ // the editor emits CURSOR_MARKER and the hardware cursor is positioned
430
+ // for IME input (pi docs/tui.md — Focusable Interface).
431
+ let dialogFocused = false;
432
+ let cachedLines: string[] | undefined;
433
+ let optionsStartIndex = -1;
434
+ // §options-scroll viewport state (select-mode + submit tabs only).
435
+ let scrollTop = 0;
436
+ let needsFollow = false;
437
+ let optionRanges: Array<[number, number]> = [];
438
+ let questionBlockEnd = -1;
439
+ let auditorEnabled = auditorToggleInit?.defaultEnabled ?? true;
440
+ const answers = new Map<string, GoalQuestionnaireAnswer>();
441
+ const drafts = new Map<string, string>();
442
+
443
+ const editorTheme: EditorTheme = {
444
+ borderColor: (s) => theme.fg("accent", s),
445
+ selectList: {
446
+ selectedPrefix: (t) => theme.fg("accent", t),
447
+ selectedText: (t) => theme.fg("accent", t),
448
+ description: (t) => theme.fg("muted", t),
449
+ scrollInfo: (t) => theme.fg("dim", t),
450
+ noMatch: (t) => theme.fg("warning", t),
451
+ },
452
+ };
453
+ const editor = new Editor(tui, editorTheme);
454
+
455
+ function refresh() {
456
+ cachedLines = undefined;
457
+ tui.requestRender();
458
+ }
459
+
460
+ function submit(cancelled: boolean) {
461
+ // Restore hardware cursor now that the dialog is closing
462
+ tui.setShowHardwareCursor(wasHardwareCursorShown);
463
+ // Resume pi's working spinner (the agent run is still active until agent_end).
464
+ ctx.ui.setWorkingVisible(true);
465
+ const ordered = questions.map((q) => answers.get(q.id)).filter((a): a is GoalQuestionnaireAnswer => !!a);
466
+ done({ questions, answers: ordered, cancelled, auditorEnabled: auditorToggleInit ? auditorEnabled : undefined });
467
+ }
468
+
469
+ function currentQuestion(): GoalQuestionnaireQuestion | undefined {
470
+ return questions[currentTab];
471
+ }
472
+
473
+ function displayOptions(): Array<{ label: string; isCustom?: boolean }> {
474
+ const q = currentQuestion();
475
+ if (!q) return [];
476
+ const opts: Array<{ label: string; isCustom?: boolean }> = q.options.map((label) => ({ label }));
477
+ if (q.allowCustom !== false) opts.push({ label: "Write your own answer...", isCustom: true });
478
+ return opts;
479
+ }
480
+
481
+ function allAnswered(): boolean {
482
+ return questions.every((q) => answers.has(q.id));
483
+ }
484
+
485
+ function enterQuestion(q: GoalQuestionnaireQuestion) {
486
+ const existing = answers.get(q.id);
487
+ const draft = drafts.get(q.id);
488
+ // §options-scroll: start each question tab from the top.
489
+ scrollTop = 0;
490
+ needsFollow = false;
491
+ if (q.options.length === 0) {
492
+ inputMode = true;
493
+ inputQuestionId = q.id;
494
+ editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
495
+ // Anchor the editor while the dialog has focus (see enterInputMode).
496
+ editor.focused = dialogFocused;
497
+ tui.setShowHardwareCursor(dialogFocused);
498
+ } else if (existing?.wasCustom) {
499
+ optionIndex = q.options.length;
500
+ } else if (existing && !existing.wasCustom) {
501
+ const idx = q.options.indexOf(existing.answer);
502
+ optionIndex = idx >= 0 ? idx : 0;
503
+ } else {
504
+ optionIndex = q.recommended ?? 0;
505
+ }
506
+ }
507
+
508
+ function enterSubmitTab() {
509
+ // §options-scroll: the submit summary also opens from the top.
510
+ optionIndex = 0;
511
+ scrollTop = 0;
512
+ needsFollow = false;
513
+ }
514
+
515
+ function advanceAfterAnswer() {
516
+ if (!isMulti) {
517
+ submit(false);
518
+ return;
519
+ }
520
+ if (currentTab < questions.length - 1) currentTab++;
521
+ else currentTab = questions.length;
522
+ const nextQ = currentQuestion();
523
+ if (nextQ) enterQuestion(nextQ);
524
+ else enterSubmitTab();
525
+ refresh();
526
+ }
527
+
528
+ function saveAnswer(qId: string, value: string, wasCustom: boolean) {
529
+ const q = questions.find((qq) => qq.id === qId);
530
+ answers.set(qId, { id: qId, question: q?.question ?? qId, answer: value, wasCustom });
531
+ }
532
+
533
+ /**
534
+ * Activate the answer editor for a question: position the hardware
535
+ * cursor and mark the Editor focused (emits CURSOR_MARKER for IME).
536
+ */
537
+ /**
538
+ * §options-first fit: every option line always stays in frame. When the
539
+ * question + context + options exceed the bound, the question/context
540
+ * section yields first (it remains in the agent transcript): middle lines
541
+ * (blanks, auditor, "Current:", context) drop before the question block
542
+ * and the footer hint, and the tail slack (blank/footer) gives way before
543
+ * the question. Returns null when even the option block + borders cannot
544
+ * fit (pathological) so the caller falls back to the scroll viewport.
545
+ */
546
+ function fitOptionsInFrame(
547
+ lines: string[],
548
+ maxDialogLines: number,
549
+ questionBlockEnd: number,
550
+ optionsStart: number,
551
+ lastOptionLine: number,
552
+ ): string[] | null {
553
+ if (maxDialogLines <= 0 || lines.length <= maxDialogLines) return lines;
554
+ if (optionsStart < 0 || lastOptionLine < optionsStart) return null;
555
+ const head = lines.slice(0, Math.max(questionBlockEnd, 1));
556
+ const middle = lines.slice(Math.max(questionBlockEnd, 1), optionsStart);
557
+ const tail = lines.slice(optionsStart);
558
+ const optionsBlockLen = lastOptionLine - optionsStart + 1;
559
+ // The option block + bottom border must fit on their own; otherwise the
560
+ // scroll viewport is the last resort (nothing is ever hidden).
561
+ if (optionsBlockLen + 1 > maxDialogLines) return null;
562
+ // Shrink the middle from its end: blanks first, then auditor/Current/
563
+ // context lines — the question block is untouched.
564
+ const mid = middle.slice();
565
+ while (head.length + mid.length + tail.length > maxDialogLines && mid.length > 0) {
566
+ let blankIdx = -1;
567
+ for (let i = mid.length - 1; i >= 0; i--) {
568
+ if (mid[i]!.trim() === "") { blankIdx = i; break; }
569
+ }
570
+ if (blankIdx >= 0) mid.splice(blankIdx, 1);
571
+ else mid.pop();
572
+ }
573
+ // Still over: the tail gives its slack (blank between options and
574
+ // footer, then the footer hint) before the question block does. Never
575
+ // drop the option block or the bottom border.
576
+ const tailKept = tail.slice();
577
+ while (head.length + mid.length + tailKept.length > maxDialogLines && tailKept.length > optionsBlockLen + 1) {
578
+ // Prefer removing a blank line (never the options or the border),
579
+ // then the line just above the bottom border (the footer hint).
580
+ let tailBlank = -1;
581
+ for (let i = tailKept.length - 2; i >= 1; i--) {
582
+ if (tailKept[i]!.trim() === "") { tailBlank = i; break; }
583
+ }
584
+ if (tailBlank >= 1) tailKept.splice(tailBlank, 1);
585
+ else tailKept.splice(tailKept.length - 2, 1);
586
+ }
587
+ // Still over: give the question block's trailing wrap lines (then the
588
+ // question itself) before giving up — options + borders always stay.
589
+ const headKept = head.slice();
590
+ while (head.length + mid.length + tailKept.length > maxDialogLines && headKept.length > 1) {
591
+ headKept.pop();
592
+ }
593
+ if (headKept.length + mid.length + tailKept.length > maxDialogLines) return null;
594
+ return [...headKept, ...mid, ...tailKept];
595
+ }
596
+
597
+ function enterInputMode(qId: string) {
598
+ inputMode = true;
599
+ inputQuestionId = qId;
600
+ const draft = drafts.get(qId);
601
+ const existing = answers.get(qId);
602
+ editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
603
+ editor.focused = dialogFocused;
604
+ tui.setShowHardwareCursor(dialogFocused);
605
+ }
606
+
607
+ /** Deactivate the answer editor: release the cursor and clear the editor. */
608
+ function leaveInputMode() {
609
+ inputMode = false;
610
+ inputQuestionId = null;
611
+ editor.focused = false;
612
+ tui.setShowHardwareCursor(false);
613
+ editor.setText("");
614
+ }
615
+
616
+ editor.onSubmit = (value) => {
617
+ if (!inputQuestionId) return;
618
+ const trimmed = value.trim();
619
+ if (!trimmed) {
620
+ refresh();
621
+ return;
622
+ }
623
+ drafts.delete(inputQuestionId);
624
+ saveAnswer(inputQuestionId, trimmed, true);
625
+ leaveInputMode();
626
+ advanceAfterAnswer();
627
+ };
628
+
629
+ function exitEditor() {
630
+ if (inputQuestionId) {
631
+ const text = editor.getText();
632
+ if (text.trim()) drafts.set(inputQuestionId, text);
633
+ else drafts.delete(inputQuestionId);
634
+ }
635
+ leaveInputMode();
636
+ }
637
+
638
+ enterQuestion(questions[0]!);
639
+
640
+ function handleInput(data: string) {
641
+ if (inputMode) {
642
+ if (controls.cancelEditing(data)) {
643
+ const q = currentQuestion();
644
+ if (q && q.options.length === 0 && !isMulti) submit(true);
645
+ else {
646
+ exitEditor();
647
+ refresh();
648
+ }
649
+ return;
650
+ }
651
+ if (isMulti && (controls.matches(data, "next", true) || controls.matches(data, "previous", true))) {
652
+ exitEditor();
653
+ currentTab = controls.matches(data, "next", true) ? (currentTab + 1) % totalTabs : (currentTab - 1 + totalTabs) % totalTabs;
654
+ const nextQ = currentQuestion();
655
+ if (nextQ) enterQuestion(nextQ);
656
+ else enterSubmitTab();
657
+ refresh();
658
+ return;
659
+ }
660
+ editor.handleInput(data);
661
+ refresh();
662
+ return;
663
+ }
664
+
665
+ const q = currentQuestion();
666
+ const opts = displayOptions();
667
+
668
+ if (isMulti) {
669
+ if (controls.matches(data, "next")) {
670
+ currentTab = (currentTab + 1) % totalTabs;
671
+ const nextQ = currentQuestion();
672
+ if (nextQ) enterQuestion(nextQ);
673
+ else enterSubmitTab();
674
+ refresh();
675
+ return;
676
+ }
677
+ if (controls.matches(data, "previous")) {
678
+ currentTab = (currentTab - 1 + totalTabs) % totalTabs;
679
+ const nextQ = currentQuestion();
680
+ if (nextQ) enterQuestion(nextQ);
681
+ else enterSubmitTab();
682
+ refresh();
683
+ return;
684
+ }
685
+ }
686
+
687
+ // §options-scroll viewport keys (select-mode question tabs AND the
688
+ // submit summary): page and line scroll without moving the selection.
689
+ // Handled before the submit-tab early return so the summary scrolls
690
+ // too. ↑/↓ still select and auto-follow into view on the next render.
691
+ if (controls.matches(data, "pageUp")) {
692
+ scrollTop -= Math.max(1, (maxDialogLines ?? 10) - 1);
693
+ needsFollow = false;
694
+ refresh();
695
+ return;
696
+ }
697
+ if (controls.matches(data, "pageDown")) {
698
+ scrollTop += Math.max(1, (maxDialogLines ?? 10) - 1);
699
+ needsFollow = false;
700
+ refresh();
701
+ return;
702
+ }
703
+ if (controls.matches(data, "scrollUp")) {
704
+ scrollTop -= 1;
705
+ needsFollow = false;
706
+ refresh();
707
+ return;
708
+ }
709
+ if (controls.matches(data, "scrollDown")) {
710
+ scrollTop += 1;
711
+ needsFollow = false;
712
+ refresh();
713
+ return;
714
+ }
715
+
716
+ if (currentTab === questions.length) {
717
+ if (controls.matches(data, "confirm") && allAnswered()) submit(false);
718
+ else if (controls.matches(data, "cancel")) submit(true);
719
+ return;
720
+ }
721
+
722
+ if (controls.matches(data, "up")) {
723
+ optionIndex = Math.max(0, optionIndex - 1);
724
+ needsFollow = true; // §options-scroll: keep the selection visible
725
+ refresh();
726
+ return;
727
+ }
728
+ if (controls.matches(data, "down")) {
729
+ optionIndex = Math.min(opts.length - 1, optionIndex + 1);
730
+ needsFollow = true;
731
+ refresh();
732
+ return;
733
+ }
734
+
735
+ // Auditor toggle hotkey
736
+ if (controls.matches(data, "toggleAuditor") && auditorToggleInit) {
737
+ auditorEnabled = !auditorEnabled;
738
+ refresh();
739
+ return;
740
+ }
741
+
742
+ if (controls.matches(data, "confirm") && q) {
743
+ if (q.options.length === 0 || opts[optionIndex]?.isCustom) {
744
+ enterInputMode(q.id);
745
+ refresh();
746
+ return;
747
+ }
748
+ const opt = opts[optionIndex];
749
+ if (opt) {
750
+ saveAnswer(q.id, opt.label, false);
751
+ advanceAfterAnswer();
752
+ }
753
+ return;
754
+ }
755
+
756
+ if (controls.matches(data, "cancel")) submit(true);
757
+ }
758
+
759
+ function render(width: number): string[] {
760
+ if (cachedLines) return cachedLines;
761
+ const safeWidth = Math.max(20, width);
762
+ let lines: string[] = [];
763
+ const q = currentQuestion();
764
+ const opts = displayOptions();
765
+ const add = (s: string) => lines.push(truncateToWidth(s, safeWidth, "…", true));
766
+ const addWrapped = (s: string) => lines.push(...wrapTextWithAnsi(s, safeWidth));
767
+ /**
768
+ * Wraps a pipe-prefixed line and prepends "│ " to continuation lines
769
+ * so wrapped content stays within the ASCII box.
770
+ */
771
+ const PIPE_PREFIX = "│ ";
772
+ const PIPE_WIDTH = visibleWidth(PIPE_PREFIX);
773
+ const addWrappedPipe = (styledLine: string) => {
774
+ const wrapWidth = Math.max(1, safeWidth - PIPE_WIDTH);
775
+ const wrapped = wrapTextWithAnsi(styledLine, wrapWidth);
776
+ for (let i = 0; i < wrapped.length; i++) {
777
+ lines.push(i === 0 ? wrapped[i]! : PIPE_PREFIX + wrapped[i]!);
778
+ }
779
+ };
780
+
781
+ /** Render context lines with per-line styling. No truncation. */
782
+ const renderContextLines = (context: string): void => {
783
+ const rawLines = context.split("\n");
784
+ for (const rawLine of rawLines) {
785
+ const trimmed = rawLine.trim();
786
+ // Empty line — preserve as spacing
787
+ if (!trimmed) {
788
+ lines.push("");
789
+ continue;
790
+ }
791
+
792
+ // 1. Announcement header — "● Goal draft/tweak ready for confirmation."
793
+ if (/^● Goal (draft|tweak) ready for confirmation\.$/.test(trimmed)) {
794
+ addWrapped(theme.fg("accent", rawLine));
795
+ continue;
796
+ }
797
+
798
+ // 2. Section marker — "─── Name ───" → full-width box-drawing header
799
+ const sectionMatch = trimmed.match(/^───\s+(.+?)\s+───$/);
800
+ if (sectionMatch) {
801
+ const sectionName = sectionMatch[1];
802
+ const namePart = ` ${sectionName} `;
803
+ const left = "┌─";
804
+ const right = "─┐";
805
+ const fill = Math.max(0, safeWidth - 2 - visibleWidth(left + namePart + right));
806
+ add(theme.fg("accent", left + namePart + "─".repeat(fill) + right));
807
+ continue;
808
+ }
809
+
810
+ // 3. Lines with │ prefix come from buildDraftConfirmationText / buildTweakConfirmationText.
811
+ if (trimmed.startsWith("│")) {
812
+ const afterPipe = trimmed.slice(1).trim();
813
+ // 3a. Task checkbox under │ prefix — detect before key-value to avoid
814
+ // "[x] t1: ..." being misinterpreted as a key-value pair.
815
+ const pipeTaskMatch = afterPipe.match(/^(\[.\])(\s+)(.+)$/);
816
+ if (pipeTaskMatch) {
817
+ const bracket = pipeTaskMatch[1]!;
818
+ const sep = pipeTaskMatch[2]!;
819
+ const rest = pipeTaskMatch[3]!;
820
+ // Preserve inner whitespace between │ and the task marker (e.g. " " in "│ [x]...")
821
+ const pipeContent = trimmed.slice(1);
822
+ const innerWs = pipeContent.slice(0, pipeContent.length - pipeContent.trimStart().length);
823
+ const linePrefix = "│" + innerWs;
824
+ const color = bracket === "[x]" ? "success" : "warning";
825
+ addWrappedPipe(linePrefix + theme.fg(color, bracket) + sep + theme.fg("muted", rest));
826
+ continue;
827
+ }
828
+ // 3b. Key-value content (e.g. "│ Mode: Normal goal", "│ Auto-continue: yes")
829
+ if (afterPipe.includes(": ")) {
830
+ const colonIdx = afterPipe.indexOf(": ");
831
+ const val = afterPipe.slice(colonIdx + 2).trim();
832
+ const keyPart = rawLine.slice(0, rawLine.indexOf(afterPipe) + colonIdx + 2);
833
+ if (val === "yes" || val === "no") {
834
+ addWrappedPipe(theme.fg("muted", keyPart) + theme.fg(val === "yes" ? "success" : "warning", val));
835
+ continue;
836
+ }
837
+ addWrappedPipe(theme.fg("muted", rawLine));
838
+ continue;
839
+ }
840
+ // 3c. Generic content under │ prefix (topic, goal text, etc.)
841
+ addWrappedPipe(theme.fg("muted", rawLine));
842
+ continue;
843
+ }
844
+
845
+ // 4. Goal objective structure lines — detected before task checkboxes
846
+ // because === Goal could overlap with ─── markers but we already checked those.
847
+ const GOAL_SECTION_RE = /^(=== (Goal|Sisyphus Goal) ===|Objective:|Success criteria:|Boundaries:|Constraints:|Verification contract:|If blocked:)/;
848
+ if (GOAL_SECTION_RE.test(trimmed)) {
849
+ addWrapped(theme.fg("accent", rawLine));
850
+ continue;
851
+ }
852
+
853
+ // 5. Actual box-drawing borders (┌ └ ├ └ ┐ ┤ ┘ ─) — NOT │ which is handled above
854
+ if (/^[┌├└┐┤┘─]/.test(trimmed)) {
855
+ addWrapped(theme.fg("dim", rawLine));
856
+ continue;
857
+ }
858
+
859
+ // 6. Task checkbox item — "[ ] ...", "[x] ...", or "[~] ..." (with optional indent)
860
+ const checkMatch = trimmed.match(/^(\[.\])(\s+)(.+)$/);
861
+ if (checkMatch) {
862
+ const bracket = checkMatch[1]!;
863
+ const sep = checkMatch[2]!;
864
+ const rest = checkMatch[3]!;
865
+ const indent = rawLine.slice(0, rawLine.length - trimmed.length);
866
+ const color = bracket === "[x]" ? "success" : "warning";
867
+ addWrapped(indent + theme.fg(color, bracket) + sep + theme.fg("muted", rest));
868
+ continue;
869
+ }
870
+
871
+ // 7. Default: any remaining content (fallback)
872
+ addWrapped(theme.fg("muted", rawLine));
873
+ }
874
+ };
875
+
876
+ add(theme.fg("accent", "─".repeat(safeWidth)));
877
+ // Lines up to the question line (top border, tabs, question incl.
878
+ // wraps) are the protected head — the slice below must never hide them.
879
+ let protectedCount = lines.length;
880
+ if (isMulti) {
881
+ const tabs: string[] = ["← "];
882
+ for (let i = 0; i < questions.length; i++) {
883
+ const isActive = i === currentTab;
884
+ const isAnswered = answers.has(questions[i]!.id);
885
+ const label = ` ${isAnswered ? "■" : "□"} ${questions[i]!.id} `;
886
+ tabs.push(isActive ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg(isAnswered ? "success" : "muted", label));
887
+ tabs.push(" ");
888
+ }
889
+ const submitText = " ✓ Submit ";
890
+ tabs.push(currentTab === questions.length ? theme.bg("selectedBg", theme.fg("text", submitText)) : theme.fg(allAnswered() ? "success" : "dim", submitText));
891
+ tabs.push(" →");
892
+ add(` ${tabs.join("")}`);
893
+ lines.push("");
894
+ protectedCount = lines.length;
895
+ }
896
+
897
+ function renderOptions() {
898
+ optionsStartIndex = lines.length;
899
+ for (let i = 0; i < opts.length; i++) {
900
+ const start = lines.length;
901
+ const opt = opts[i]!;
902
+ const selected = i === optionIndex;
903
+ const prefix = selected ? theme.fg("accent", "> ") : " ";
904
+ const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
905
+ addWrapped(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
906
+ optionRanges[i] = [start, lines.length - 1];
907
+ }
908
+ }
909
+
910
+ if (inputMode && q) {
911
+ addWrapped(theme.fg("text", ` ${q.question}`));
912
+ protectedCount = lines.length;
913
+ if (q.context) renderContextLines(q.context);
914
+ lines.push("");
915
+ if (q.options.length > 0) {
916
+ renderOptions();
917
+ lines.push("");
918
+ }
919
+ add(theme.fg("muted", " Your answer:"));
920
+ for (const line of editor.render(safeWidth - 2)) add(` ${line}`);
921
+ lines.push("");
922
+ add(theme.fg("dim", ` Enter to submit • ${controls.editorCancelHint()} to cancel`));
923
+ } else if (currentTab === questions.length) {
924
+ add(theme.fg("accent", theme.bold(" Ready to submit")));
925
+ protectedCount = lines.length;
926
+ lines.push("");
927
+ for (const question of questions) {
928
+ const answer = answers.get(question.id);
929
+ add(`${theme.fg("muted", ` ${question.id}: `)}${answer ? theme.fg("text", `${answer.wasCustom ? "(wrote) " : ""}${answer.answer}`) : theme.fg("warning", "(unanswered)")}`);
930
+ }
931
+ lines.push("");
932
+ add(allAnswered() ? theme.fg("success", ` Press ${controls.hint("confirm")} to submit`) : theme.fg("warning", ` Unanswered: ${questions.filter((qq) => !answers.has(qq.id)).map((qq) => qq.id).join(", ")}`));
933
+ } else if (q) {
934
+ addWrapped(theme.fg("text", ` ${q.question}`));
935
+ questionBlockEnd = lines.length;
936
+ protectedCount = lines.length;
937
+ if (q.context) renderContextLines(q.context);
938
+ // Auditor toggle line between context and options
939
+ if (auditorToggleInit) {
940
+ const circle = auditorEnabled ? "●" : "○";
941
+ const label = auditorEnabled ? "Auditor enabled" : "Auditor disabled";
942
+ const color = auditorEnabled ? "success" : "warning";
943
+ add(theme.fg(color, ` ${circle} ${label}`) + theme.fg("dim", ` (press '${controls.hint("toggleAuditor")}' to toggle)`));
944
+ lines.push("");
945
+ }
946
+ const existing = answers.get(q.id);
947
+ if (existing) add(theme.fg("dim", ` Current: ${existing.wasCustom ? "(wrote) " : ""}${existing.answer}`));
948
+ lines.push("");
949
+ if (opts.length > 0) renderOptions();
950
+ else add(theme.fg("muted", ` Press ${controls.hint("confirm")} to write your answer`));
951
+ }
952
+
953
+ lines.push("");
954
+ if (!inputMode) {
955
+ const auditorHint = auditorToggleInit ? ` • ${controls.hint("toggleAuditor")} toggle auditor` : "";
956
+ add(theme.fg("dim", isMulti ? ` ${controls.hint("next")}/${controls.hint("previous")} navigate • ${controls.navigationHint()} select • ${controls.hint("confirm")} confirm • ${controls.hint("cancel")} cancel` + auditorHint : ` ${controls.navigationHint()} navigate • ${controls.hint("confirm")} select • ${controls.hint("cancel")} cancel` + auditorHint));
957
+ }
958
+ add(theme.fg("accent", "─".repeat(safeWidth)));
959
+ // Safety net: ensure no returned line exceeds the terminal width
960
+ for (let i = 0; i < lines.length; i++) {
961
+ const line = lines[i];
962
+ if (line && visibleWidth(line) > safeWidth) {
963
+ lines[i] = truncateToWidth(line, safeWidth);
964
+ }
965
+ }
966
+ // Churn guard: bound to the terminal height (see factory top) so the
967
+ // opened frame never exceeds the screen. Never slice the question:
968
+ // keep the protected head, then spend the rest of the budget on the
969
+ // tail (options/footer/bottom border) — or, for plain select-mode
970
+ // questions where the options start right after the head, keep the
971
+ // top options so the recommended one stays visible. Proposal
972
+ // confirmations additionally keep the tasks section in frame; the
973
+ // objective-box middle is sacrificed there because the full objective
974
+ // is always in the scrollable transcript presentation (renderCall).
975
+ if (maxDialogLines !== undefined && lines.length > maxDialogLines) {
976
+ const proposalSegments = !inputMode && currentTab !== questions.length && !!q && q.context
977
+ ? findProposalPresentationSegments(lines, optionsStartIndex)
978
+ : null;
979
+ if (proposalSegments) {
980
+ // Proposal confirmations keep their segment protection (tasks +
981
+ // auditor + options within the bound; the objective-box middle
982
+ // stays in the scrollback presentation).
983
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, proposalSegments);
984
+ } else if (!inputMode && currentTab !== questions.length && questionBlockEnd >= 0) {
985
+ // Plain select-mode question tab: all options in frame;
986
+ // question/context yields first (it stays in the transcript).
987
+ const fitted = fitOptionsInFrame(lines, maxDialogLines, questionBlockEnd, optionsStartIndex, optionRanges[optionRanges.length - 1]?.[1] ?? -1);
988
+ if (fitted) {
989
+ lines = fitted;
990
+ scrollTop = 0;
991
+ needsFollow = false;
992
+ } else {
993
+ // Pathological: options alone overflow the bound — scroll
994
+ // viewport is the last resort (nothing ever hidden).
995
+ const scrollState = { scrollTop, needsFollow, optionRanges, followIndex: optionIndex };
996
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, null, scrollState, (s) => theme.fg("dim", s), controls.pagingHint());
997
+ scrollTop = scrollState.scrollTop;
998
+ needsFollow = scrollState.needsFollow;
999
+ }
1000
+ } else {
1001
+ // Input mode, submit tab: legacy tail-keep / viewport behavior.
1002
+ const scrollState = !inputMode ? { scrollTop, needsFollow, optionRanges, followIndex: optionIndex } : null;
1003
+ lines = fitDialogLines(lines, maxDialogLines, protectedCount, null, scrollState, (s) => theme.fg("dim", s), controls.pagingHint());
1004
+ if (scrollState) {
1005
+ scrollTop = scrollState.scrollTop;
1006
+ needsFollow = scrollState.needsFollow;
1007
+ }
1008
+ }
1009
+ }
1010
+ cachedLines = lines;
1011
+ return lines;
1012
+ }
1013
+
1014
+ return {
1015
+ render,
1016
+ invalidate: () => { cachedLines = undefined; },
1017
+ handleInput,
1018
+ // Focusable (pi docs/tui.md): propagate focus to the embedded Editor
1019
+ // so it emits CURSOR_MARKER and the hardware cursor is positioned
1020
+ // correctly for IME input while the answer editor is active.
1021
+ get focused(): boolean { return dialogFocused; },
1022
+ set focused(v: boolean) {
1023
+ dialogFocused = v;
1024
+ editor.focused = v && inputMode;
1025
+ tui.setShowHardwareCursor(v && inputMode);
1026
+ },
1027
+ };
1028
+ });
1029
+ // RPC hosts historically never reached this path (the mode check sent them
1030
+ // straight to the per-question dialogs), so keep their safety net: a host that
1031
+ // advertises `custom` yet throws while building or rendering the component
1032
+ // degrades to per-question dialogs instead of failing the whole draft. Non-rpc
1033
+ // hosts keep upstream semantics — their errors surface to the caller.
1034
+ const result = ctx.mode === "rpc"
1035
+ ? await showRichDialog().catch((error) => {
1036
+ // Degrading silently would look like the per-question dialogs came back for no
1037
+ // reason (and any already-answered questions are re-asked): say why first.
1038
+ ctx.ui.notify(
1039
+ `Goal dialog could not be rendered by this host (${error instanceof Error ? error.message : String(error)}); falling back to one dialog per question.`,
1040
+ "warning",
1041
+ );
1042
+ return undefined;
1043
+ })
1044
+ : await showRichDialog();
1045
+ if (result !== undefined) return result;
1046
+ return runQuestionnaireWithBasicDialogs(ctx, questions, auditorToggleInit);
1047
+ }
1048
+
1049
+ /**
1050
+ * Confirm a proposed draft through the shared questionnaire UI. Escape / cancel
1051
+ * maps to "continue" so the user is never trapped.
1052
+ */
1053
+ export async function showProposalDialog(
1054
+ ctx: ExtensionContext,
1055
+ confirmationText: string,
1056
+ focus: GoalDraftingFocus,
1057
+ defaultAuditorEnabled?: boolean,
1058
+ ): Promise<{ decision: ProposalDecision; auditorEnabled: boolean; unavailable: boolean }> {
1059
+ const headerTitle = focus === "sisyphus" ? "Confirm Sisyphus Goal Draft" : "Confirm Goal Draft";
1060
+ const result = await runGoalQuestionnaire(ctx, [{
1061
+ id: "confirm",
1062
+ question: headerTitle,
1063
+ context: confirmationText,
1064
+ options: ["Confirm — create this goal now", "Continue chatting — keep refining", "Cancel — discard this draft"],
1065
+ recommended: 0,
1066
+ allowCustom: false,
1067
+ }], defaultAuditorEnabled !== undefined ? { defaultEnabled: defaultAuditorEnabled } : undefined);
1068
+ const decision = proposalDecisionFromQuestionnaireResult({
1069
+ cancelled: result.cancelled,
1070
+ answer: result.answers[0]?.answer,
1071
+ });
1072
+ return { decision, auditorEnabled: result.auditorEnabled ?? true, unavailable: result.unavailable === true };
1073
+ }