@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,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
|
+
}
|