@quantmind-br/pi-goal-x 0.0.0-stage → 0.35.1

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 +239 -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 +330 -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,723 @@
1
+ import type { GoalKeybinding, GoalCommands } from "../goal-controls.ts";
2
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import type { Component, TUI } from "@earendil-works/pi-tui";
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import { truncateToWidth } from "./text-cache.ts";
6
+ import {
7
+ displayObjectiveTitle,
8
+ formatDuration,
9
+ formatTokenValue,
10
+ truncateText,
11
+ type GoalDisplayRecordLike,
12
+ } from "../goal-core.ts";
13
+ import type { GoalRecord, GoalTask, GoalTaskList, TaskStatus } from "../goal-record.ts";
14
+ import type { GoalDashboardKeybindings, GoalSettings } from "../goal-settings.ts";
15
+ import { sisyphusStepProgress } from "../goal-policy.ts";
16
+ import type { GoalLedgerEvent } from "../goal-ledger.ts";
17
+ import {
18
+ anchoredScrollOffset,
19
+ clampScrollOffset,
20
+ compactTaskViewportRows,
21
+ deriveGoalDashboardModel,
22
+ expandedTaskViewportRows,
23
+ latestCompletedNodeIndex,
24
+ maxScrollOffset,
25
+ taskViewportPageSize,
26
+ type GoalDashboardModel,
27
+ } from "./goal-dashboard-model.ts";
28
+ import {
29
+ clampLinesToWidth,
30
+ renderAuditResultCard,
31
+ renderAuditorDashboard,
32
+ renderCompactDashboard,
33
+ renderExpandedDashboard,
34
+ renderUnfocusedDashboard,
35
+ } from "./goal-dashboard-renderer.ts";
36
+ import { deriveAuditResultCard, deriveAuditorDashboardModel } from "./auditor-dashboard-model.ts";
37
+
38
+ type GoalWidgetColor = Extract<ThemeColor, "accent" | "warning" | "success" | "error" | "dim" | "muted" | "text">;
39
+
40
+ export interface GoalWidgetRecord extends GoalDisplayRecordLike {
41
+ id: string;
42
+ createdAt: string;
43
+ updatedAt: string;
44
+ activePath?: string | null;
45
+ archivedPath?: string | null;
46
+ pauseReason?: string;
47
+ pauseSuggestedAction?: string;
48
+ taskList?: GoalTaskList | null;
49
+ verificationContract?: string;
50
+ tokenBudget?: number;
51
+ currentTaskId?: string;
52
+ }
53
+
54
+ export const GOAL_WIDGET_KEY = "goal";
55
+
56
+ /**
57
+ * Live display record for the widget/status line: the accounted usage view
58
+ * (clone with live elapsed seconds) when accounting is active for the goal,
59
+ * otherwise the goal as-is (P1-12 extraction from goal-state).
60
+ */
61
+ export function liveDisplayGoal(goal: GoalRecord | null, accounting: { isActiveFor(goalId: string): boolean; liveSeconds(): number }): GoalRecord | null {
62
+ if (!goal || goal.status !== "active" || !accounting.isActiveFor(goal.id)) return goal;
63
+ const liveSeconds = accounting.liveSeconds();
64
+ if (liveSeconds === 0) return goal;
65
+ return {
66
+ ...goal,
67
+ usage: { ...goal.usage, activeSeconds: goal.usage.activeSeconds + liveSeconds },
68
+ };
69
+ }
70
+
71
+ /**
72
+ * The above-editor widget registration factory (P1-12 extraction): builds the
73
+ * GoalWidgetComponent factory the host UI calls at render time. Reads live
74
+ * state through getters so renders always see the current goal.
75
+ */
76
+ export function makeGoalWidgetFactory(opts: {
77
+ getGoal: () => GoalWidgetRecord | null;
78
+ getOpenGoalCount: () => number;
79
+ getAuditorProgress: () => AuditorWidgetProgress | null;
80
+ getSettings: () => GoalSettings;
81
+ getDebugMode: () => boolean;
82
+ getStalled: () => boolean;
83
+ getExpanded?: () => boolean;
84
+ getLedgerEvents?: () => GoalLedgerEvent[];
85
+ getAuditResult?: () => AuditResultView | null;
86
+ /** Receives the live widget so terminal shortcuts can scroll its viewports. */
87
+ componentRef?: { current: GoalWidgetComponent | null };
88
+ }) {
89
+ return (tui: TUI, theme: Theme) => {
90
+ const component = new GoalWidgetComponent({
91
+ tui,
92
+ theme,
93
+ getGoal: opts.getGoal,
94
+ getOpenGoalCount: opts.getOpenGoalCount,
95
+ getAuditorProgress: opts.getAuditorProgress,
96
+ getSettings: opts.getSettings,
97
+ getDebugMode: opts.getDebugMode,
98
+ getStalled: opts.getStalled,
99
+ getExpanded: opts.getExpanded,
100
+ getLedgerEvents: opts.getLedgerEvents,
101
+ getAuditResult: opts.getAuditResult,
102
+ });
103
+ if (opts.componentRef) opts.componentRef.current = component;
104
+ return component;
105
+ };
106
+ }
107
+
108
+ export interface AuditorWidgetProgress {
109
+ currentTool?: string;
110
+ currentToolArgs?: string;
111
+ currentToolStartedAt?: number;
112
+ recentOutput: string[];
113
+ phase: "running" | "tool_executing" | "producing_report" | "thinking" | "done";
114
+ elapsedMs: number;
115
+ /** Current step label shown to the user */
116
+ label?: string;
117
+ /** Completion percentage from 0 to 100 */
118
+ percentage?: number;
119
+ /** §15: auditor identity (provider/model) for the audit dashboard header. */
120
+ auditorLabel?: string;
121
+ }
122
+
123
+ export interface GoalWidgetOptions {
124
+ theme: Theme;
125
+ tui: TUI;
126
+ getGoal: () => GoalWidgetRecord | null;
127
+ getOpenGoalCount?: () => number;
128
+ getAuditorProgress?: () => AuditorWidgetProgress | null;
129
+ getSettings?: () => GoalSettings;
130
+ getDebugMode?: () => boolean;
131
+ /** F5: stalled badge (active auto-continue goal with no recent activity). */
132
+ getStalled?: () => boolean;
133
+ /** §10: dashboard expansion state (compact vs expanded task view). */
134
+ getExpanded?: () => boolean;
135
+ /** §12: durable ledger events for the recent-activity feed. */
136
+ getLedgerEvents?: () => GoalLedgerEvent[];
137
+ /** §15.4: finished audit result card (cleared after a short display). */
138
+ getAuditResult?: () => AuditResultView | null;
139
+ }
140
+
141
+ function fit(value: string, width: number): string {
142
+ return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value;
143
+ }
144
+
145
+ // ── terminal-height bound (spec 2026-08-10-widget-height-bound-scrollback-fix) ──
146
+
147
+ /**
148
+ * Minimum rows reserved below/around the widget so pi's frame stays usable
149
+ * when the widget is at its cap: status line (1) + editor minimum (3) +
150
+ * footer (1) + a chat row (1). With the widget capped at
151
+ * `terminalRows − WIDGET_HEIGHT_RESERVE`, the widget never fills the terminal:
152
+ * the chat row stays in the frame, the widget's top lines stay within the
153
+ * viewport (no pi-tui full-render scrollback wipe), and the editor/footer
154
+ * stay visible — the equal-height scroll-up failure is impossible. Kept at
155
+ * the minimum that still guarantees the chat row: a larger reserve would
156
+ * slice dashboards that genuinely fit (e.g. the expanded 24-line dashboard
157
+ * on a 30-row terminal renders unchanged at 24 + 5 chrome + 1 chat = 30).
158
+ */
159
+ export const WIDGET_HEIGHT_RESERVE = 6;
160
+
161
+ /**
162
+ * Deterministic height guard: cap rendered lines to the terminal height minus
163
+ * the reserved chrome. Head slice, because the dashboard's content priority
164
+ * is top-down (identity → status → progress → tasks → details → hints); the
165
+ * goal identity/status and the interactive task list stay in frame. Pure
166
+ * index function — stable across renders, so the widget never shrinks on its
167
+ * own and can never trigger pi-tui's clearOnShrink full-render
168
+ * (`\x1b[2J\x1b[H\x1b[3J` scrollback wipe). No-op (unbounded) when
169
+ * terminalRows is missing (mock TUI, headless contexts, /goal-status text).
170
+ *
171
+ * This pure head-slice is the terminal bound for direct callers. The live
172
+ * component additionally applies the stable-height bound
173
+ * (spec 2026-08-11-stable-widget-height) so the rendered height stays
174
+ * constant within each regime — see applyStableHeightBound.
175
+ */
176
+ export function boundWidgetRenderLines(lines: string[], terminalRows: number | undefined): string[] {
177
+ if (!terminalRows || terminalRows <= 0) return lines;
178
+ const cap = Math.max(1, terminalRows - WIDGET_HEIGHT_RESERVE);
179
+ if (lines.length <= cap) return lines;
180
+ return lines.slice(0, cap);
181
+ }
182
+
183
+ /**
184
+ * Stable rendered height per regime (spec 2026-08-11-stable-widget-height).
185
+ *
186
+ * The widget's natural (unbounded) height varies with goal state — activity
187
+ * feed growth, current-task contract/evidence wrapping, verification text,
188
+ * budget, task growth (measured 4..31 lines on a 24-row terminal). Every
189
+ * natural-height change alters the dock height → the terminal buffer's line
190
+ * count changes → the terminal scrolls to the bottom and the user cannot hold
191
+ * a scroll position to read the chat. The latch makes the rendered height
192
+ * invariant to goal-state changes in EVERY case (fits and capped):
193
+ *
194
+ * - the first render of each regime commits the rendered height
195
+ * (min(natural, cap)) and every later render of that regime renders exactly
196
+ * that many lines — growth is head-sliced, shrink is blank-padded, the
197
+ * height never changes;
198
+ * - resizing the terminal clears the latch and re-evaluates
199
+ * min(natural, newCap): growing the terminal reveals more of the widget,
200
+ * shrinking re-caps;
201
+ * - a regime change (goal id/status, widget state kind, compact↔expanded,
202
+ * debug mode, tasks disabled, first task appearing) clears the latch so the
203
+ * new mode starts from its own natural height.
204
+ *
205
+ * Pure given the persisted latch state: deterministic, no timers, no
206
+ * randomness — the same goal state on the same terminal renders the same
207
+ * lines on every render.
208
+ */
209
+ export function applyStableHeightBound(
210
+ lines: string[],
211
+ terminalRows: number | undefined,
212
+ state: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number },
213
+ regime: string,
214
+ reserve: number = WIDGET_HEIGHT_RESERVE,
215
+ ): string[] {
216
+ if (!terminalRows || terminalRows <= 0) return lines;
217
+ const cap = Math.max(1, terminalRows - reserve);
218
+ if (state.stickyTerminalRows !== terminalRows) {
219
+ // Terminal resized: adapt to the new height (grow/shrink to fit).
220
+ state.stickyCap = undefined;
221
+ state.stickyTerminalRows = terminalRows;
222
+ }
223
+ if (state.stickyReserve !== reserve) {
224
+ // The chrome below the widget changed (editor grew/shrunk, status,
225
+ // footer, pending): re-evaluate so the widget's block plus the chrome
226
+ // never exceeds the terminal — otherwise the chat's appended lines
227
+ // and the status line land above pi-tui's viewport top and every
228
+ // agent write triggers a 2J+3J scrollback wipe (spec 2026-08-11).
229
+ state.stickyCap = undefined;
230
+ state.stickyReserve = reserve;
231
+ }
232
+ if (state.stickyRegime !== regime) {
233
+ // Mode/state changed: the latch belongs to one regime.
234
+ state.stickyCap = undefined;
235
+ state.stickyRegime = regime;
236
+ }
237
+ if (state.stickyCap === undefined) {
238
+ // First render of the regime: latch the height — the natural height
239
+ // when it fits, else the cap. Constant from here on, fits AND capped.
240
+ state.stickyCap = Math.min(lines.length, cap);
241
+ }
242
+ if (lines.length > state.stickyCap) {
243
+ // Growth past the committed height: head-slice (the dashboard's
244
+ // content priority is top-down), so the buffer line count never
245
+ // changes.
246
+ return lines.slice(0, state.stickyCap);
247
+ }
248
+ if (lines.length < state.stickyCap) {
249
+ // Natural dipped below the committed height: pad deterministically so
250
+ // the buffer line count never changes. Blank rows after the box footer
251
+ // — honest filler (the …/↑ N more markers are only for hidden content).
252
+ const padded = lines.slice();
253
+ while (padded.length < state.stickyCap) padded.push("");
254
+ return padded;
255
+ }
256
+ // Exactly the committed height: unchanged.
257
+ return lines;
258
+ }
259
+
260
+ function heading(theme: Theme, width: number, left: string, right = ""): string {
261
+ if (!right) return fit(left, width);
262
+ const rightPart = ` ${right}`;
263
+ const fill = Math.max(1, width - visibleWidth(left) - visibleWidth(rightPart));
264
+ return fit(`${left}${theme.fg("dim", " ".repeat(fill))}${rightPart}`, width);
265
+ }
266
+
267
+ function branchLine(theme: Theme, width: number, isLast: boolean, content: string): string {
268
+ const prefix = isLast ? "└─" : "├─";
269
+ return fit(`${theme.fg("dim", prefix)} ${content}`, width);
270
+ }
271
+
272
+ function progressBar(pct: number, barWidth: number, theme: Theme): string {
273
+ const safeBar = Math.max(3, barWidth);
274
+ const filled = Math.min(safeBar, Math.max(0, Math.round((pct / 100) * safeBar)));
275
+ const empty = safeBar - filled;
276
+ return `[${theme.fg("accent", "█".repeat(filled))}${theme.fg("dim", "░".repeat(empty))}]`;
277
+ }
278
+
279
+
280
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
281
+
282
+ function spinnerFrame(): string {
283
+ return SPINNER[Math.floor(Date.now() / 80) % SPINNER.length]!;
284
+ }
285
+
286
+ export interface AuditResultView {
287
+ verdict: "approved" | "disapproved" | "error";
288
+ report: string;
289
+ }
290
+
291
+ /**
292
+ * Structured audit dashboard (§15): five check stages, elapsed duration, and a
293
+ * progress bar, derived from the raw auditor progress stream. Raw tools and
294
+ * recent output appear only with showToolDetails (expanded/debug audit mode)
295
+ * or after a failed audit.
296
+ */
297
+ export function renderAuditorWidgetLines(
298
+ progress: AuditorWidgetProgress,
299
+ theme: Theme,
300
+ width: number,
301
+ opts: { showToolDetails?: boolean; verdict?: "approved" | "disapproved" | "error" | null; interruptKey?: GoalKeybinding } = {},
302
+ ): string[] {
303
+ const model = deriveAuditorDashboardModel(progress, { auditorLabel: progress.auditorLabel, verdict: opts.verdict });
304
+ return renderAuditorDashboard(model, theme, width, { showToolDetails: opts.showToolDetails, interruptKey: opts.interruptKey });
305
+ }
306
+
307
+ /** §15.4: approval / rejection result card for a finished audit. */
308
+ export function renderAuditResultCardView(
309
+ view: AuditResultView,
310
+ theme: Theme,
311
+ width: number,
312
+ ): string[] {
313
+ return renderAuditResultCard(deriveAuditResultCard(view.verdict, view.report), theme, width);
314
+ }
315
+
316
+ export function renderGoalWidgetLines(goal: GoalWidgetRecord | null, theme: Theme, width: number, options: { commands?: GoalCommands; openGoalCount?: number; auditorProgress?: AuditorWidgetProgress | null; disableTasks?: boolean; stalled?: boolean; ledgerEvents?: GoalLedgerEvent[]; expanded?: boolean; debug?: boolean; model?: GoalDashboardModel | null; compactScrollOffset?: number; expandedScrollOffset?: number; expandedTaskRows?: number; keybindings?: GoalDashboardKeybindings; auditorKey?: GoalKeybinding; interruptAuditKey?: GoalKeybinding; /** Terminal-height bound (see boundWidgetRenderLines); undefined = unbounded. */ terminalRows?: number } = {}): string[] {
317
+ // When auditor progress is active, show the structured audit dashboard
318
+ // instead of the normal goal widget (§15.3).
319
+ if (options.auditorProgress) {
320
+ return boundWidgetRenderLines(renderAuditorWidgetLines(options.auditorProgress, theme, width, {
321
+ showToolDetails: options.expanded === true || options.debug === true,
322
+ interruptKey: options.interruptAuditKey,
323
+ }), options.terminalRows);
324
+ }
325
+ const openGoalCount = options.openGoalCount ?? 0;
326
+ if (!goal) {
327
+ // Unfocused panel when open goals exist; nothing when the pool is empty.
328
+ return boundWidgetRenderLines(openGoalCount > 0 ? renderUnfocusedDashboard(openGoalCount, theme, width, options.commands) : [], options.terminalRows);
329
+ }
330
+ const safeWidth = Math.max(1, width);
331
+ const otherCount = Math.max(0, openGoalCount - 1);
332
+ const model = options.model ?? deriveGoalDashboardModel(goal as GoalRecord | null, {
333
+ focused: true,
334
+ otherOpenGoals: otherCount,
335
+ ledgerEvents: options.ledgerEvents ?? [],
336
+ tasksDisabled: options.disableTasks === true,
337
+ commands: options.commands,
338
+ });
339
+ if (!model) return [];
340
+ const lines = options.expanded
341
+ ? renderExpandedDashboard(model, theme, safeWidth, { scrollOffset: options.expandedScrollOffset, rows: options.expandedTaskRows, keybindings: options.keybindings })
342
+ : renderCompactDashboard(model, theme, safeWidth, { scrollOffset: options.compactScrollOffset, keybindings: options.keybindings, auditorKey: options.auditorKey });
343
+ return boundWidgetRenderLines(clampLinesToWidth(lines, width), options.terminalRows);
344
+ }
345
+
346
+ export class GoalWidgetComponent implements Component {
347
+ private theme: Theme;
348
+ private tui: TUI;
349
+ private getGoal: () => GoalWidgetRecord | null;
350
+ private getOpenGoalCount: () => number;
351
+ private getAuditorProgress: () => AuditorWidgetProgress | null;
352
+ private getSettings: () => GoalSettings;
353
+ private getDebugMode: () => boolean;
354
+ private getStalled: () => boolean;
355
+ private getExpanded: () => boolean;
356
+ private getLedgerEvents: () => GoalLedgerEvent[];
357
+ private getAuditResult: () => AuditResultView | null;
358
+
359
+ // §9.6 scroll state: viewport offsets for the compact top-level list and
360
+ // the expanded tree, compact scroll-focus engagement, and the re-anchor
361
+ // bookkeeping (a new completion re-anchors to the latest completed task).
362
+ private compactScrollOffset = 0;
363
+ private expandedScrollOffset = 0;
364
+ private lastRenderWidth = 100;
365
+ private lastSeenGoalId: string | undefined;
366
+ private lastSeenLatestCompletedAt: string | undefined;
367
+
368
+ // Stable-height latch (spec 2026-08-11-stable-widget-height): the first
369
+ // render of each regime commits the rendered height (natural when it fits,
370
+ // else the cap); the height stays constant (head-slice growth, pad shrink)
371
+ // until the regime, the terminal size, or the measured dock chrome changes,
372
+ // so the buffer line count never changes and the terminal stops jumping to
373
+ // the bottom.
374
+ private stableHeightState: { stickyCap: number | undefined; stickyRegime: string | undefined; stickyTerminalRows: number | undefined; stickyReserve?: number } = {
375
+ stickyCap: undefined,
376
+ stickyRegime: undefined,
377
+ stickyTerminalRows: undefined,
378
+ stickyReserve: undefined,
379
+ };
380
+
381
+ constructor(options: GoalWidgetOptions) {
382
+ this.theme = options.theme;
383
+ this.tui = options.tui;
384
+ this.getGoal = options.getGoal;
385
+ this.getOpenGoalCount = options.getOpenGoalCount ?? (() => (this.getGoal() ? 1 : 0));
386
+ this.getAuditorProgress = options.getAuditorProgress ?? (() => null);
387
+ this.getSettings = options.getSettings ?? (() => ({}));
388
+ this.getDebugMode = options.getDebugMode ?? (() => false);
389
+ this.getStalled = options.getStalled ?? (() => false);
390
+ this.getExpanded = options.getExpanded ?? (() => false);
391
+ this.getLedgerEvents = options.getLedgerEvents ?? (() => []);
392
+ this.getAuditResult = options.getAuditResult ?? (() => null);
393
+ }
394
+
395
+ /** Whether the dashboard is currently in expanded mode (§10). */
396
+ isExpanded(): boolean {
397
+ return this.getExpanded();
398
+ }
399
+
400
+ /** Pi 1.4's public focus getter and main-editor app wiring survive custom editors. */
401
+ isMainEditorFocused(): boolean | undefined {
402
+ const tui = this.tui as TUI & {getFocusedComponent?: () => Component | null};
403
+ if (typeof tui.getFocusedComponent !== "function") return undefined;
404
+ const focused = tui.getFocusedComponent() as (Component & {onSubmit?: unknown; actionHandlers?: unknown}) | null;
405
+ // Pi copies these public app handlers when installing a replacement CustomEditor.
406
+ // A foreign text editor can have getText/setText without owning app actions.
407
+ return !!focused && typeof focused.onSubmit === "function"
408
+ && focused.actionHandlers instanceof Map && focused.actionHandlers.has("app.clear");
409
+ }
410
+
411
+ update(): void {
412
+ this.tui.requestRender();
413
+ }
414
+
415
+ /** Render debug info panel when debug mode is active */
416
+ private renderDebugPanel(width: number): string[] {
417
+ const t = this.theme;
418
+ const lines: string[] = [];
419
+ const safeWidth = Math.max(20, width);
420
+
421
+ // Divider
422
+ lines.push(t.fg("dim", "─".repeat(safeWidth)));
423
+ lines.push(t.fg("warning", "⊙ [DEBUG MODE]"));
424
+ lines.push("");
425
+
426
+ const goal = this.getGoal();
427
+ if (goal) {
428
+ lines.push(t.fg("dim", ` id: ${goal.id}`));
429
+ lines.push(t.fg("dim", ` status: ${goal.status}`));
430
+ lines.push(t.fg("dim", ` objective: ${truncateText(goal.objective, 80)}`));
431
+ lines.push(t.fg("dim", ` sisyphus: ${goal.sisyphus}`));
432
+ lines.push(t.fg("dim", ` autoContinue: ${goal.autoContinue}`));
433
+ lines.push(t.fg("dim", ` tokens: ${goal.usage.tokensUsed}`));
434
+ lines.push(t.fg("dim", ` activeSeconds: ${goal.usage.activeSeconds}`));
435
+ lines.push(t.fg("dim", ` createdAt: ${goal.createdAt}`));
436
+ lines.push(t.fg("dim", ` updatedAt: ${goal.updatedAt}`));
437
+ if (goal.pauseReason) lines.push(t.fg("dim", ` pauseReason: ${goal.pauseReason}`));
438
+ if (goal.pauseSuggestedAction) lines.push(t.fg("dim", ` pauseSuggestedAction: ${goal.pauseSuggestedAction}`));
439
+ if (goal.stopReason) lines.push(t.fg("dim", ` stopReason: ${goal.stopReason}`));
440
+ if (goal.activePath) lines.push(t.fg("dim", ` activePath: ${goal.activePath}`));
441
+ if (goal.archivedPath) lines.push(t.fg("dim", ` archivedPath: ${goal.archivedPath}`));
442
+ if (goal.verificationContract) lines.push(t.fg("dim", ` vContract: ${truncateText(goal.verificationContract, 60)}`));
443
+
444
+ // Task tree summary (from the shared dashboard model).
445
+ const model = deriveGoalDashboardModel(goal as GoalRecord | null, { focused: true, otherOpenGoals: 0 });
446
+ if (model?.taskProgress) {
447
+ lines.push(t.fg("dim", ` tasks: ${model.taskProgress.completed}/${model.taskProgress.total}`));
448
+ }
449
+ if (model?.currentTask) {
450
+ lines.push(t.fg("dim", ` next: ${model.currentTask.id} (${truncateText(model.currentTask.title, 40)})`));
451
+ }
452
+ } else {
453
+ lines.push(t.fg("dim", " (no goal)"));
454
+ }
455
+
456
+ lines.push("");
457
+ lines.push(t.fg("dim", "── Debug keybindings ──"));
458
+ lines.push(t.fg("dim", " Ctrl+Shift+X Toggle debug mode"));
459
+ lines.push(t.fg("dim", " Ctrl+Shift+N Create test goal"));
460
+ lines.push(t.fg("dim", " Ctrl+Shift+T Inject sample tasks"));
461
+ lines.push(t.fg("dim", " Ctrl+Shift+R Mock audit animation"));
462
+ lines.push(t.fg("dim", " Ctrl+Shift+O Open proposal dialog"));
463
+
464
+ return lines;
465
+ }
466
+
467
+ render(width: number): string[] {
468
+ const settings = this.getSettings();
469
+ this.lastRenderWidth = Math.max(1, width);
470
+ // Terminal-height bound (spec 2026-08-10): read pi's terminal rows at
471
+ // render time so the widget never fills the terminal — the chat/editor
472
+ // stay visible and scrollable and the widget can never trigger pi-tui's
473
+ // shrink full-render (\x1b[2J\x1b[H\x1b[3J) scrollback wipe. Same cast
474
+ // pattern as the questionnaire churn guard; mock TUIs without a
475
+ // `terminal` render unbounded.
476
+ const terminalRows = (this.tui as unknown as { terminal?: { rows?: number } }).terminal?.rows;
477
+ // Render the current branch unbounded (natural), then apply the
478
+ // stable-height bound (spec 2026-08-11): the rendered height latches at
479
+ // the first render of each regime and stays constant (fits and capped),
480
+ // so the dock height / buffer line count stop changing and the
481
+ // terminal stops jumping to the bottom.
482
+ const natural = this.renderNatural(width);
483
+ const regime = this.stableHeightRegime();
484
+ // Reserve adapted to the actual dock chrome (status + editor + footer +
485
+ // pending): the widget's block plus the chrome must never exceed the
486
+ // terminal, or the chat's appended lines and the status line land above
487
+ // pi-tui's viewport top and every agent write wipes the scrollback.
488
+ const reserve = this.measureDockReserve(width) ?? WIDGET_HEIGHT_RESERVE;
489
+ return applyStableHeightBound(natural, terminalRows, this.stableHeightState, regime, reserve);
490
+ }
491
+
492
+ /**
493
+ * Measure the rendered height of the dock chrome around the widget
494
+ * (pending + status + editor + widgetBelow + footer) at the current width,
495
+ * so the widget can size itself so its block plus the chrome never exceeds
496
+ * the terminal (spec 2026-08-11): pi-tui full-renders (2J+3J, scrollback
497
+ * wipe) whenever the first changed line is above its tracked viewport top
498
+ * (`previousBufferLength − terminalRows`); when the widget's block pushes
499
+ * the chat's append point and the status line above that, every agent
500
+ * write wipes the terminal scrollback and the user is forced to the
501
+ * bottom. Returns undefined (→ the static WIDGET_HEIGHT_RESERVE fallback)
502
+ * when the TUI exposes no measurable dock (mock TUIs, headless).
503
+ */
504
+ private measureDockReserve(width: number): number | undefined {
505
+ type DockChild = { children?: DockChild[]; render?: (w: number) => string[] };
506
+ const tui = this.tui as unknown as { children?: DockChild[] };
507
+ const top = tui.children;
508
+ if (!Array.isArray(top) || top.length < 2) return undefined;
509
+ // Locate the dock list and the widget's own container:
510
+ // regular mode — top-level [document, pending, status, widgetAbove,
511
+ // editor, widgetBelow, footer] with the widget directly inside
512
+ // widgetContainerAbove;
513
+ // fullscreen mode — top-level [transcript, dock] with the widget
514
+ // inside widgetContainerAbove inside the dock VStack.
515
+ let dockList: typeof top | undefined;
516
+ let widgetContainer: (typeof top)[number] | undefined;
517
+ for (const c of top) {
518
+ const kids = Array.isArray(c.children) ? c.children : undefined;
519
+ if (kids?.includes(this)) {
520
+ dockList = top;
521
+ widgetContainer = c;
522
+ break;
523
+ }
524
+ const nested = kids?.find((cc) => Array.isArray(cc.children) && cc.children!.includes(this)) as (typeof top)[number] | undefined;
525
+ if (nested) {
526
+ dockList = kids!;
527
+ widgetContainer = nested;
528
+ break;
529
+ }
530
+ }
531
+ if (!dockList || !widgetContainer) return undefined;
532
+ let total = 0;
533
+ for (let i = 0; i < dockList.length; i++) {
534
+ const c = dockList[i]!;
535
+ // Skip the document container / transcript (its height cancels out
536
+ // of the viewport math: it sits above both the chat's append point
537
+ // and the viewport top) and the widget's own container.
538
+ if (dockList === top && i === 0) continue;
539
+ if (c === widgetContainer) continue;
540
+ if (typeof c?.render !== "function") continue;
541
+ try {
542
+ const lines = c.render(width);
543
+ if (Array.isArray(lines)) total += lines.length;
544
+ } catch {
545
+ // A sibling render failure must never break the widget; fall
546
+ // back to the static reserve.
547
+ return undefined;
548
+ }
549
+ }
550
+ // +1 slack so the strictest safety condition (chat append point below
551
+ // the viewport top) holds even at the boundary.
552
+ return total + 1;
553
+ }
554
+
555
+ /** The current widget branch rendered unbounded (no terminal-height bound). */
556
+ private renderNatural(width: number): string[] {
557
+ const settings = this.getSettings();
558
+ const safeWidth = Math.max(1, width);
559
+ // §15.4: a finished audit shows its result card until cleared.
560
+ const auditResult = this.getAuditResult();
561
+ if (auditResult) {
562
+ return clampLinesToWidth(renderAuditResultCardView(auditResult, this.theme, width), width);
563
+ }
564
+ const goal = this.getGoal();
565
+ const otherCount = Math.max(0, this.getOpenGoalCount() - 1);
566
+ const model = goal ? deriveGoalDashboardModel(goal as GoalRecord | null, {
567
+ focused: true,
568
+ otherOpenGoals: otherCount,
569
+ ledgerEvents: this.getLedgerEvents(),
570
+ tasksDisabled: settings.disableTasks === true,
571
+ maxAutonomousRuns: settings.maxAutonomousRuns,
572
+ idleContinuationBackoff: settings.idleContinuationBackoff,
573
+ commands: settings.commands,
574
+ }) : null;
575
+ this.maybeReanchor(model);
576
+ const lines = renderGoalWidgetLines(this.getGoal(), this.theme, safeWidth, {
577
+ openGoalCount: this.getOpenGoalCount(),
578
+ auditorProgress: this.getAuditorProgress(),
579
+ disableTasks: settings.disableTasks,
580
+ stalled: this.getStalled(),
581
+ expanded: this.getExpanded(),
582
+ ledgerEvents: this.getLedgerEvents(),
583
+ debug: this.getDebugMode(),
584
+ model,
585
+ compactScrollOffset: this.compactScrollOffset,
586
+ expandedScrollOffset: this.expandedScrollOffset,
587
+ expandedTaskRows: expandedTaskViewportRows(this.lastRenderWidth),
588
+ keybindings: settings.keybindings?.dashboard,
589
+ auditorKey: settings.keybindings?.toggleAuditor,
590
+ commands: settings.commands,
591
+ interruptAuditKey: settings.keybindings?.interruptAudit,
592
+ });
593
+ if (this.getDebugMode()) {
594
+ lines.push(...this.renderDebugPanel(width));
595
+ }
596
+ return clampLinesToWidth(lines, width);
597
+ }
598
+
599
+ /**
600
+ * Stable-height regime key: the latch belongs to one goal, goal status,
601
+ * widget state kind (focused / audit / result / unfocused / none),
602
+ * expansion mode, debug mode, task setting, and task presence — a change
603
+ * clears the latch so the new mode starts from its own natural height.
604
+ * Task presence re-latches a structurally empty goal (0 tasks → tiny
605
+ * dashboard) when its first task appears — a rare structural jump, not
606
+ * steady-state churn.
607
+ */
608
+ private stableHeightRegime(): string {
609
+ const settings = this.getSettings();
610
+ const goal = this.getGoal();
611
+ let stateKind: string;
612
+ if (this.getAuditResult()) stateKind = "result";
613
+ else if (this.getAuditorProgress()) stateKind = "audit";
614
+ else if (goal) stateKind = "focused";
615
+ else stateKind = this.getOpenGoalCount() > 0 ? "unfocused" : "none";
616
+ const hasTasks = (goal?.taskList?.tasks?.length ?? 0) > 0;
617
+ return [goal?.id ?? "∅", goal?.status ?? "∅", stateKind, String(this.getExpanded()), String(this.getDebugMode()), String(settings.disableTasks === true), String(hasTasks)].join("|");
618
+ }
619
+
620
+ /**
621
+ * §9.6 re-anchor rule: when the goal changes or a new task completes (the
622
+ * latest completedAt moves), reset both viewports to the anchored defaults
623
+ * so the most recently completed work stays visible. Between such events
624
+ * the user's manual scroll position is preserved.
625
+ */
626
+ private maybeReanchor(model: GoalDashboardModel | null): void {
627
+ const latestAt = latestCompletedNodeIndex(model?.taskTree ?? []) >= 0
628
+ ? model!.taskTree[latestCompletedNodeIndex(model!.taskTree)]!.completedAt
629
+ : undefined;
630
+ if (model?.goalId === this.lastSeenGoalId && latestAt === this.lastSeenLatestCompletedAt) return;
631
+ this.lastSeenGoalId = model?.goalId;
632
+ this.lastSeenLatestCompletedAt = latestAt;
633
+ if (!model || model.taskTree.length === 0) {
634
+ this.compactScrollOffset = 0;
635
+ this.expandedScrollOffset = 0;
636
+ return;
637
+ }
638
+ const topLevel = model.taskTree.filter((n) => n.depth === 0);
639
+ this.compactScrollOffset = anchoredScrollOffset(topLevel, compactTaskViewportRows(this.lastRenderWidth));
640
+ this.expandedScrollOffset = anchoredScrollOffset(model.taskTree, expandedTaskViewportRows(this.lastRenderWidth));
641
+ }
642
+
643
+ /**
644
+ * Scroll the compact task list with a Ctrl+Shift chord (§9.6). The chords
645
+ * are free in pi (the editor owns the plain arrows), so no focus state is
646
+ * needed — but they are consumed only when the compact list actually
647
+ * overflows its viewport, so they never swallow keystrokes for a short
648
+ * list. Returns true when the key was consumed.
649
+ */
650
+ handleCompactScrollKey(key: "up" | "down" | "pageUp" | "pageDown" | "home" | "end"): boolean {
651
+ if (this.getExpanded()) return false;
652
+ const settings = this.getSettings();
653
+ const goal = this.getGoal();
654
+ const model = goal ? deriveGoalDashboardModel(goal as GoalRecord | null, {
655
+ focused: true,
656
+ otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
657
+ ledgerEvents: this.getLedgerEvents(),
658
+ tasksDisabled: settings.disableTasks === true,
659
+ maxAutonomousRuns: settings.maxAutonomousRuns,
660
+ idleContinuationBackoff: settings.idleContinuationBackoff,
661
+ commands: settings.commands,
662
+ }) : null;
663
+ this.maybeReanchor(model);
664
+ const list = model?.taskTree.filter((n) => n.depth === 0) ?? [];
665
+ const rows = compactTaskViewportRows(this.lastRenderWidth);
666
+ if (list.length <= rows) return false;
667
+ let offset = clampScrollOffset(this.compactScrollOffset, list.length, rows);
668
+ if (key === "up") offset -= 1;
669
+ else if (key === "down") offset += 1;
670
+ else if (key === "pageUp") offset -= taskViewportPageSize(rows);
671
+ else if (key === "pageDown") offset += taskViewportPageSize(rows);
672
+ else if (key === "home") offset = 0;
673
+ else if (key === "end") offset = maxScrollOffset(list.length, rows);
674
+ offset = clampScrollOffset(offset, list.length, rows);
675
+ this.compactScrollOffset = offset;
676
+ this.tui.requestRender();
677
+ return true;
678
+ }
679
+
680
+ /**
681
+ * Handle a navigation key for the expanded dashboard. The expanded
682
+ * dashboard is modal — while it is open it owns the arrow keys (like the
683
+ * session tree or model selector), so plain ↑/↓/PgUp/PgDn scroll the task
684
+ * tree and Esc collapses. Returns true when the key was consumed.
685
+ * Clamps at both ends; Home/End jump to the edges; PgUp/PgDn page by one
686
+ * viewport.
687
+ */
688
+ handleNavigationKey(key: "up" | "down" | "pageUp" | "pageDown" | "home" | "end"): boolean {
689
+ if (!this.getExpanded()) return false;
690
+ const settings = this.getSettings();
691
+ const goal = this.getGoal();
692
+ const model = goal ? deriveGoalDashboardModel(goal as GoalRecord | null, {
693
+ focused: true,
694
+ otherOpenGoals: Math.max(0, this.getOpenGoalCount() - 1),
695
+ ledgerEvents: this.getLedgerEvents(),
696
+ tasksDisabled: settings.disableTasks === true,
697
+ maxAutonomousRuns: settings.maxAutonomousRuns,
698
+ idleContinuationBackoff: settings.idleContinuationBackoff,
699
+ commands: settings.commands,
700
+ }) : null;
701
+ this.maybeReanchor(model);
702
+ const list = model?.taskTree ?? [];
703
+ if (list.length === 0) return false;
704
+ const rows = expandedTaskViewportRows(this.lastRenderWidth);
705
+ const maxO = maxScrollOffset(list.length, rows);
706
+ if (maxO <= 0) return true;
707
+ let offset = clampScrollOffset(this.expandedScrollOffset, list.length, rows);
708
+ if (key === "up") offset -= 1;
709
+ else if (key === "down") offset += 1;
710
+ else if (key === "pageUp") offset -= taskViewportPageSize(rows);
711
+ else if (key === "pageDown") offset += taskViewportPageSize(rows);
712
+ else if (key === "home") offset = 0;
713
+ else if (key === "end") offset = maxO;
714
+ offset = clampScrollOffset(offset, list.length, rows);
715
+ this.expandedScrollOffset = offset;
716
+ this.tui.requestRender();
717
+ return true;
718
+ }
719
+
720
+ invalidate(): void {
721
+ this.tui.requestRender();
722
+ }
723
+ }