@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/assets/badge-dark.svg +18 -0
- package/assets/badge-light.svg +18 -0
- package/docs/advanced-usage.md +106 -0
- package/docs/agent-flow-design.md +306 -0
- package/docs/agentic-runtime-prd.md +773 -0
- package/docs/architecture.md +368 -0
- package/docs/controls.md +140 -0
- package/docs/pi-autoresearch-survey.md +50 -0
- package/docs/pi-goal-x-unified-dashboard-plan.md +1667 -0
- package/docs/prompt-caching.md +31 -0
- package/docs/unified-dashboard.md +286 -0
- package/extensions/auditor-selector.ts +74 -0
- package/extensions/goal-accounting.ts +107 -0
- package/extensions/goal-activity.ts +202 -0
- package/extensions/goal-auditor.ts +480 -0
- package/extensions/goal-commands.ts +866 -0
- package/extensions/goal-compaction.ts +197 -0
- package/extensions/goal-completion.ts +173 -0
- package/extensions/goal-context.ts +12 -0
- package/extensions/goal-contract.ts +58 -0
- package/extensions/goal-controls.ts +202 -0
- package/extensions/goal-core-tools.ts +549 -0
- package/extensions/goal-core.ts +78 -0
- package/extensions/goal-detail.ts +65 -0
- package/extensions/goal-dialog-controls.ts +31 -0
- package/extensions/goal-draft.ts +244 -0
- package/extensions/goal-drafting.ts +535 -0
- package/extensions/goal-events.ts +692 -0
- package/extensions/goal-format.ts +376 -0
- package/extensions/goal-interruption.ts +53 -0
- package/extensions/goal-ledger-index.ts +68 -0
- package/extensions/goal-ledger.ts +1007 -0
- package/extensions/goal-live-retention.ts +219 -0
- package/extensions/goal-oracle.ts +346 -0
- package/extensions/goal-policy.ts +370 -0
- package/extensions/goal-pool.ts +103 -0
- package/extensions/goal-prompt-cache.ts +80 -0
- package/extensions/goal-questionnaire.ts +1073 -0
- package/extensions/goal-record.ts +388 -0
- package/extensions/goal-recovery.ts +256 -0
- package/extensions/goal-runtime.ts +277 -0
- package/extensions/goal-scheduler-state.ts +100 -0
- package/extensions/goal-scheduler.ts +436 -0
- package/extensions/goal-service.ts +843 -0
- package/extensions/goal-session-health.ts +130 -0
- package/extensions/goal-session-safety.ts +65 -0
- package/extensions/goal-settings.ts +1370 -0
- package/extensions/goal-state.ts +1069 -0
- package/extensions/goal-status.ts +305 -0
- package/extensions/goal-task-confirmation.ts +174 -0
- package/extensions/goal-task-count.ts +59 -0
- package/extensions/goal-task-derive.ts +46 -0
- package/extensions/goal-task-index.ts +57 -0
- package/extensions/goal-task-tools.ts +595 -0
- package/extensions/goal-tool-inputs.ts +102 -0
- package/extensions/goal-tool-names.ts +73 -0
- package/extensions/goal-tools.ts +16 -0
- package/extensions/goal-widget.ts +455 -0
- package/extensions/goal.ts +42 -0
- package/extensions/network-error-backoff.ts +55 -0
- package/extensions/prompts/goal-prompts.ts +321 -0
- package/extensions/storage/atomic-rename.ts +52 -0
- package/extensions/storage/goal-files.ts +695 -0
- package/extensions/storage/goal-lock.ts +131 -0
- package/extensions/storage/goal-root.ts +95 -0
- package/extensions/widgets/auditor-dashboard-model.ts +174 -0
- package/extensions/widgets/dialog-scaffold.ts +26 -0
- package/extensions/widgets/goal-controls-dialog.ts +329 -0
- package/extensions/widgets/goal-dashboard-model.ts +530 -0
- package/extensions/widgets/goal-dashboard-renderer.ts +674 -0
- package/extensions/widgets/goal-escape-dialog.ts +148 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +723 -0
- package/extensions/widgets/task-list-overlay.ts +409 -0
- package/extensions/widgets/text-cache.ts +30 -0
- package/package.json +117 -4
- 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
|
+
}
|