/** * task-list.ts — the run's plan, painted above the editor, in the RAIL card * grammar (design system §2.A): a `┏ PLAN ▸ ` canopy carrying * the progress gauge, one `┃` row per task in PLAN ORDER with task numbers * (#1, #2, …), and a `┗ ` cap. * Completed rows are dimmed and struck through; the task actively executing * carries a spinner frame with elapsed time and token counts; queued rows * name the dependencies they wait on. * * Example: * ``` * ┏ PLAN ▸ default/build ···················· ▕████▎░░░░░▏ 2/5 * ┃ ✔ #1 Design the flux capacitor * ┃ ⠙ #2 Wire the overlay scroll (2m 49s · ↑ 4.1k ↓ 1.2k) * ┃ ◻ #3 Run the suite › blocked by #2 * ┃ ◻ #4 Report the result * ┗ 2 done · 2 in progress * ``` * * Pure plan surface: which worker/agent/model runs a task is the dock's * story below the editor, never the plan's. Pure display — the interactive * surface stays that dock. Rows come from the run snapshot's `tasks` slice * (plan order), so the list repaints on task transitions only; the running * row's spinner/elapsed/token suffix rides the render signature's spinner * bucket while any worker is alive. */ import type { TeamTaskState } from "../../state/types.ts"; import { visibleWidth } from "../../utils/visual.ts"; import { formatCount } from "../format-helpers.ts"; import { canopyLine, gaugeBar, overflowHint, RAIL, railLine, statusSlot } from "../rail.ts"; import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts"; import { shortRunLabel } from "./widget-model.ts"; import type { WidgetRun } from "./widget-types.ts"; /** Plain (uncoloured) default — the widget component passes the real theme. */ const PLAIN_THEME = asCrewTheme(undefined); /** Gauge width on the canopy line (eighth-block precision, so it stays * truthful even at 10 cells). */ const GAUGE_WIDTH = 12; /** Task rows painted before the "… and N more" overflow line (pi-tasks * defaults its `maxVisible` to 10). */ const MAX_TASK_ROWS = 10; const ACTIVE_TASK_STATUSES = new Set(["running", "queued", "waiting", "needs_attention"]); // pi-tasks-style strikethrough + dim for completed rows: raw SGR because the // theme adapter has no strike; the truncate/colorize paths are ANSI-aware. const DIM_ON = "\x1b[2m"; const DIM_OFF = "\x1b[22m"; const STRIKE_ON = "\x1b[9m"; const STRIKE_OFF = "\x1b[29m"; function isDoneStatus(status: string): boolean { return status === "completed" || status === "failed" || status === "cancelled" || status === "skipped"; } function taskTitle(task: TeamTaskState): string { return (task.displayName ?? task.title ?? "").replace(/\s+/g, " ").trim(); } /** pi-tasks status glyph per row: ✔ done (struck through), ▶ the actively * executing task, ◼ parked/needs input, ◻ queued, ✗ dead. * * L-plan-flicker (2026-10-07 live probe, 600 frames @0.2s in the owner * session): the running row previously painted `spinnerFrame()` — a braille * frame rotating every 160ms — turning the whole card into a ~8Hz repaint * above the editor ("nháy nháy liên tục"). The plan card is a LIST: per the * variant's own design comment it must change only on task transitions, so * the running glyph is STATIC (like ◼/◻) and the elapsed suffix carries * liveness at 1s grain. The dock keeps its spinner — one animated row is * intentional there. */ function taskStatusIcon(task: TeamTaskState): string { switch (task.status) { case "running": return "▶"; case "queued": return "◻"; case "waiting": case "needs_attention": return "◼"; case "completed": return "✔"; case "failed": case "cancelled": case "skipped": return "✗"; default: return "?"; } } /** * Plan-card elapsed dialect (QW#5): `45s` / `2m 49s` / `1h 03m`, empty * string for non-finite/negative input. Deliberately NOT ui/format-helpers' * canonical `formatDuration` (that one renders `12.3s`/`3m5s` with no hours * bucket and no invalid-input guard) — the spaced, zero-padded, hour-capable * format here is pinned by the pi-tasks plan-card display. Renamed from a * local `formatDuration` to end the three-way name collision. */ function formatTaskElapsed(ms: number): string { if (!Number.isFinite(ms) || ms < 0) return ""; const totalSeconds = Math.floor(ms / 1000); if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); if (minutes < 60) return `${minutes}m ${String(totalSeconds % 60).padStart(2, "0")}s`; const hours = Math.floor(minutes / 60); return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`; } /** `4.1k` / `1.2M` / `918` — no unit suffix; the arrow pair carries it. */ function compactTokens(count: number): string { if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return ""; if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; return `${count}`; } /** `(2m 49s · ↑ 4.1k ↓ 1.2k)` — elapsed plus tokens, pi-tasks style. Live * totals come from agentProgress until the final usage lands. */ function runningSuffix(task: TeamTaskState): string { const parts: string[] = []; const start = Date.parse(task.startedAt ?? ""); if (Number.isFinite(start) && start > 0) { const end = task.finishedAt ? Date.parse(task.finishedAt) : Date.now(); const elapsed = formatTaskElapsed(end - start); if (elapsed) parts.push(elapsed); } const input = task.usage?.input ?? 0; const output = task.usage?.output ?? 0; if (input > 0 || output > 0) { parts.push(`↑ ${compactTokens(input) || "0"} ↓ ${compactTokens(output) || "0"}`); } else { const live = compactTokens(task.agentProgress?.tokens ?? 0); if (live) parts.push(live); } return parts.length ? ` (${parts.join(" · ")})` : ""; } /** `› blocked by #2, #3` for queued tasks — open (non-completed) * dependencies only, named by task number (pi-tasks display rule). */ function blockedSuffix(task: TeamTaskState, taskById: Map, numberById: Map): string { if (task.status !== "queued") return ""; // RR-021 WI-4.3b: Map lookup — tasks.find() per dependency was O(deps×tasks) // on every queued row render. const openDeps = (task.dependsOn ?? []) .map((depId) => taskById.get(depId)) .filter((dep): dep is TeamTaskState => dep !== undefined && dep.status !== "completed"); if (openDeps.length === 0) return ""; const labels = openDeps.map((dep) => `#${numberById.get(dep.id) ?? "?"}`); return ` › blocked by ${labels.join(", ")}`; } /** One line per task: `#n title` behind its status glyph — pi-tasks style. * Completed titles are dimmed and struck through. Returns the ROW CONTENT: * the rail glyph (`┃ `) is owned by `railLine`. */ function taskRow(task: TeamTaskState, taskNumber: number, taskById: Map, numberById: Map): string { const title = taskTitle(task); const suffix = task.status === "running" ? runningSuffix(task) : blockedSuffix(task, taskById, numberById); if (task.status === "completed") { const struck = `${STRIKE_ON}${DIM_ON}#${taskNumber} ${title}${DIM_OFF}${STRIKE_OFF}`; return `${taskStatusIcon(task)} ${struck}`; } return `${taskStatusIcon(task)} #${taskNumber} ${title}${suffix}`.trimEnd(); } /** The run whose plan is shown: the first with unfinished work, else the first. */ function primaryRun(runs: readonly WidgetRun[]): WidgetRun | undefined { return runs.find((entry) => (entry.snapshot?.tasks ?? []).some((task) => ACTIVE_TASK_STATUSES.has(task.status))) ?? runs[0]; } /** Which rows fit the cap: plan order, but never drop an unfinished task — * if active work falls outside the first MAX_TASK_ROWS rows it stays and * finished rows give way instead. */ function visibleTasks(tasks: readonly TeamTaskState[]): TeamTaskState[] { if (tasks.length <= MAX_TASK_ROWS) return [...tasks]; const active = tasks.filter((task) => ACTIVE_TASK_STATUSES.has(task.status)); if (active.length >= MAX_TASK_ROWS) return active.slice(0, MAX_TASK_ROWS); const keep = new Set(active.map((task) => task.id)); for (const task of tasks) { if (keep.size >= MAX_TASK_ROWS) break; keep.add(task.id); } return tasks.filter((task) => keep.has(task.id)); } /** * Build the plan card for the current runs: canopy (identity + gauge), one * `┃` row per task, the overflow hint, and the `┗` counts cap. Empty when no * run carries a tasks slice (nothing to say — the dock alone stays). */ export function buildTaskListLines(runs: readonly WidgetRun[], width: number, theme: CrewTheme = PLAIN_THEME): string[] { const entry = primaryRun(runs); if (!entry) return []; const tasks = entry.snapshot?.tasks ?? []; if (tasks.length === 0) return []; const budget = Math.max(8, width - 2); const numberById = new Map(tasks.map((task, index) => [task.id, index + 1])); const taskById = new Map(tasks.map((task) => [task.id, task])); const done = tasks.filter((task) => task.status === "completed").length; const dead = tasks.filter((task) => isDoneStatus(task.status) && task.status !== "completed").length; const inProgress = tasks.filter( (task) => task.status === "running" || task.status === "waiting" || task.status === "needs_attention", ).length; const open = tasks.filter((task) => task.status === "queued").length; const slot = statusSlot(entry.run.status); // Canopy: `┏ PLAN ▸ ` + the progress gauge on the right, where a bar // fits (a 12-cell bar plus its tally needs ~6 columns of clearance). const title = shortRunLabel(entry.run); const ratio = done / tasks.length; const tally = `${done}/${tasks.length}`; const gauge = `${gaugeBar(ratio, GAUGE_WIDTH, theme, dead > 0 ? "error" : "success")} ${tally}`; const labelWidth = visibleWidth(`PLAN ${title}`) + 5; // word + ` ▸ ` const right = budget - labelWidth - 6 >= GAUGE_WIDTH + tally.length ? gauge : undefined; const lines = [ canopyLine({ word: "PLAN", subject: title, theme, budget, slot, right, }), ]; const visible = visibleTasks(tasks); for (const task of visible) { lines.push(railLine(RAIL.body, "border", taskRow(task, numberById.get(task.id) ?? 0, taskById, numberById), theme, budget)); } const hidden = tasks.length - visible.length; if (hidden > 0) { // Canonical overflow dialect (RAIL §1) — the legacy `… and N more` is // retired with the other three overflow spellings. lines.push(railLine(RAIL.body, "border", overflowHint(0, hidden, theme), theme, budget)); } // Close cap: the counts (formatCount → singular/plural; the participle // labels pass an explicit plural so nothing renders as `2 dones`). const counts = [formatCount(done, "done", "done")]; if (dead) counts.push(formatCount(dead, "failed", "failed")); counts.push(formatCount(inProgress, "in progress", "in progress"), formatCount(open, "open", "open")); lines.push(railLine(RAIL.close, slot, counts.join(" · "), theme, budget)); return lines; }