@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,674 @@
|
|
|
1
|
+
import type { GoalKeybinding } from "../goal-controls.ts";
|
|
2
|
+
import { bindingKeys, commandHint, type GoalCommands } from "../goal-controls.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Unified dashboard renderer (plan §5, §4).
|
|
5
|
+
*
|
|
6
|
+
* Renders the shared dashboard view model (goal-dashboard-model.ts) as the
|
|
7
|
+
* compact above-editor widget, the expanded full dashboard (which replaces the
|
|
8
|
+
* task overlay), and the unfocused panel. Pure presentation: all data comes
|
|
9
|
+
* from the model; this file owns the §5 visual spec (borders, status symbols,
|
|
10
|
+
* progress bars, responsive layouts, width-safe ANSI/Unicode handling) and
|
|
11
|
+
* must never emit a line wider than the requested terminal width.
|
|
12
|
+
*
|
|
13
|
+
* Layout modes (§5.5): wide ≥100, medium 70–99, narrow 50–69, minimal <50.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
18
|
+
import { truncateToWidth, wrapTextWithAnsi } from "./text-cache.ts";
|
|
19
|
+
import {
|
|
20
|
+
anchoredScrollOffset,
|
|
21
|
+
compactTaskViewportRows,
|
|
22
|
+
deriveTaskListViewport,
|
|
23
|
+
formatAuditElapsed,
|
|
24
|
+
formatBudget,
|
|
25
|
+
type DashboardStatusCode,
|
|
26
|
+
type DashboardTaskNode,
|
|
27
|
+
type GoalDashboardModel,
|
|
28
|
+
type TaskListViewport,
|
|
29
|
+
} from "./goal-dashboard-model.ts";
|
|
30
|
+
import type { GoalActivityItem } from "../goal-activity.ts";
|
|
31
|
+
import { truncateText } from "../goal-core.ts";
|
|
32
|
+
import { DEFAULT_GOAL_KEYBINDINGS, formatGoalKeybinding, type GoalDashboardKeybindings } from "../goal-settings.ts";
|
|
33
|
+
|
|
34
|
+
// mdLink is deliberately excluded: the chrome must stay neutral gray, not
|
|
35
|
+
// blue-tinged ("more grey than blue").
|
|
36
|
+
type RenderColor = Extract<ThemeColor, "accent" | "warning" | "success" | "error" | "dim" | "muted" | "text" | "mdHeading">;
|
|
37
|
+
|
|
38
|
+
// ── §5.1 border system ──────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const H = "─";
|
|
41
|
+
const V = "│";
|
|
42
|
+
|
|
43
|
+
/** Box chrome: the entire frame — corners, top/bottom dashes, left/right
|
|
44
|
+
* edges and interior rules — is one tone: the theme's neutral gray (muted).
|
|
45
|
+
* No blue tinge: the chrome is grey, while the content inside is
|
|
46
|
+
* colour-coded. */
|
|
47
|
+
function frame(theme: Theme, value: string): string {
|
|
48
|
+
return theme.fg("muted", value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fit(value: string, width: number): string {
|
|
52
|
+
return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boxHeader(theme: Theme, width: number, left: string, right = ""): string {
|
|
56
|
+
const inner = Math.max(4, width - 2);
|
|
57
|
+
// One-tone frame line: corners, leading/trailing dashes and fill all carry
|
|
58
|
+
// the frame color; only the brand inside stays accent.
|
|
59
|
+
const l = `${frame(theme, H)} ${left}`;
|
|
60
|
+
const r = right ? ` ${right} ${frame(theme, H)}` : "";
|
|
61
|
+
const fixed = visibleWidth(l) + visibleWidth(r);
|
|
62
|
+
if (fixed > inner - 2) {
|
|
63
|
+
// Too tight: truncate the title so the right-side meta survives.
|
|
64
|
+
const budget = Math.max(4, inner - visibleWidth(r) - 4);
|
|
65
|
+
const l2 = `${frame(theme, H)} ${fit(left, budget)}`;
|
|
66
|
+
const fill = Math.max(1, inner - visibleWidth(l2) - visibleWidth(r));
|
|
67
|
+
return `${frame(theme, "╭")}${l2}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`;
|
|
68
|
+
}
|
|
69
|
+
const fill = Math.max(1, inner - fixed);
|
|
70
|
+
return `${frame(theme, "╭")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function boxLine(theme: Theme, width: number, content: string): string {
|
|
74
|
+
const inner = Math.max(2, width - 2);
|
|
75
|
+
const contentFit = fit(content, inner - 1);
|
|
76
|
+
const pad = Math.max(0, inner - 1 - visibleWidth(contentFit));
|
|
77
|
+
return `${frame(theme, V)} ${contentFit}${" ".repeat(pad)}${frame(theme, V)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function boxRule(theme: Theme, width: number): string {
|
|
81
|
+
return frame(theme, `├${H.repeat(Math.max(1, width - 2))}┤`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Section separator with a label and optional right-side content:
|
|
85
|
+
* `├─ Tasks ──── [████░] · Sub 2/3 [██░░] ┤` (§5.1). The right content
|
|
86
|
+
* (e.g. the header progress bars) always survives; the label truncates when
|
|
87
|
+
* tight. Hardened so label + right never exceed the inner width: the fill is
|
|
88
|
+
* `max(0, …)` and the truncation budget leaves room for the `─ ` prefix and
|
|
89
|
+
* trailing space. */
|
|
90
|
+
function boxSectionRule(theme: Theme, width: number, label: string, right = ""): string {
|
|
91
|
+
const inner = Math.max(4, width - 2);
|
|
92
|
+
const left = frame(theme, `${H} ${label} `);
|
|
93
|
+
const r = right ? ` ${right} ` : "";
|
|
94
|
+
let l = left;
|
|
95
|
+
if (visibleWidth(left) + visibleWidth(r) > inner) {
|
|
96
|
+
const budget = Math.max(4, inner - visibleWidth(r) - 3);
|
|
97
|
+
l = frame(theme, `${H} ${fit(label, budget)} `);
|
|
98
|
+
}
|
|
99
|
+
const fill = Math.max(0, inner - visibleWidth(l) - visibleWidth(r));
|
|
100
|
+
return `${frame(theme, "├")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "┤")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function boxFooter(theme: Theme, width: number, content: string, right = ""): string {
|
|
104
|
+
const inner = Math.max(4, width - 2);
|
|
105
|
+
// Optional right slot mirrors boxHeader's right-meta treatment (` ● ─`
|
|
106
|
+
// before the corner), so the auditor dot sits at the bottom-right of the
|
|
107
|
+
// border. The dot survives; the hint (left content) truncates when tight.
|
|
108
|
+
const r = right ? ` ${right} ${frame(theme, H)}` : "";
|
|
109
|
+
if (!content && !r) {
|
|
110
|
+
return frame(theme, `╰${H.repeat(inner)}╯`);
|
|
111
|
+
}
|
|
112
|
+
// The footer is one frame tone: leading dash, hint text and trailing fill
|
|
113
|
+
// all carry the frame color, so the blue-gray spans the whole line.
|
|
114
|
+
const l = content ? frame(theme, `${H} ${fit(content, Math.max(0, inner - 4 - visibleWidth(r)))}`) : "";
|
|
115
|
+
const fill = Math.max(1, inner - visibleWidth(l) - visibleWidth(r));
|
|
116
|
+
return `${frame(theme, "╰")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╯")}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── §5.2 status colors ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const STATUS_COLOR: Record<DashboardStatusCode, RenderColor> = {
|
|
122
|
+
running: "accent",
|
|
123
|
+
idle: "muted",
|
|
124
|
+
paused: "muted",
|
|
125
|
+
blocked: "error",
|
|
126
|
+
budget_limited: "mdHeading",
|
|
127
|
+
complete: "success",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Widget status line — footer-status parity: `goal: <label> [<usage>] (+N open)`.
|
|
132
|
+
* Same terminology/format as the pi footer status line (goal-core statusLabel +
|
|
133
|
+
* usage bracket), without the objective preview (the header title shows it) and
|
|
134
|
+
* without the old `Focused: yes` / `Other goals: N` bits. The open-goals count
|
|
135
|
+
* appears only when other open goals exist.
|
|
136
|
+
*/
|
|
137
|
+
function statusLine(theme: Theme, model: GoalDashboardModel): string {
|
|
138
|
+
const color = STATUS_COLOR[model.status.code];
|
|
139
|
+
const usage = model.usage.footerBits ? ` [${model.usage.footerBits}]` : "";
|
|
140
|
+
const open = model.otherOpenGoals > 0 ? ` (+${model.otherOpenGoals} open)` : "";
|
|
141
|
+
return `${theme.fg(color, `goal: ${model.status.footerLabel}`)}${muted(theme, `${usage}${open}`)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── §5.3 progress bars ──────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function progressBar(theme: Theme, pct: number, barWidth: number): string {
|
|
147
|
+
const safeBar = Math.max(2, barWidth);
|
|
148
|
+
const filled = Math.min(safeBar, Math.max(0, Math.round((pct / 100) * safeBar)));
|
|
149
|
+
// The bar is muted like the rest of the progress info: neutral-gray fill
|
|
150
|
+
// with dim empty cells — no accent teal anywhere in the progress element.
|
|
151
|
+
return `[${theme.fg("muted", "█".repeat(filled))}${theme.fg("dim", "░".repeat(safeBar - filled))}]`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Layout modes (§5.5) ─────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
type LayoutMode = "wide" | "medium" | "narrow" | "minimal";
|
|
157
|
+
|
|
158
|
+
function layoutMode(width: number): LayoutMode {
|
|
159
|
+
if (width >= 100) return "wide";
|
|
160
|
+
if (width >= 70) return "medium";
|
|
161
|
+
if (width >= 50) return "narrow";
|
|
162
|
+
return "minimal";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface LayoutSpec {
|
|
166
|
+
barWidth: number;
|
|
167
|
+
/** Compact progress bar width inside the Tasks header row (bar at the end). */
|
|
168
|
+
headerBarWidth: number;
|
|
169
|
+
/** Subtask progress bar width inside the Tasks header row, beside the task
|
|
170
|
+
* bar (wide/medium/narrow; minimal omits the segment — unused there). */
|
|
171
|
+
subtaskBarWidth: number;
|
|
172
|
+
showPath: boolean;
|
|
173
|
+
showPauseAction: boolean;
|
|
174
|
+
footerHint: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function specFor(mode: LayoutMode): LayoutSpec {
|
|
178
|
+
switch (mode) {
|
|
179
|
+
case "wide":
|
|
180
|
+
return { barWidth: 26, headerBarWidth: 8, subtaskBarWidth: 4, showPath: true, showPauseAction: true, footerHint: "Ctrl+Shift+T: expand tasks" };
|
|
181
|
+
case "medium":
|
|
182
|
+
return { barWidth: 18, headerBarWidth: 6, subtaskBarWidth: 3, showPath: true, showPauseAction: true, footerHint: "Ctrl+Shift+T: expand tasks" };
|
|
183
|
+
case "narrow":
|
|
184
|
+
return { barWidth: 12, headerBarWidth: 5, subtaskBarWidth: 2, showPath: false, showPauseAction: false, footerHint: "Ctrl+Shift+T: expand" };
|
|
185
|
+
case "minimal":
|
|
186
|
+
return { barWidth: 8, headerBarWidth: 4, subtaskBarWidth: 2, showPath: false, showPauseAction: false, footerHint: "Ctrl+Shift+T: expand" };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── shared row helpers ──────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function muted(theme: Theme, value: string): string {
|
|
193
|
+
return theme.fg("muted", value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function dim(theme: Theme, value: string): string {
|
|
197
|
+
return theme.fg("dim", value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function accent(theme: Theme, value: string): string {
|
|
201
|
+
return theme.fg("accent", value);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function success(theme: Theme, value: string): string {
|
|
205
|
+
return theme.fg("success", value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Pastel accent helpers (§5): amber for tasks, teal for current/progress,
|
|
209
|
+
* muted gray for chrome, soft red for blockers, muted green for complete. */
|
|
210
|
+
function amber(theme: Theme, value: string): string {
|
|
211
|
+
return theme.fg("mdHeading", value);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── task tree rows (§9.2) ───────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** Task rows: pending amber ·, complete muted-green ✓, skipped gray ~, and
|
|
217
|
+
* the current task teal ▸ with accent text; row text is pastel amber. */
|
|
218
|
+
function taskMarker(node: DashboardTaskNode): { symbol: string; color: RenderColor } {
|
|
219
|
+
if (node.isCurrent) return { symbol: "▸", color: "accent" };
|
|
220
|
+
if (node.status === "complete") return { symbol: "✓", color: "success" };
|
|
221
|
+
if (node.status === "skipped") return { symbol: "~", color: "muted" };
|
|
222
|
+
return { symbol: "·", color: "mdHeading" };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function renderTaskRow(theme: Theme, node: DashboardTaskNode, indent: number, available: number): string {
|
|
226
|
+
const marker = taskMarker(node);
|
|
227
|
+
const indentText = " ".repeat(indent);
|
|
228
|
+
const prefix = `${indentText}${marker.symbol} ${node.id} `;
|
|
229
|
+
const contractMark = node.verificationContract ? dim(theme, " ☑") : "";
|
|
230
|
+
const titleBudget = Math.max(4, available - visibleWidth(prefix) - visibleWidth(contractMark));
|
|
231
|
+
const title = fit(node.title, titleBudget);
|
|
232
|
+
const markerText = theme.fg(marker.color, marker.symbol);
|
|
233
|
+
// Colour-coded: the id shares the marker color (amber pending, green
|
|
234
|
+
// complete, gray skipped, teal current); titles stay amber, the current
|
|
235
|
+
// task is fully accent.
|
|
236
|
+
const idText = node.isCurrent ? accent(theme, node.id) : theme.fg(marker.color, node.id);
|
|
237
|
+
const titleText = node.isCurrent ? accent(theme, title) : amber(theme, title);
|
|
238
|
+
return `${indentText}${markerText} ${idText} ${titleText}${contractMark}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Compact task-list rows (§9.2, §9.6): top-level tasks shown in a window over
|
|
243
|
+
* the plan-ordered list with colored markers and truncated titles, an aligned
|
|
244
|
+
* id column, and `↑ N more` / `… +N more` indicator rows so the widget height
|
|
245
|
+
* stays bounded. The window is a viewport (offset + rows) derived from the
|
|
246
|
+
* shared model — the default (anchored) offset is computed by the caller when
|
|
247
|
+
* no explicit offset is given.
|
|
248
|
+
*/
|
|
249
|
+
function renderCompactTaskRows(theme: Theme, nodes: DashboardTaskNode[], viewport: TaskListViewport, available: number): string[] {
|
|
250
|
+
const rows: string[] = [];
|
|
251
|
+
if (viewport.hiddenAbove > 0) {
|
|
252
|
+
rows.push(muted(theme, `↑ ${viewport.hiddenAbove} more task${viewport.hiddenAbove === 1 ? "" : "s"}`));
|
|
253
|
+
}
|
|
254
|
+
const shown = nodes.slice(viewport.offset, viewport.offset + viewport.rows);
|
|
255
|
+
const idWidth = shown.length === 0 ? 0 : Math.min(10, Math.max(...shown.map((node) => node.id.length)));
|
|
256
|
+
for (const node of shown) {
|
|
257
|
+
const marker = taskMarker(node);
|
|
258
|
+
const contractMark = node.verificationContract ? dim(theme, " ☑") : "";
|
|
259
|
+
// §9.3 compact subtask marker: direct-child done/total for tasks that
|
|
260
|
+
// have subtasks, muted so it stays annotation — the current-task bar
|
|
261
|
+
// below carries the visual weight.
|
|
262
|
+
const subtaskMark = node.totalSubtasks > 0 ? muted(theme, ` ▸ ${node.completedSubtasks}/${node.totalSubtasks}`) : "";
|
|
263
|
+
const id = node.id.padEnd(idWidth);
|
|
264
|
+
const prefix = `${marker.symbol} ${id} `;
|
|
265
|
+
const titleBudget = Math.max(4, available - visibleWidth(prefix) - visibleWidth(contractMark) - visibleWidth(subtaskMark));
|
|
266
|
+
const title = fit(node.title, titleBudget);
|
|
267
|
+
const markerText = theme.fg(marker.color, marker.symbol);
|
|
268
|
+
// Colour-coded: id shares the marker color; titles amber; current accent.
|
|
269
|
+
const idText = node.isCurrent ? accent(theme, id) : theme.fg(marker.color, id);
|
|
270
|
+
const body = node.isCurrent ? accent(theme, title) : amber(theme, title);
|
|
271
|
+
rows.push(`${markerText} ${idText} ${body}${contractMark}${subtaskMark}`);
|
|
272
|
+
}
|
|
273
|
+
if (viewport.hiddenBelow > 0) {
|
|
274
|
+
rows.push(muted(theme, `… +${viewport.hiddenBelow} more task${viewport.hiddenBelow === 1 ? "" : "s"}`));
|
|
275
|
+
}
|
|
276
|
+
return rows;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── activity rows ───────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function activityMarker(item: GoalActivityItem): { symbol: string; color: RenderColor } {
|
|
282
|
+
if (item.marker === "done") return { symbol: "✓", color: "success" };
|
|
283
|
+
if (item.marker === "current") return { symbol: "▸", color: "accent" };
|
|
284
|
+
if (item.marker === "skipped") return { symbol: "~", color: "mdHeading" };
|
|
285
|
+
return { symbol: "·", color: "muted" };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── COMPACT DASHBOARD (§4.1) ────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Persistent summary above the editor while a goal is focused. Visually rich
|
|
292
|
+
* but compact; the footer hints at the expansion shortcut.
|
|
293
|
+
*/
|
|
294
|
+
export function renderCompactDashboard(
|
|
295
|
+
model: GoalDashboardModel,
|
|
296
|
+
theme: Theme,
|
|
297
|
+
width: number,
|
|
298
|
+
opts: { footerHint?: string; scrollOffset?: number; keybindings?: GoalDashboardKeybindings; auditorKey?: GoalKeybinding } = {},
|
|
299
|
+
): string[] {
|
|
300
|
+
const safeWidth = Math.max(10, width);
|
|
301
|
+
const mode = layoutMode(safeWidth);
|
|
302
|
+
const spec = specFor(mode);
|
|
303
|
+
const inner = safeWidth - 2;
|
|
304
|
+
const lines: string[] = [];
|
|
305
|
+
|
|
306
|
+
lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
|
|
307
|
+
|
|
308
|
+
// Status line.
|
|
309
|
+
if (model.status.code === "complete") {
|
|
310
|
+
lines.push(boxLine(theme, safeWidth, `${success(theme, "✓")} ${success(theme, "All required work is complete.")}`));
|
|
311
|
+
} else {
|
|
312
|
+
lines.push(boxLine(theme, safeWidth, model.idlePacing ? fit(model.idlePacing, inner) : statusLine(theme, model)));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const row of model.scheduling ?? []) if (row !== model.idlePacing) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
|
|
316
|
+
|
|
317
|
+
// Budget (when configured): fuel gauge + amount, amber until the budget is
|
|
318
|
+
// exhausted, then soft red.
|
|
319
|
+
if (model.budget) {
|
|
320
|
+
const fuel = model.budget.used >= model.budget.total ? "error" : "mdHeading";
|
|
321
|
+
lines.push(boxLine(theme, safeWidth, `${theme.fg(fuel as RenderColor, "⛽")} ${muted(theme, "Budget")} ${theme.fg(fuel as RenderColor, formatBudget(model.budget.used, model.budget.total))}`));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// §auditor-toggle: the focused goal's independent-auditor status is the
|
|
325
|
+
// bottom-right border dot (green on / muted gray off, see the footer
|
|
326
|
+
// below); wide/medium footers right-align a note next to it explaining
|
|
327
|
+
// that Ctrl+Shift+A turns the auditor on and off.
|
|
328
|
+
|
|
329
|
+
// §9.1 compact task counts + progress bars live in the Tasks header row
|
|
330
|
+
// (below); the standalone progress lines were removed in favor of it.
|
|
331
|
+
|
|
332
|
+
// §9.2/§9.6 compact task list: a window over the top-level tasks, anchored
|
|
333
|
+
// by default so the most recently completed tasks are visible; when the
|
|
334
|
+
// list overflows the window, the footer advertises Ctrl+Shift+↑↓ to scroll
|
|
335
|
+
// it. The current task's subtask progress lives in the header segment, and
|
|
336
|
+
// per-row subtask markers show which tasks have subtasks.
|
|
337
|
+
const topLevel = model.taskTree.filter((node) => node.depth === 0);
|
|
338
|
+
const compactRows = compactTaskViewportRows(safeWidth);
|
|
339
|
+
const listOverflows = topLevel.length > compactRows;
|
|
340
|
+
if (topLevel.length > 0) {
|
|
341
|
+
const offset = opts.scrollOffset ?? anchoredScrollOffset(topLevel, compactRows);
|
|
342
|
+
const viewport = deriveTaskListViewport(topLevel.length, compactRows, offset);
|
|
343
|
+
// §9.1/§9.3 header row: counts first (`✓N done · M open`, skipped
|
|
344
|
+
// counts as done), compact task progress bar, then the current task's
|
|
345
|
+
// subtask progress bar beside it (` · Sub done/total `, wide/medium;
|
|
346
|
+
// narrow drops the word `Sub` so the full counts fit at 50 cols;
|
|
347
|
+
// minimal omits the segment — counts + task bar alone cannot share 38
|
|
348
|
+
// inner columns with a second bar). All muted: the header stays one
|
|
349
|
+
// frame-tone block. The standalone compact progress lines were removed
|
|
350
|
+
// in favor of this row.
|
|
351
|
+
const headerLabel = model.taskProgress
|
|
352
|
+
? `Tasks · ✓${model.taskProgress.completed} done · ${model.taskProgress.total - model.taskProgress.completed} open`
|
|
353
|
+
: "Tasks";
|
|
354
|
+
let headerRight = model.taskProgress ? progressBar(theme, model.taskProgress.percentage, spec.headerBarWidth) : "";
|
|
355
|
+
if (model.currentTask && model.currentTask.totalSubtasks > 0 && mode !== "minimal") {
|
|
356
|
+
const subBar = progressBar(theme, model.currentTask.subtaskPercentage, spec.subtaskBarWidth);
|
|
357
|
+
const subWord = mode === "narrow" ? "" : "Sub ";
|
|
358
|
+
headerRight += `${muted(theme, ` · ${subWord}`)}${muted(theme, `${model.currentTask.completedSubtasks}/${model.currentTask.totalSubtasks}`)} ${subBar}`;
|
|
359
|
+
}
|
|
360
|
+
lines.push(boxSectionRule(theme, safeWidth, headerLabel, headerRight));
|
|
361
|
+
for (const row of renderCompactTaskRows(theme, topLevel, viewport, inner)) {
|
|
362
|
+
lines.push(boxLine(theme, safeWidth, row));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// §9.4: all top-level tasks done and no current task → "All tasks complete".
|
|
367
|
+
if (!model.currentTask && model.taskProgress && model.taskProgress.completed === model.taskProgress.total && model.status.code !== "complete") {
|
|
368
|
+
lines.push(boxLine(theme, safeWidth, `${muted(theme, "Current")} ${accent(theme, "All tasks complete")}`));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Current task (persisted focus or inferred first pending).
|
|
372
|
+
if (model.currentTask) {
|
|
373
|
+
if (mode === "minimal") {
|
|
374
|
+
lines.push(boxLine(theme, safeWidth, `${accent(theme, "▸")} ${accent(theme, fit(model.currentTask.title, inner - 3))}`));
|
|
375
|
+
} else {
|
|
376
|
+
const titleBudget = inner - visibleWidth(`Current ${model.currentTask.id} · `);
|
|
377
|
+
lines.push(boxLine(theme, safeWidth, `${muted(theme, "Current")} ${accent(theme, model.currentTask.id)} · ${accent(theme, fit(model.currentTask.title, titleBudget))}`));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Current-task subtask progress moved into the Tasks header row (§9.3);
|
|
382
|
+
// the standalone compact Subtasks progress line was removed. (The
|
|
383
|
+
// expanded dashboard's Current-task block still shows it.)
|
|
384
|
+
|
|
385
|
+
// Goal-level verification (§11.1): truncated first line in compact.
|
|
386
|
+
if (model.goalVerificationContract && mode !== "minimal") {
|
|
387
|
+
lines.push(boxLine(theme, safeWidth, `${muted(theme, "Verify")} ${fit(model.goalVerificationContract.replace(/\s+/g, " "), inner - 9)}`));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Blocked details (§4.5).
|
|
391
|
+
if (model.status.code === "blocked") {
|
|
392
|
+
if (model.status.reason) lines.push(boxLine(theme, safeWidth, `${theme.fg("error", "Blocker")} ${muted(theme, fit(model.status.reason, inner - 10))}`));
|
|
393
|
+
if (spec.showPauseAction && model.status.suggestedAction) lines.push(boxLine(theme, safeWidth, `${muted(theme, "Action")} ${fit(model.status.suggestedAction, inner - 9)}`));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Paused details (§4.4).
|
|
397
|
+
if (model.status.code === "paused" && model.status.reason) {
|
|
398
|
+
lines.push(boxLine(theme, safeWidth, `${muted(theme, "Reason")} ${fit(model.status.reason, inner - 9)}`));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// File path (§4.1 example shows the active file).
|
|
402
|
+
if (spec.showPath && model.filePath) {
|
|
403
|
+
lines.push(boxLine(theme, safeWidth, `${dim(theme, `File ${model.filePath}`)}`));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const keybindings = opts.keybindings ?? DEFAULT_GOAL_KEYBINDINGS.dashboard;
|
|
407
|
+
const toggleShortcut = formatGoalKeybinding(keybindings.toggleExpand);
|
|
408
|
+
const scrollShortcut = `${formatGoalKeybinding(keybindings.scrollUp)}/${formatGoalKeybinding(keybindings.scrollDown)}`;
|
|
409
|
+
const usesDefaultScrollKeys = keybindings.scrollUp === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollUp && keybindings.scrollDown === DEFAULT_GOAL_KEYBINDINGS.dashboard.scrollDown;
|
|
410
|
+
const scrollHint = usesDefaultScrollKeys
|
|
411
|
+
? mode === "minimal" ? "↑↓" : "Ctrl+Shift+↑↓"
|
|
412
|
+
: scrollShortcut;
|
|
413
|
+
const expandHint = !bindingKeys(keybindings.toggleExpand).length ? "" : mode === "wide" || mode === "medium"
|
|
414
|
+
? `${toggleShortcut}: expand tasks`
|
|
415
|
+
: `${toggleShortcut}: expand`;
|
|
416
|
+
const footerHint = opts.footerHint
|
|
417
|
+
?? (listOverflows
|
|
418
|
+
? mode === "wide" || mode === "medium"
|
|
419
|
+
? `${toggleShortcut}: expand · ${scrollHint}: scroll`
|
|
420
|
+
: `${scrollHint}: scroll`
|
|
421
|
+
: expandHint);
|
|
422
|
+
// The auditor dot sits at the bottom-right of the border: green when the
|
|
423
|
+
// focused goal's independent auditor is on, muted gray when off. In
|
|
424
|
+
// wide/medium a right-aligned note (same frame tone as the hint) explains
|
|
425
|
+
// that Ctrl+Shift+A turns the auditor on and off; narrow/minimal keep
|
|
426
|
+
// just the dot. The boxFooter right slot pushes both to the right edge,
|
|
427
|
+
// so the chord and its note are right-aligned by construction.
|
|
428
|
+
const auditorDot = model.auditorEnabled ? success(theme, "●") : muted(theme, "●");
|
|
429
|
+
const auditorKey = opts.auditorKey ?? "ctrl+shift+a";
|
|
430
|
+
const right =
|
|
431
|
+
(mode === "wide" || mode === "medium") && bindingKeys(auditorKey).length
|
|
432
|
+
? `${frame(theme, `${formatGoalKeybinding(auditorKey)}: toggle auditor`)} ${auditorDot}`
|
|
433
|
+
: auditorDot;
|
|
434
|
+
lines.push(boxFooter(theme, safeWidth, footerHint, right));
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── EXPANDED DASHBOARD (§4.2) ───────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The full dashboard: goal header, status, usage, progress, a window over the
|
|
442
|
+
* task tree, current-task details with contract/evidence, verification, and
|
|
443
|
+
* recent activity. Replaces the separate task overlay. The task-tree window
|
|
444
|
+
* defaults to the whole tree; pass `rows` (and optionally `scrollOffset`) to
|
|
445
|
+
* bound the panel for interactive scrolling — the default offset anchors to
|
|
446
|
+
* the most recently completed task.
|
|
447
|
+
*/
|
|
448
|
+
export function renderExpandedDashboard(
|
|
449
|
+
model: GoalDashboardModel,
|
|
450
|
+
theme: Theme,
|
|
451
|
+
width: number,
|
|
452
|
+
opts: { scrollOffset?: number; rows?: number; keybindings?: GoalDashboardKeybindings } = {},
|
|
453
|
+
): string[] {
|
|
454
|
+
const safeWidth = Math.max(10, width);
|
|
455
|
+
const mode = layoutMode(safeWidth);
|
|
456
|
+
const spec = specFor(mode);
|
|
457
|
+
const inner = safeWidth - 2;
|
|
458
|
+
const lines: string[] = [];
|
|
459
|
+
|
|
460
|
+
lines.push(boxHeader(theme, safeWidth, `${accent(theme, "pi-goal-x")} ${frame(theme, `─ ${model.title}`)}`));
|
|
461
|
+
|
|
462
|
+
lines.push(boxLine(theme, safeWidth, statusLine(theme, model)));
|
|
463
|
+
for (const row of model.scheduling ?? []) lines.push(boxLine(theme, safeWidth, muted(theme, fit(row, inner))));
|
|
464
|
+
|
|
465
|
+
if (spec.showPath && model.filePath) {
|
|
466
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, `File: ${model.filePath}`)));
|
|
467
|
+
}
|
|
468
|
+
if (model.budget) {
|
|
469
|
+
lines.push(boxLine(theme, safeWidth, `${theme.fg("mdHeading", "⛽")} ${muted(theme, `Budget ${formatBudget(model.budget.used, model.budget.total)}`)}`));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Progress section (§4.2).
|
|
473
|
+
if (model.taskProgress) {
|
|
474
|
+
lines.push(boxSectionRule(theme, safeWidth, "Progress"));
|
|
475
|
+
const bar = progressBar(theme, model.taskProgress.percentage, Math.max(10, spec.barWidth + 8));
|
|
476
|
+
lines.push(boxLine(theme, safeWidth, `${bar} ${muted(theme, `${model.taskProgress.completed}/${model.taskProgress.total} tasks · ${model.taskProgress.percentage}%`)}`));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Tasks section: a window over the recursive tree (§9.2, §9.6). With no
|
|
480
|
+
// explicit rows the whole tree is shown (backward compatible); with a row
|
|
481
|
+
// budget the panel stays bounded and ↑/↓ keys move the window.
|
|
482
|
+
if (model.taskTree.length > 0) {
|
|
483
|
+
lines.push(boxSectionRule(theme, safeWidth, "Tasks"));
|
|
484
|
+
const totalRows = model.taskTree.length;
|
|
485
|
+
const rows = opts.rows ?? totalRows;
|
|
486
|
+
const offset = opts.scrollOffset ?? anchoredScrollOffset(model.taskTree, rows);
|
|
487
|
+
const viewport = deriveTaskListViewport(totalRows, rows, offset);
|
|
488
|
+
if (viewport.hiddenAbove > 0) {
|
|
489
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, `↑ ${viewport.hiddenAbove} more task${viewport.hiddenAbove === 1 ? "" : "s"}`)));
|
|
490
|
+
}
|
|
491
|
+
for (const node of model.taskTree.slice(viewport.offset, viewport.offset + viewport.rows)) {
|
|
492
|
+
const indent = Math.min(node.depth, 6);
|
|
493
|
+
lines.push(boxLine(theme, safeWidth, renderTaskRow(theme, node, indent, inner)));
|
|
494
|
+
}
|
|
495
|
+
if (viewport.hiddenBelow > 0) {
|
|
496
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, `… +${viewport.hiddenBelow} more task${viewport.hiddenBelow === 1 ? "" : "s"}`)));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Current-task section (§4.2).
|
|
501
|
+
if (model.currentTask) {
|
|
502
|
+
lines.push(...renderCurrentTaskBlock(model, theme, safeWidth, spec.barWidth));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Goal-level verification section (§11.1).
|
|
506
|
+
if (model.goalVerificationContract) {
|
|
507
|
+
lines.push(boxSectionRule(theme, safeWidth, "Verification"));
|
|
508
|
+
lines.push(...wrappedBlock(theme, safeWidth, "", model.goalVerificationContract));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Recent activity section (§12).
|
|
512
|
+
if (model.recentActivity.length > 0) {
|
|
513
|
+
lines.push(...renderActivityBlock(model.recentActivity, theme, safeWidth));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const closeKeys = [opts.keybindings?.collapse ?? "escape", opts.keybindings?.toggleExpand ?? DEFAULT_GOAL_KEYBINDINGS.dashboard.toggleExpand].flatMap(bindingKeys);
|
|
517
|
+
lines.push(boxFooter(theme, safeWidth, closeKeys.length ? `${closeKeys.map(formatGoalKeybinding).join("/")}: collapse` : ""));
|
|
518
|
+
return lines;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The current-task detail block (§4.2): title, subtask progress, contract,
|
|
523
|
+
* evidence, and the inferred-focus note. Shared by the expanded dashboard and
|
|
524
|
+
* the standard /goal-status output (§13.3).
|
|
525
|
+
*/
|
|
526
|
+
export function renderCurrentTaskBlock(
|
|
527
|
+
model: GoalDashboardModel,
|
|
528
|
+
theme: Theme,
|
|
529
|
+
width: number,
|
|
530
|
+
barWidth = 16,
|
|
531
|
+
): string[] {
|
|
532
|
+
const safeWidth = Math.max(10, width);
|
|
533
|
+
const inner = safeWidth - 2;
|
|
534
|
+
const lines: string[] = [];
|
|
535
|
+
lines.push(boxSectionRule(theme, safeWidth, "Current task"));
|
|
536
|
+
lines.push(boxLine(theme, safeWidth, `${accent(theme, model.currentTask!.id)} · ${fit(model.currentTask!.title, inner - visibleWidth(`${model.currentTask!.id} · `))}`));
|
|
537
|
+
if (model.currentTask!.totalSubtasks > 0) {
|
|
538
|
+
const bar = progressBar(theme, model.currentTask!.subtaskPercentage, Math.max(8, barWidth));
|
|
539
|
+
lines.push(boxLine(theme, safeWidth, `${muted(theme, "Subtasks")} ${bar} ${model.currentTask!.completedSubtasks}/${model.currentTask!.totalSubtasks} · ${model.currentTask!.subtaskPercentage}%`));
|
|
540
|
+
}
|
|
541
|
+
if (model.currentTask!.verificationContract) {
|
|
542
|
+
lines.push(...wrappedBlock(theme, safeWidth, "Contract", model.currentTask!.verificationContract));
|
|
543
|
+
}
|
|
544
|
+
if (model.currentTask!.evidence) {
|
|
545
|
+
lines.push(...wrappedBlock(theme, safeWidth, "Evidence", model.currentTask!.evidence));
|
|
546
|
+
}
|
|
547
|
+
if (model.currentTask!.inferred) {
|
|
548
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, "Inferred from the first pending task — no persisted current task.")));
|
|
549
|
+
}
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The recent-activity block (§12), shared by the expanded dashboard and /goal-status. */
|
|
554
|
+
export function renderActivityBlock(items: GoalActivityItem[], theme: Theme, width: number): string[] {
|
|
555
|
+
const safeWidth = Math.max(10, width);
|
|
556
|
+
const inner = safeWidth - 2;
|
|
557
|
+
const lines: string[] = [];
|
|
558
|
+
lines.push(boxSectionRule(theme, safeWidth, "Recent activity"));
|
|
559
|
+
for (const item of items) {
|
|
560
|
+
const marker = activityMarker(item);
|
|
561
|
+
const text = fit(item.text, inner - 4);
|
|
562
|
+
lines.push(boxLine(theme, safeWidth, `${theme.fg(marker.color, marker.symbol)} ${text}`));
|
|
563
|
+
}
|
|
564
|
+
return lines;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Wrap long contract/evidence text safely at the inner width (§11.1). */
|
|
568
|
+
function wrappedBlock(theme: Theme, width: number, label: string, text: string): string[] {
|
|
569
|
+
const inner = Math.max(4, width - 2);
|
|
570
|
+
const prefix = label ? `${label}: ` : "";
|
|
571
|
+
const indent = " ".repeat(visibleWidth(prefix));
|
|
572
|
+
const wrapped = wrapTextWithAnsi(text.replace(/\s+/g, " ").trim(), Math.max(8, inner - visibleWidth(prefix)));
|
|
573
|
+
const lines: string[] = [];
|
|
574
|
+
for (const [index, segment] of wrapped.entries()) {
|
|
575
|
+
const content = index === 0 ? `${prefix}${segment}` : `${indent}${segment}`;
|
|
576
|
+
lines.push(boxLine(theme, width, muted(theme, content)));
|
|
577
|
+
}
|
|
578
|
+
return lines;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── UNFOCUSED PANEL (§4.3) ──────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Shown when open goals exist but none is focused. The widget's default
|
|
585
|
+
* no-goal branch renders nothing (surfaces without goals).
|
|
586
|
+
*/
|
|
587
|
+
export function renderUnfocusedDashboard(openGoalCount: number, theme: Theme, width: number, commands?: GoalCommands): string[] {
|
|
588
|
+
const safeWidth = Math.max(10, width);
|
|
589
|
+
const lines: string[] = [];
|
|
590
|
+
lines.push(boxHeader(theme, safeWidth, frame(theme, "pi-goal-x ─ Goal focus required")));
|
|
591
|
+
const goals = openGoalCount === 1 ? "1 open goal is available." : `${openGoalCount} open goals are available.`;
|
|
592
|
+
lines.push(boxLine(theme, safeWidth, muted(theme, goals)));
|
|
593
|
+
lines.push(boxLine(theme, safeWidth, muted(theme, `Run ${commandHint("goal-focus", commands)} to choose the goal for this session.`)));
|
|
594
|
+
lines.push(boxFooter(theme, safeWidth, ""));
|
|
595
|
+
return lines;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ── width safety net (defense in depth) ─────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
/** Clamp every rendered line to the terminal width (used by the component). */
|
|
601
|
+
export function clampLinesToWidth(lines: string[], width: number): string[] {
|
|
602
|
+
return lines.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width, "…") : line));
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ── AUDIT DASHBOARD (§15) ───────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
import type { AuditorDashboardModel, AuditResultCard } from "./auditor-dashboard-model.ts";
|
|
608
|
+
|
|
609
|
+
const CHECK_SYMBOL = { passed: "✓", running: "◌", pending: "·", failed: "✗" } as const;
|
|
610
|
+
const CHECK_COLOR = { passed: "success", running: "accent", pending: "muted", failed: "error" } as const;
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Structured audit dashboard (§15.3): five check stages, a progress bar, and
|
|
614
|
+
* the elapsed duration, using the same visual system as the goal dashboard.
|
|
615
|
+
* Raw tools and recent output appear only when showToolDetails is set
|
|
616
|
+
* (expanded/debug audit mode) or when the audit finished with a failure.
|
|
617
|
+
*/
|
|
618
|
+
export function renderAuditorDashboard(
|
|
619
|
+
model: AuditorDashboardModel,
|
|
620
|
+
theme: Theme,
|
|
621
|
+
width: number,
|
|
622
|
+
opts: { showToolDetails?: boolean; interruptKey?: GoalKeybinding } = {},
|
|
623
|
+
): string[] {
|
|
624
|
+
const safeWidth = Math.max(10, width);
|
|
625
|
+
const inner = safeWidth - 2;
|
|
626
|
+
const lines: string[] = [];
|
|
627
|
+
const duration = formatAuditElapsed(model.elapsedMs);
|
|
628
|
+
lines.push(boxHeader(theme, safeWidth, frame(theme, `Independent completion audit ─ ${model.auditorLabel}`), frame(theme, duration)));
|
|
629
|
+
|
|
630
|
+
for (const check of model.checks) {
|
|
631
|
+
const symbol = CHECK_SYMBOL[check.state];
|
|
632
|
+
const color = CHECK_COLOR[check.state];
|
|
633
|
+
lines.push(boxLine(theme, safeWidth, `${theme.fg(color as RenderColor, symbol)} ${check.label}`));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (model.percentage !== undefined) {
|
|
637
|
+
const barWidth = Math.max(8, Math.min(inner - 12, 30));
|
|
638
|
+
const bar = progressBar(theme, model.percentage, barWidth);
|
|
639
|
+
lines.push(boxLine(theme, safeWidth, `${bar} ${theme.fg("muted", `${model.percentage}%`)}`));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const showDiagnostics = opts.showToolDetails === true || model.verdict === "disapproved" || model.verdict === "error";
|
|
643
|
+
if (showDiagnostics) {
|
|
644
|
+
if (model.currentTool) {
|
|
645
|
+
const args = model.currentToolArgs ? ` ${dim(theme, truncateText(model.currentToolArgs, Math.max(10, inner - 20)))}` : "";
|
|
646
|
+
lines.push(boxLine(theme, safeWidth, `${accent(theme, "tool")} ${model.currentTool}${args}`));
|
|
647
|
+
}
|
|
648
|
+
if (model.recentOutput.length > 0) {
|
|
649
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, "─".repeat(Math.max(4, inner - 8)))));
|
|
650
|
+
for (const output of model.recentOutput.slice(0, 3)) {
|
|
651
|
+
lines.push(boxLine(theme, safeWidth, dim(theme, truncateText(output, Math.max(8, inner - 4)))));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
lines.push(boxFooter(theme, safeWidth, model.active && bindingKeys(opts.interruptKey ?? "escape").length ? `${formatGoalKeybinding(opts.interruptKey ?? "escape")}: stop audit` : ""));
|
|
657
|
+
return lines;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Audit result card (§15.4): APPROVED or CHANGES REQUIRED / ERROR. */
|
|
661
|
+
export function renderAuditResultCard(card: AuditResultCard, theme: Theme, width: number): string[] {
|
|
662
|
+
const safeWidth = Math.max(10, width);
|
|
663
|
+
const inner = safeWidth - 2;
|
|
664
|
+
const lines: string[] = [];
|
|
665
|
+
const success = card.verdict === "approved";
|
|
666
|
+
const accentColor = success ? "success" : "error";
|
|
667
|
+
lines.push(boxHeader(theme, safeWidth, frame(theme, `Audit result ─ ${card.label}`)));
|
|
668
|
+
for (const line of card.lines) {
|
|
669
|
+
const symbol = success ? "✓" : "✗";
|
|
670
|
+
lines.push(boxLine(theme, safeWidth, `${theme.fg(accentColor as RenderColor, symbol)} ${fit(line, inner - 4)}`));
|
|
671
|
+
}
|
|
672
|
+
lines.push(boxFooter(theme, safeWidth, ""));
|
|
673
|
+
return lines;
|
|
674
|
+
}
|