/** * Session-scoped state, lag tracking, and widget synchronization for the todo * extension. * * The list is reconstructed from the active branch on session start and tree * navigation, and the persistent widget mirrors it whenever it changes. Only * interactive (`tui`) sessions get a widget; whether a finished list stays * visible comes from the todo config. The same runtime carries the lag-tracking * counters the `agent_before_settle` reminder reads, so they reset with the * session and the branch. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { onRailsSuppressed, onUpperRailChanged } from "../../lib/rails.ts"; import { DEFAULT_TODO_CONFIG, type TodoConfig } from "./config.ts"; import { shouldNudge } from "./nudge.ts"; import { hasOpenTodos, reconstructTodos } from "./state.ts"; import { TodoWidget, WIDGET_KEY } from "./tui.ts"; import type { Todo } from "./types.ts"; export interface TodoRuntime { /** The current list. */ getTodos(): Todo[]; /** Replace the list and refresh the widget. */ setTodos(todos: Todo[], ctx?: ExtensionContext): void; /** Rebuild the list from the active branch. */ reconstruct(ctx: ExtensionContext): void; /** Apply the widget config (loaded per session). */ setConfig(config: TodoConfig): void; /** Record a successful mutating call; `fileWrite` marks one that can change files. */ noteWork(fileWrite?: boolean): void; /** Whether a lag reminder is due and has not been sent for it yet. */ nudgeDue(): boolean; /** Mark the current lag as reminded, activating the injected reminder. */ markNudged(): void; /** Expire the injected reminder at the start of a new user turn. */ deactivateNudge(): void; /** Whether an injected reminder is still current. */ isNudgeActive(): boolean; /** Clear per-turn lag tracking; the reminder itself expires separately. */ resetNudge(): void; /** Subscribe to list changes; returns an unsubscribe. */ onChange(listener: () => void): () => void; /** Remove the widget, for example on shutdown. */ clearWidget(ctx: ExtensionContext): void; } export function createTodoRuntime(pi?: Pick): TodoRuntime { let todos: Todo[] = []; let config: TodoConfig = DEFAULT_TODO_CONFIG; // The most recent interactive context, so the list can re-assert its widget // when a rail above it changes. Cleared on shutdown. let lastTuiCtx: ExtensionContext | undefined; // True while a dock screen owns the editor slot; the rail stays hidden. let suppressed = false; // Lag tracking: `mutations` counts successful mutating calls since the list // was last written and `writes` how many of those can change files, // `touched` records that the model wrote or cleared the list this turn (so an // explicit clear is not second-guessed), `nudged` caps the reminder to one per // user turn, and `nudgeActive` marks an injected reminder as current so // `context` keeps it for the continuation and drops it on the next user turn. let mutations = 0; let writes = 0; let touched = false; let nudged = false; let nudgeActive = false; // Screens watching the list (for example the open `/todos` screen) so they // can re-render when the list changes underneath them. const listeners = new Set<() => void>(); const notify = (): void => { for (const listener of listeners) listener(); }; // An empty list is always hidden; a fully completed one is hidden only when // the config asks for it. const shouldShow = (): boolean => todos.length > 0 && !(config.hideWhenComplete && !hasOpenTodos(todos)); const syncWidget = (ctx?: ExtensionContext): void => { if (!ctx || ctx.mode !== "tui") return; lastTuiCtx = ctx; if (suppressed || !shouldShow()) { ctx.ui.setWidget(WIDGET_KEY, undefined); return; } const snapshot = todos; ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => new TodoWidget(snapshot, theme)); }; // Re-assert the list when the goal above it changes: Pi re-inserts a widget // on every set, so a goal update would otherwise sink the list below it. Todo // is the bottom rail, so it does not announce. if (pi) onUpperRailChanged(pi, "todo", () => { if (lastTuiCtx) syncWidget(lastTuiCtx); }); // Hide the rail while a dock screen is open; re-sync when the last screen // closes so the ordering chain is re-established. if (pi) onRailsSuppressed(pi, (open) => { suppressed = open; if (lastTuiCtx) syncWidget(lastTuiCtx); }); return { getTodos: () => todos, setTodos: (next, ctx) => { todos = next; // A todo update is the model syncing the list, so the lag is cleared. // `nudged` is not: the reminder stays capped at one per user turn. mutations = 0; writes = 0; touched = true; syncWidget(ctx); notify(); }, reconstruct: (ctx) => { todos = reconstructTodos(ctx.sessionManager.getBranch()); mutations = 0; writes = 0; touched = false; nudged = false; nudgeActive = false; syncWidget(ctx); notify(); }, setConfig: (next) => { config = next; }, noteWork: (fileWrite = false) => { mutations++; if (fileWrite) writes++; }, nudgeDue: () => shouldNudge({ mutations, writes, touched, nudged, todos }), markNudged: () => { nudged = true; nudgeActive = true; }, deactivateNudge: () => { nudgeActive = false; }, isNudgeActive: () => nudgeActive, resetNudge: () => { mutations = 0; writes = 0; touched = false; nudged = false; }, onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, clearWidget: (ctx) => { lastTuiCtx = undefined; if (ctx.mode === "tui") ctx.ui.setWidget(WIDGET_KEY, undefined); }, }; }