import type { ExtensionCommandContext, KeybindingsManager, Theme, } from "@earendil-works/pi-coding-agent"; import { type Component, Key, matchesKey, Text, type TUI, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; import { hintLine, panelFrame, screenTitleLine, } from "../shared/screen-chrome.ts"; import type { TaskItem, TaskSnapshot } from "./tasks.ts"; /** * One colour per status, shared by every surface. The widget used to paint * in-progress amber while the full list painted it accent, so the same item * changed colour depending on where you looked at it. */ const STATUS_COLOR = { pending: "muted", in_progress: "accent", blocked: "warning", done: "success", dropped: "error", } as const satisfies Record; const STATUS_ICON: Record = { pending: "○", in_progress: "●", blocked: "!", done: "✓", dropped: "×", }; const TASK_WIDGET_ORDER: Record = { in_progress: 0, blocked: 1, pending: 2, done: 3, dropped: 4, }; export const TASK_WIDGET_LIMIT = 4; /** * A settled item's subject reads as struck-through and dimmed, so a glance at * the list separates what is left from what is behind you. * * Both, not either: SGR 9 is widely but not universally supported, and a * terminal that drops it would otherwise render done items identically to * open ones. The dim color survives on its own. */ function subjectStyle(status: TaskItem["status"], theme: Theme) { if (status === "done" || status === "dropped") { return (text: string) => theme.strikethrough(theme.fg("dim", text)); } // The one item being worked on is the only thing most glances are looking // for, so it is the only one rendered at full weight. if (status === "in_progress") { return (text: string) => theme.bold(theme.fg("text", text)); } return (text: string) => theme.fg(status === "blocked" ? "text" : "muted", text); } const SUMMARY_LABEL: Record = { done: "done", in_progress: "in progress", blocked: "blocked", pending: "open", dropped: "dropped", }; /** Order the counts by how far along they are, not by status enum order. */ const SUMMARY_ORDER: TaskItem["status"][] = [ "done", "in_progress", "blocked", "pending", "dropped", ]; /** Census of a whole batch, independent of which rows a view chooses to show. */ export type TaskCounts = Record & { total: number }; export function taskCounts(items: readonly TaskItem[]): TaskCounts { const counts: TaskCounts = { total: items.length, pending: 0, in_progress: 0, blocked: 0, done: 0, dropped: 0, }; for (const item of items) counts[item.status]++; return counts; } /** * One-line census: `4 tasks · 3 done · 1 open`. * * Colour carries the status and the total anchors the line, so nothing needs * bold numbers alternating with dim words — that zebra was the loudest thing * on screen and said the least. Zeros are dropped: "0 in progress" costs a * segment to tell you nothing, and a segment appearing when work starts is a * signal, not a glitch. When one status covers everything the redundant count * collapses to `all`, so a fresh batch reads `8 tasks · all open` rather than * `8 tasks · 8 open`. * * Takes counts rather than items because a view often shows a bounded subset * of rows; the header must describe the whole batch regardless. */ export function renderTaskSummary(counts: TaskCounts, theme: Theme): string { // Coerced, not trusted: these counts can arrive from a tool-result record // persisted by an older build, where a missing key would render the literal // word "undefined" (or "NaN tasks") into the header. const count = (status: TaskItem["status"]) => Number.isFinite(counts[status]) ? counts[status] : 0; const total = Number.isFinite(counts.total) ? counts.total : 0; if (total <= 0) return theme.fg("dim", "no tasks"); const present = SUMMARY_ORDER.filter((status) => count(status) > 0); // Built segment by segment rather than by wrapping the whole line: each // styled run emits its own reset, so an outer color would stop applying at // the first inner one. const chips = present.map((status) => theme.fg( STATUS_COLOR[status], `${present.length === 1 && count(status) === total ? "all" : count(status)} ${SUMMARY_LABEL[status]}`, ), ); return [ theme.fg("dim", `${total} ${total === 1 ? "task" : "tasks"}`), ...chips, ].join(theme.fg("dim", " · ")); } export interface TaskToolDetails { action: "add" | "update" | "list"; items: TaskItem[]; total: number; revision: number; batchClosed?: boolean; /** Census of the whole batch; `items` is only the rows this call touched. */ counts?: TaskCounts; } export function renderTaskRows( items: readonly TaskItem[], theme: Theme, width: number, ) { if (items.length === 0) return [theme.fg("dim", "No task items.")]; // Ids address tasks in tasks_update, so they stay — but right-aligned, so a // T10 appearing later never shifts every subject one column over. // Floor of 3 ("T99"), not 2: the width is computed per view, so a batch // crossing T9 — or the same batch shown collapsed (5 rows) then expanded // (all of them) — would otherwise shift every subject sideways by a column. const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3); return items.flatMap((item) => { const color = STATUS_COLOR[item.status]; // No `[status]` text: the icon, its color, and the subject's own weight // already say it, and repeating it in words crowded every row. const id = `T${item.id}`.padStart(idWidth); const rows = [ truncateToWidth( `${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", id)} ${subjectStyle(item.status, theme)(item.subject)}`, width, ), ]; // Continuation lines hang under the subject, not the icon, so the eye // follows one left edge down the list. const indent = " ".repeat(idWidth + 3); if (item.detail) { rows.push( truncateToWidth(`${indent}${theme.fg("dim", item.detail)}`, width), ); } if (item.note) { const label = item.status === "blocked" ? "Blocked" : item.status === "done" ? "Evidence" : item.status === "dropped" ? "Reason" : "Note"; rows.push( truncateToWidth( `${indent}${theme.fg("dim", `${label}:`)} ${theme.fg("muted", item.note)}`, width, ), ); } return rows; }); } export function renderTaskWidget( snapshot: TaskSnapshot, theme: Theme, width: number, expanded = false, ) { const tracked = snapshot.items.filter((item) => item.status !== "dropped"); const actionable = tracked .filter((item) => item.status !== "done") .sort( (left, right) => TASK_WIDGET_ORDER[left.status] - TASK_WIDGET_ORDER[right.status] || left.id - right.id, ); if (actionable.length === 0) return []; const hasOverflow = actionable.length > TASK_WIDGET_LIMIT; const toggleHint = hasOverflow ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}` : ""; // Same census as the full list and the /tasks screen. Counted over `tracked` // rather than every item, because the widget deliberately hides dropped work // and a total that included it would not add up against the rows shown. // // Hints sit on the right edge instead of trailing the census, so the eye lands // on state first and the keystrokes stay out of the way until wanted. They are // dropped rather than truncated when the terminal is too narrow to hold both. const label = theme.fg("accent", "◆ ") + theme.fg("text", theme.bold("Tasks")); const left = `${label} ${renderTaskSummary(taskCounts(tracked), theme)}`; const hint = theme.fg("dim", `/tasks${toggleHint}`); const header = visibleWidth(left) + visibleWidth(hint) + 3 <= width ? fitNavigationSides(left, hint, width) : left; const visible = expanded ? actionable : actionable.slice(0, TASK_WIDGET_LIMIT); const hidden = actionable.length - visible.length; // Right-aligned like the full list, with the same floor: a widget whose ids // are ragged next to a list whose ids are not reads as a different control. const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3); const lines = [truncateToWidth(header, width)]; for (const [index, item] of visible.entries()) { const color = STATUS_COLOR[item.status]; const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─"; lines.push( truncateToWidth( // Same subject weighting as the full list, so the item in flight reads // the same wherever you happen to be looking. `${theme.fg("dim", branch)} ${theme.fg(color, STATUS_ICON[item.status])} ${theme.fg("dim", `T${item.id}`.padStart(idWidth))} ${subjectStyle(item.status, theme)(item.subject)}`, width, ), ); } if (hidden > 0) { lines.push( truncateToWidth(theme.fg("dim", `╰─ … ${hidden} more tasks`), width), ); } return lines; } /** * Rows are built at the width they will be shown at, not at a fixed width and * then re-wrapped. `Text` re-wraps with a wrapper that closes a line's colour * and underline but NOT strikethrough, so a row laid out for a wider terminal * and folded here left SGR 9 open across the fold — the padding to the right * of the break rendered as a solid struck-through bar. `truncateToWidth` * emits a full reset, so cutting at the real width is safe. */ class TaskResultView implements Component { private readonly details: TaskToolDetails; private readonly expanded: boolean; private readonly theme: Theme; constructor(details: TaskToolDetails, expanded: boolean, theme: Theme) { this.details = details; this.expanded = expanded; this.theme = theme; } render(width: number): string[] { const theme = this.theme; const details = this.details; const items = this.expanded ? details.items : details.items.slice(0, 5); const rows: string[] = []; // A closed batch has already been cleared from the live snapshot, so a // census here would read "0 tasks" directly above the rows it describes. // The "Batch complete" line below says everything that is left to say. if (!details.batchClosed && details.counts) { rows.push(renderTaskSummary(details.counts, theme)); } rows.push(...renderTaskRows(items, theme, width)); if (!this.expanded && details.items.length > items.length) { rows.push( theme.fg("dim", `… ${details.items.length - items.length} more`), ); } if (details.batchClosed) { rows.push( theme.fg("success", "✓ Batch complete") + theme.fg("dim", " · next request starts at T1"), ); } return rows.map((row) => truncateToWidth(row, width, "…")); } /** Nothing is cached between renders, so there is nothing to drop. */ invalidate() {} } export function renderToolResult( details: unknown, expanded: boolean, theme: Theme, fallbackText = "Tasks updated.", ): Component { if ( !details || typeof details !== "object" || !Array.isArray((details as Partial).items) ) { return new Text(theme.fg("dim", fallbackText), 0, 0); } return new TaskResultView(details as TaskToolDetails, expanded, theme); } class TasksScreen implements Component { private offset = 0; private readonly tui: TUI; private readonly theme: Theme; private readonly keybindings: KeybindingsManager; private readonly snapshot: TaskSnapshot; private readonly done: () => void; constructor( tui: TUI, theme: Theme, keybindings: KeybindingsManager, snapshot: TaskSnapshot, done: () => void, ) { this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.snapshot = snapshot; this.done = done; } handleInput(data: string) { if ( this.keybindings.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape) ) { this.done(); return; } if (this.keybindings.matches(data, "tui.editor.cursorUp") || data === "k") { this.offset = Math.max(0, this.offset - 1); this.tui.requestRender(); return; } if ( this.keybindings.matches(data, "tui.editor.cursorDown") || data === "j" ) { this.offset += 1; this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.editor.pageUp")) { this.offset = Math.max(0, this.offset - 10); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.editor.pageDown")) { this.offset += 10; this.tui.requestRender(); } } render(width: number) { const theme = this.theme; const counts = taskCounts(this.snapshot.items); const body = renderTaskRows(this.snapshot.items, theme, width - 4); // Title (1) + frame (2) + hint (1): one row more chrome than the old bare // rule, so the body gives one back and the screen keeps its total height. const rows = Math.max(8, (this.tui.terminal.rows || 30) - 9); const maxOffset = Math.max(0, body.length - rows); this.offset = Math.min(this.offset, maxOffset); const visible = body.slice(this.offset, this.offset + rows); // Framed like /subagents, /ps, and /workflows rather than a bare rule: a // full-screen view of a list is the same object in each of them, and it // should not look like a different control here. return [ screenTitleLine(theme, "Session tasks", "", width), ...panelFrame(theme, { label: renderTaskSummary(counts, theme), rows: visible.map((line) => ` ${line}`), width, height: rows + 2, }), hintLine( theme, [ ["j/k or ↑/↓", "scroll"], ["pgup/pgdn", "page"], ["esc", "close"], ], width, ), ]; } invalidate() {} } export async function openTasksScreen( ctx: ExtensionCommandContext, snapshot: TaskSnapshot, ) { if (ctx.mode !== "tui") { if (ctx.hasUI) ctx.ui.notify(`${snapshot.items.length} task item(s)`, "info"); return; } await ctx.ui.custom( (tui, theme, keybindings, done) => new TasksScreen(tui, theme, keybindings, snapshot, () => done()), ); }