/** * Unified dashboard renderer (plan §5, §4). * * Renders the shared dashboard view model (goal-dashboard-model.ts) as the * compact above-editor widget, the expanded full dashboard (which replaces the * task overlay), and the unfocused panel. Pure presentation: all data comes * from the model; this file owns the §5 visual spec (borders, status symbols, * progress bars, responsive layouts, width-safe ANSI/Unicode handling) and * must never emit a line wider than the requested terminal width. * * Layout modes (§5.5): wide ≥100, medium 70–99, narrow 50–69, minimal <50. */ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { truncateToWidth, wrapTextWithAnsi } from "./text-cache.ts"; import { anchoredScrollOffset, compactTaskViewportRows, deriveTaskListViewport, formatAuditElapsed, formatBudget, type DashboardStatusCode, type DashboardTaskNode, type GoalDashboardModel, type TaskListViewport, } from "./goal-dashboard-model.ts"; import type { GoalActivityItem } from "../goal-activity.ts"; import { truncateText } from "../goal-core.ts"; import { DEFAULT_GOAL_KEYBINDINGS, formatGoalKeybinding, type GoalDashboardKeybindings } from "../goal-settings.ts"; // mdLink is deliberately excluded: the chrome must stay neutral gray, not // blue-tinged ("more grey than blue"). type RenderColor = Extract; // ── §5.1 border system ────────────────────────────────────────────────────── const H = "─"; const V = "│"; /** Box chrome: the entire frame — corners, top/bottom dashes, left/right * edges and interior rules — is one tone: the theme's neutral gray (muted). * No blue tinge: the chrome is grey, while the content inside is * colour-coded. */ function frame(theme: Theme, value: string): string { return theme.fg("muted", value); } function fit(value: string, width: number): string { return visibleWidth(value) > width ? truncateToWidth(value, width, "…") : value; } function boxHeader(theme: Theme, width: number, left: string, right = ""): string { const inner = Math.max(4, width - 2); // One-tone frame line: corners, leading/trailing dashes and fill all carry // the frame color; only the brand inside stays accent. const l = `${frame(theme, H)} ${left}`; const r = right ? ` ${right} ${frame(theme, H)}` : ""; const fixed = visibleWidth(l) + visibleWidth(r); if (fixed > inner - 2) { // Too tight: truncate the title so the right-side meta survives. const budget = Math.max(4, inner - visibleWidth(r) - 4); const l2 = `${frame(theme, H)} ${fit(left, budget)}`; const fill = Math.max(1, inner - visibleWidth(l2) - visibleWidth(r)); return `${frame(theme, "╭")}${l2}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`; } const fill = Math.max(1, inner - fixed); return `${frame(theme, "╭")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╮")}`; } function boxLine(theme: Theme, width: number, content: string): string { const inner = Math.max(2, width - 2); const contentFit = fit(content, inner - 1); const pad = Math.max(0, inner - 1 - visibleWidth(contentFit)); return `${frame(theme, V)} ${contentFit}${" ".repeat(pad)}${frame(theme, V)}`; } function boxRule(theme: Theme, width: number): string { return frame(theme, `├${H.repeat(Math.max(1, width - 2))}┤`); } /** Section separator with a label and optional right-side content: * `├─ Tasks ──── [████░] · Sub 2/3 [██░░] ┤` (§5.1). The right content * (e.g. the header progress bars) always survives; the label truncates when * tight. Hardened so label + right never exceed the inner width: the fill is * `max(0, …)` and the truncation budget leaves room for the `─ ` prefix and * trailing space. */ function boxSectionRule(theme: Theme, width: number, label: string, right = ""): string { const inner = Math.max(4, width - 2); const left = frame(theme, `${H} ${label} `); const r = right ? ` ${right} ` : ""; let l = left; if (visibleWidth(left) + visibleWidth(r) > inner) { const budget = Math.max(4, inner - visibleWidth(r) - 3); l = frame(theme, `${H} ${fit(label, budget)} `); } const fill = Math.max(0, inner - visibleWidth(l) - visibleWidth(r)); return `${frame(theme, "├")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "┤")}`; } function boxFooter(theme: Theme, width: number, content: string, right = ""): string { const inner = Math.max(4, width - 2); // Optional right slot mirrors boxHeader's right-meta treatment (` ● ─` // before the corner), so the auditor dot sits at the bottom-right of the // border. The dot survives; the hint (left content) truncates when tight. const r = right ? ` ${right} ${frame(theme, H)}` : ""; if (!content && !r) { return frame(theme, `╰${H.repeat(inner)}╯`); } // The footer is one frame tone: leading dash, hint text and trailing fill // all carry the frame color, so the blue-gray spans the whole line. const l = content ? frame(theme, `${H} ${fit(content, Math.max(0, inner - 4 - visibleWidth(r)))}`) : ""; const fill = Math.max(1, inner - visibleWidth(l) - visibleWidth(r)); return `${frame(theme, "╰")}${l}${frame(theme, H.repeat(fill))}${r}${frame(theme, "╯")}`; } // ── §5.2 status colors ────────────────────────────────────────────────────── const STATUS_COLOR: Record = { running: "accent", idle: "muted", paused: "muted", blocked: "error", budget_limited: "mdHeading", complete: "success", }; /** * Widget status line — footer-status parity: `goal: