/** * todo-overlay.ts — Persistent widget showing todo list above the editor. * * Lifecycle controller for Pi's `setWidget` contract: factory-form * registration in widgetContainerAbove, register-once + requestRender() * refresh, 12-line collapse-not-scroll (plus a trailing spacer row, so the * widget renders up to 13 lines), auto-hide when empty. * * Reads its injected store at render time — NEVER `replayFromBranch` from * `tool_execution_end` (branch is stale; `message_end` runs after). */ import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent"; import { type TUI, truncateToWidth } from "@earendil-works/pi-tui"; import { loadConfig, resolveStatusIcons, type StatusIcons } from "./config.js"; import { formatStatusLabel, t } from "./state/i18n-bridge.js"; import { selectHasActive, selectOverlayLayout, selectShowTaskIds, selectTodoCounts } from "./state/selectors.js"; import type { TodoStore } from "./state/store.js"; import { formatOverlayTaskLine, statusIcon } from "./view/format.js"; const WIDGET_KEY = "rpiv-todos"; // Budget for content rows (heading + tasks/summary). The rendered widget is // one line taller — withTrailingSpacer() appends a blank row below the panel. const MAX_WIDGET_LINES = 12; const NERD_FONT_ANIMATION_INTERVAL_MS = 300; // English fallbacks for localized overlay chrome strings. const OVERLAY_HEADING = "Todos"; const OVERLAY_MORE = "more"; export class TodoOverlay { private uiCtx: ExtensionUIContext | undefined; private widgetRegistered = false; private tui: TUI | undefined; private completedTaskIdsPendingHide = new Set(); private hiddenCompletedTaskIds = new Set(); private lastNextId: number | undefined; private animationTimer: ReturnType | undefined; private inProgressFrameIndex = 0; private readonly statusIcons: StatusIcons; constructor( private readonly store: TodoStore, statusIcons = resolveStatusIcons(loadConfig().statusIcons), ) { this.statusIcons = statusIcons; } setUICtx(ctx: ExtensionUIContext): void { // Identity-compare so repeat session_start handlers are idempotent; // on identity change (/reload) invalidate so update() re-registers. if (ctx !== this.uiCtx) { this.stopAnimation(); this.uiCtx = ctx; this.widgetRegistered = false; this.tui = undefined; } } update(): void { if (!this.uiCtx) return; const snapshot = this.getSnapshot(); const visible = this.selectOverlayTasks(snapshot); if (visible.length === 0) { this.stopAnimation(); if (this.widgetRegistered) { this.uiCtx.setWidget(WIDGET_KEY, undefined); this.widgetRegistered = false; this.tui = undefined; } return; } this.syncAnimation(visible.some((task) => task.status === "in_progress")); if (!this.widgetRegistered) { this.uiCtx.setWidget( WIDGET_KEY, (tui, theme) => { this.tui = tui; return { render: (width: number) => this.renderWidget(theme, width), invalidate: () => { this.stopAnimation(); this.widgetRegistered = false; this.tui = undefined; }, }; }, { placement: "aboveEditor" }, ); this.widgetRegistered = true; } else { this.tui?.requestRender(); } } resetCompletedDisplayState(): void { this.completedTaskIdsPendingHide.clear(); this.hiddenCompletedTaskIds.clear(); this.lastNextId = undefined; } hideCompletedTasksFromPreviousTurn(): void { if (this.completedTaskIdsPendingHide.size === 0) return; for (const taskId of this.completedTaskIdsPendingHide) { this.hiddenCompletedTaskIds.add(taskId); } this.completedTaskIdsPendingHide.clear(); this.tui?.requestRender(); } private getSnapshot() { const state = this.store.getState(); if (this.lastNextId !== undefined && state.nextId < this.lastNextId) { this.resetCompletedDisplayState(); } this.lastNextId = state.nextId; const completedTaskIds = new Set( state.tasks.filter((task) => task.status === "completed").map((task) => task.id), ); for (const taskId of this.completedTaskIdsPendingHide) { if (!completedTaskIds.has(taskId)) this.completedTaskIdsPendingHide.delete(taskId); } for (const taskId of this.hiddenCompletedTaskIds) { if (!completedTaskIds.has(taskId)) this.hiddenCompletedTaskIds.delete(taskId); } return { tasks: [...state.tasks], nextId: state.nextId }; } private selectOverlayTasks(snapshot: ReturnType) { return snapshot.tasks.filter((task) => task.status !== "deleted" && !this.shouldHideCompletedTask(task)); } private shouldHideCompletedTask(task: ReturnType["tasks"][number]): boolean { return task.status === "completed" && this.hiddenCompletedTaskIds.has(task.id); } private renderWidget(theme: Theme, width: number): string[] { const snapshot = this.getSnapshot(); const overlayTasks = this.selectOverlayTasks(snapshot); if (overlayTasks.length === 0) { this.stopAnimation(); return []; } const hasInProgress = overlayTasks.some((task) => task.status === "in_progress"); this.syncAnimation(hasInProgress); const inProgressFrame = this.currentInProgressFrame(); const overlayState = { tasks: overlayTasks, nextId: snapshot.nextId }; const truncate = (line: string): string => truncateToWidth(line, width, "…"); const counts = selectTodoCounts(overlayState); const hasActive = selectHasActive(overlayState); const showIds = selectShowTaskIds(overlayState); const headingColor = hasActive ? "accent" : "dim"; const headingIcon = theme.fg(headingColor, this.statusIcons.heading); const headingText = `${t("overlay.heading", OVERLAY_HEADING)} (${counts.completed}/${counts.total})`; const heading = truncate(`${headingIcon} ${theme.fg(headingColor, headingText)}`); const lines: string[] = [heading]; const layout = selectOverlayLayout(overlayState, MAX_WIDGET_LINES - 1); for (const task of layout.visible) { lines.push( truncate( `${theme.fg("dim", "├─")} ${formatOverlayTaskLine(task, theme, showIds, this.statusIcons, inProgressFrame)}`, ), ); } const newlyDisplayedCompletedTaskIds = overlayTasks .filter( (task) => task.status === "completed" && !this.completedTaskIdsPendingHide.has(task.id) && !this.hiddenCompletedTaskIds.has(task.id), ) .map((task) => task.id); for (const taskId of newlyDisplayedCompletedTaskIds) { this.completedTaskIdsPendingHide.add(taskId); } if (layout.hiddenCompleted === 0 && layout.truncatedTail === 0) { const last = lines.length - 1; lines[last] = lines[last].replace("├─", "└─"); return this.withTrailingSpacer(lines); } const totalHidden = layout.hiddenCompleted + layout.truncatedTail; const overflowParts: string[] = []; if (layout.hiddenCompleted > 0) overflowParts.push(`${layout.hiddenCompleted} ${formatStatusLabel("completed")}`); if (layout.truncatedTail > 0) overflowParts.push(`${layout.truncatedTail} ${formatStatusLabel("pending")}`); const more = t("overlay.more", OVERLAY_MORE); const summary = overflowParts.length > 0 ? `+${totalHidden} ${more} (${overflowParts.join(", ")})` : `+${totalHidden} ${more}`; lines.push(truncate(`${theme.fg("dim", "└─")} ${theme.fg("dim", summary)}`)); return this.withTrailingSpacer(lines); } /** * Append a trailing blank line so the overlay isn't flush against the * editor box. Pi's host adds a leading spacer above the widget but none * below, which leaves the last "└─" row (or the "+N more" summary) glued * to the input box. The empty string gives the "Todos" panel a little * breathing room. */ private withTrailingSpacer(lines: string[]): string[] { if (lines.length === 0) return lines; lines.push(""); return lines; } private currentInProgressFrame(): string { return this.statusIcons.inProgressFrames[this.inProgressFrameIndex % this.statusIcons.inProgressFrames.length]!; } private syncAnimation(hasInProgress: boolean): void { if (!this.tui || !hasInProgress || this.statusIcons.inProgressFrames.length < 2) { this.stopAnimation(); return; } if (this.animationTimer) return; this.animationTimer = setInterval(() => { this.inProgressFrameIndex = (this.inProgressFrameIndex + 1) % this.statusIcons.inProgressFrames.length; this.tui?.requestRender(); }, NERD_FONT_ANIMATION_INTERVAL_MS); this.animationTimer.unref?.(); } private stopAnimation(): void { if (this.animationTimer) clearInterval(this.animationTimer); this.animationTimer = undefined; this.inProgressFrameIndex = 0; } dispose(): void { this.stopAnimation(); if (this.uiCtx) this.uiCtx.setWidget(WIDGET_KEY, undefined); this.widgetRegistered = false; this.tui = undefined; this.uiCtx = undefined; this.resetCompletedDisplayState(); } }