/** * task-widget.ts — Persistent widget showing task list with status icons and progress. * * Display style matches Claude Code's task list: * ✔ completed tasks * ◼ in_progress tasks * ◻ pending tasks * ✳ actively executing task (with activeForm text) * * The widget renders as plain text lines (string[]) — no TUI component * factory — so it behaves identically in TUI and RPC modes. Lines are capped * at a fixed width (MAX_LINE_WIDTH) since there is no terminal width to read. */ import type { TaskStore } from "../task-store.js"; import type { TasksConfig } from "../tasks-config.js"; // ---- Truncation ---- import type { Task } from "../types.js"; function truncateFromTop(tasks: Task[], limit: number): Task[] { return tasks.slice(-limit); } function truncateFromBottom(tasks: Task[], limit: number): Task[] { return tasks.slice(0, limit); } const TRUNCATE_FNS = { top: truncateFromTop, bottom: truncateFromBottom }; // ---- Types ---- export type UICtx = { setStatus(key: string, text: string | undefined): void; setWidget( key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }, ): void; }; /** Icon for the actively executing task (static — no animation). */ const ACTIVE_ICON = "✳"; /** Fixed line width cap; longer lines are truncated with a trailing "…". */ const MAX_LINE_WIDTH = 40; const DEFAULT_MAX_VISIBLE_TASKS = 10; /** Refresh interval while a task is actively executing (elapsed time ticks). */ const WIDGET_REFRESH_MS = 1000; /** Truncate a line to a fixed width with a trailing ellipsis. */ function truncateLine(line: string): string { return line.length > MAX_LINE_WIDTH ? line.slice(0, MAX_LINE_WIDTH - 1) + "…" : line; } /** Per-task runtime metrics (elapsed time, token usage). */ export interface TaskMetrics { startedAt: number; inputTokens: number; outputTokens: number; } /** Format milliseconds as a human-readable duration (e.g., "2m 49s", "1h 3m"). */ function formatDuration(ms: number): string { const totalSec = Math.floor(ms / 1000); if (totalSec < 60) return `${totalSec}s`; const min = Math.floor(totalSec / 60); const sec = totalSec % 60; if (min < 60) return sec > 0 ? `${min}m ${sec}s` : `${min}m`; const hr = Math.floor(min / 60); const remMin = min % 60; return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`; } /** Format token count with k suffix (e.g., "4.1k", "850"). */ function formatTokens(n: number): string { if (n < 1000) return String(n); return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k"; } // ---- Widget ---- export class TaskWidget { private uiCtx: UICtx | undefined; private widgetInterval: ReturnType | undefined; /** IDs of tasks currently being actively executed (show ✳). */ private activeTaskIds = new Set(); /** Per-task runtime metrics keyed by task ID. */ private metrics = new Map(); constructor( private store: TaskStore, private config: TasksConfig = {}, ) {} setStore(store: TaskStore) { this.store = store; } setConfig(config: TasksConfig) { this.config = config; } setUICtx(ctx: UICtx) { this.uiCtx = ctx; } /** Add or remove a task from the active set. */ setActiveTask(taskId: string | undefined, active = true) { if (taskId && active) { this.activeTaskIds.add(taskId); if (!this.metrics.has(taskId)) { this.metrics.set(taskId, { startedAt: Date.now(), inputTokens: 0, outputTokens: 0 }); } this.ensureTimer(); } else if (taskId) { this.activeTaskIds.delete(taskId); } this.update(); } /** Record token usage for the currently active task(s). */ addTokenUsage(inputTokens: number, outputTokens: number) { // Distribute to all currently active tasks for (const id of this.activeTaskIds) { const m = this.metrics.get(id); if (m) { m.inputTokens += inputTokens; m.outputTokens += outputTokens; } } this.update(); } /** Ensure the widget refresh timer is running. */ ensureTimer() { if (!this.widgetInterval) { this.widgetInterval = setInterval(() => this.update(), WIDGET_REFRESH_MS); } } /** Build widget lines from current live state. Guarded so a render error can * never escape — worst case the widget is empty for one update. */ private buildWidgetLines(): string[] { try { const sortOrder = this.config.sortOrder ?? "id"; const tasks = this.store.list(sortOrder); if (tasks.length === 0) return []; const completed = tasks.filter(t => t.status === "completed"); const inProgress = tasks.filter(t => t.status === "in_progress"); const pending = tasks.filter(t => t.status === "pending"); const parts: string[] = []; if (completed.length > 0) parts.push(`${completed.length} done`); if (inProgress.length > 0) parts.push(`${inProgress.length} in progress`); if (pending.length > 0) parts.push(`${pending.length} open`); const statusText = `${tasks.length} tasks (${parts.join(", ")})`; const lines: string[] = [`● ${statusText}`]; const showAll = this.config.showAll ?? false; const limit = this.config.maxVisible ?? DEFAULT_MAX_VISIBLE_TASKS; const hiddenAt = this.config.hiddenAt ?? "bottom"; const visible = showAll ? tasks : TRUNCATE_FNS[hiddenAt](tasks, limit); const hiddenCount = tasks.length - visible.length; const overflowLine = hiddenCount > 0 ? truncateLine(` … and ${hiddenCount} more`) : undefined; if (overflowLine && hiddenAt === "top") { lines.push(overflowLine); } for (let i = 0; i < visible.length; i++) { const task = visible[i]; const isActive = this.activeTaskIds.has(task.id) && task.status === "in_progress"; let icon: string; if (isActive) { icon = ACTIVE_ICON; } else if (task.status === "completed") { icon = "✔"; } else if (task.status === "in_progress") { icon = "◼"; } else { icon = "◻"; } let suffix = ""; if (task.status === "pending" && task.blockedBy.length > 0) { const openBlockers = task.blockedBy.filter(bid => { const blocker = this.store.get(bid); return blocker && blocker.status !== "completed"; }); if (openBlockers.length > 0) { suffix = ` › blocked by ${openBlockers.map(id => "#" + id).join(", ")}`; } } let text: string; if (isActive) { const form = task.activeForm || task.subject; const agentId = task.metadata?.agentId; const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : ""; const m = this.metrics.get(task.id); let stats = ""; if (m) { const elapsed = formatDuration(Date.now() - m.startedAt); const tokenParts: string[] = []; if (m.inputTokens > 0) tokenParts.push(`↑ ${formatTokens(m.inputTokens)}`); if (m.outputTokens > 0) tokenParts.push(`↓ ${formatTokens(m.outputTokens)}`); stats = tokenParts.length > 0 ? ` (${elapsed} · ${tokenParts.join(" ")})` : ` (${elapsed})`; } text = ` ${icon} #${task.id} ${form + agentLabel}…${stats}`; } else if (task.status === "completed") { text = ` ${icon} #${task.id} ${task.subject}`; } else { const agentSuffix = task.status === "in_progress" && task.metadata?.agentId ? ` (agent ${task.metadata.agentId.slice(0, 5)})` : ""; text = ` ${icon} #${task.id} ${task.subject}${agentSuffix}`; } lines.push(truncateLine(text + suffix)); } if (overflowLine && hiddenAt !== "top") { lines.push(overflowLine); } return lines; } catch { return []; } } /** Push the current task list to the UI. Called on state changes, and by the * refresh timer while a task is actively executing (elapsed time ticks). */ update() { if (!this.uiCtx) return; const tasks = this.store.list(); // Transition: visible → hidden if (tasks.length === 0) { this.uiCtx.setWidget("tasks", ["No tasks"], { placement: "aboveEditor" }); if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } return; } // Prune stale active IDs (deleted or no longer in_progress) for (const id of this.activeTaskIds) { const t = this.store.get(id); if (t?.status !== "in_progress") { this.activeTaskIds.delete(id); this.metrics.delete(id); } } // Refresh while a task is actively executing const hasActiveTask = tasks.some(t => this.activeTaskIds.has(t.id) && t.status === "in_progress"); if (hasActiveTask) { this.ensureTimer(); } else if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } this.uiCtx.setWidget("tasks", this.buildWidgetLines(), { placement: "aboveEditor" }); } dispose() { if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; } if (this.uiCtx) { this.uiCtx.setWidget("tasks", undefined); } } }