import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { makeBorderedBox } from "@ftrdotdev/pi-tui"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { Effect, Layer, ManagedRuntime, Option, Result } from "effect"; import { join } from "node:path"; import { ClassifierGateway } from "./compaction/classifier.ts"; import { CompletionObserver } from "./compaction/completion.ts"; import { DECISION_CUSTOM_TYPE, encodeDecisionRecord } from "./compaction/decision-record.ts"; import { FootprintRecorder, productionProbe, type ToolEvent } from "./compaction/footprint.ts"; import { formatItemRef, parseItemRef, type DependencyItem, type DependencyListView, } from "./core/deps.ts"; import { TodoItem, TodoList, TrackerState, emptyState, encodeState } from "./core/domain.ts"; import { TrackerPersistence } from "./core/persistence.ts"; import { SMART_COMPACTION_CUSTOM_TYPE } from "./compaction/pointer.ts"; import { SMART_COMPACTION_CONFIG_FILE, SmartCompactionSettingsService, } from "./compaction/settings.ts"; import { SettleDecider } from "./compaction/settle.ts"; import { TrackerError, TrackerStore, type ItemSpec, type UpdateItemPatch } from "./core/store.ts"; import { TRACKER_TOOL_METADATA, blockedDoneNote, doneMarkReminder, validateTrackerCall, type TrackerItemSpec, type TrackerToolAction, type TrackerToolDetails, type TrackerToolParams, type TrackerUpdatePatch, } from "./presentation/tool-metadata.ts"; import { displayList, makeTrackerOverlay, makeTrackerWidget, readinessSuffix, type TrackerUiAction, } from "./presentation/ui.ts"; /** Custom-entry type used to persist the tracker state in the session. */ const CUSTOM_TYPE = "tracker/state"; // -------------------------------------------------------------------------- // Effect bridge // -------------------------------------------------------------------------- /** * Run a store operation: `TrackerStore` (the service key) is itself an * Effect that yields the service instance. */ const withStore = ( f: (store: TrackerStore["Service"]) => Effect.Effect, ): Effect.Effect => Effect.flatMap(TrackerStore, f); const withPersistence = ( f: (persistence: TrackerPersistence["Service"]) => Effect.Effect, ): Effect.Effect => Effect.flatMap(TrackerPersistence, f); const withCompletionObserver = ( f: (observer: CompletionObserver["Service"]) => Effect.Effect, ): Effect.Effect => Effect.flatMap(CompletionObserver, f); const withSettleDecider = ( f: (decider: SettleDecider["Service"]) => Effect.Effect, ): Effect.Effect => Effect.flatMap(SettleDecider, f); const withFootprintRecorder = ( f: (recorder: FootprintRecorder["Service"]) => Effect.Effect, ): Effect.Effect => Effect.flatMap(FootprintRecorder, f); /** The bounded `path`/`command` projection of a tool call's arguments. */ const boundedArgsOf = (value: unknown): ToolEvent["args"] => { if (typeof value !== "object" || value === null) return undefined; const record = value as { readonly path?: unknown; readonly command?: unknown }; const path = typeof record.path === "string" && record.path.length > 0 ? record.path : undefined; const command = typeof record.command === "string" && record.command.length > 0 ? record.command : undefined; if (path === undefined && command === undefined) return undefined; return { ...(path === undefined ? {} : { path }), ...(command === undefined ? {} : { command }), }; }; /** The tool result's `details`, which carries a write's or edit's unified patch. */ const detailsOf = (result: unknown): unknown => typeof result === "object" && result !== null ? (result as { readonly details?: unknown }).details : undefined; /** Throw for a missing required tool parameter; caught in `execute`. */ const requireParam = (value: T | undefined, name: string): T => { if (value === undefined) throw new Error(`${name} is required for this tracker action`); return value; }; /** Project the tool's item objects onto the store's item specs. */ const toItemSpecs = (items: readonly TrackerItemSpec[]): ItemSpec[] => items.map((item) => ({ title: item.title, ...(item.description === undefined ? {} : { description: item.description }), ...(item.refs === undefined ? {} : { refs: item.refs }), ...(item.produces === undefined ? {} : { produces: item.produces }), ...(item.deps === undefined ? {} : { deps: item.deps }), })); // -------------------------------------------------------------------------- // Extension // -------------------------------------------------------------------------- export default function (pi: ExtensionAPI): void { // The settings path is resolved inside the factory, so a test can point // ENV_AGENT_DIR at a temp directory before `tracker(api)` runs. const probe = productionProbe(); const decisionServices = Layer.mergeAll( CompletionObserver.layer, ClassifierGateway.layer, FootprintRecorder.layer(probe), SmartCompactionSettingsService.make(join(getAgentDir(), SMART_COMPACTION_CONFIG_FILE)), ); const runtime = ManagedRuntime.make( SettleDecider.layer(probe).pipe( Layer.provideMerge(Layer.mergeAll(TrackerStore.layer, decisionServices)), Layer.provideMerge( TrackerPersistence.layer((encoded) => pi.appendEntry(CUSTOM_TYPE, encoded)), ), Layer.provide(NodeFileSystem.layer), ), ); /** Mirror of the store state, read by the widget and tool renderers. */ let state: TrackerState = emptyState(); const buildToolProgram = ( params: TrackerToolParams, ): Effect.Effect => { switch (params.action) { case "list": return withStore((store) => store.state); case "create_list": return withStore((store) => store.createList(requireParam(params.name, "name"), { activate: params.activate ?? true, ...(params.initial_items !== undefined ? { initialItems: toItemSpecs(params.initial_items) } : {}), }), ); case "delete_list": return withStore((store) => store.deleteList(requireParam(params.list_id, "list_id"))); case "set_active": return withStore((store) => store.setActiveList(params.list_id ?? null)); case "add_items": { // Validation guarantees every entry is an item object; the shared // `items` field also carries update_items patches, which are rejected // for this action. const specs = toItemSpecs( requireParam(params.items, "items") as readonly TrackerItemSpec[], ); return withStore((store) => store.addItems(requireParam(params.list_id, "list_id"), specs)); } case "update_items": { if (params.items !== undefined) { // Batch form: list_id + item-id patches within one list (mirrors // add_items' list_id + items). The store resolves each id against // that single list, so the reference's list name must match it. const listId = requireParam(params.list_id, "list_id"); const batch = (params.items as readonly TrackerUpdatePatch[]).map((patch) => ({ itemId: patch.item_id, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.description !== undefined ? { description: patch.description } : {}), ...(patch.refs !== undefined ? { refs: patch.refs } : {}), ...(patch.produces !== undefined ? { produces: patch.produces } : {}), ...(patch.done !== undefined ? { done: patch.done } : {}), ...(patch.deps !== undefined ? { deps: patch.deps } : {}), })); return withStore((store) => store.updateItems(listId, batch)); } const patch: UpdateItemPatch = { ...(typeof params.title === "string" ? { title: params.title } : {}), ...(params.description !== undefined ? { description: params.description } : {}), ...(params.refs !== undefined ? { refs: params.refs } : {}), ...(params.produces !== undefined ? { produces: params.produces } : {}), ...(params.done !== undefined ? { done: params.done } : {}), ...(params.deps !== undefined ? { deps: params.deps } : {}), }; // Both update_items forms return an affected-items array, so the // result contract matches add_items and the renderer/reminder can // treat every result the same way. return withStore((store) => store.updateItem(requireParam(params.item_id, "item_id"), patch), ).pipe(Effect.map((item) => [item])); } case "remove_item": return withStore((store) => store.removeItem(requireParam(params.item_id, "item_id"))); } }; const uiActionProgram = ( action: TrackerUiAction, ): Effect.Effect => { switch (action.type) { case "createList": return withStore((store) => store.createList(action.name)); case "deleteList": return withStore((store) => store.deleteList(action.listId)); case "setActive": return withStore((store) => store.setActiveList(action.listId)); case "addItem": return withStore((store) => store.addItem(action.listId, action.title)); case "updateItem": return withStore((store) => store.updateItem(action.itemId, action.patch)); case "removeItem": return withStore((store) => store.removeItem(action.itemId)); } }; /** * Every state change runs through this chain, so the read and the session * write of one change stay atomic and in order. Tool calls are sequential * already (the tool declares `executionMode`), but a session event is not * serialized with them: without this queue, a restore on `session_tree` * could land between a change and its write and clobber it. */ let mutationQueue: Promise = Promise.resolve(); /** Serialize a state-changing step and keep the chain alive if it fails. */ const enqueue = (step: () => Promise): Promise => { const run = mutationQueue.then(step); mutationQueue = run.then( () => undefined, () => undefined, ); return run; }; /** * Persist the current store state and refresh the widget pane. Called after * every successful mutation, from both the tool and the /tracker UI, so the * two entry points can never diverge. Returns the state it read, which the * tool result is built from, so no surface reads a stale mirror. */ const applyMutation = (ctx: ExtensionContext): Promise => enqueue(async () => { const next = await runtime.runPromise(withStore((store) => store.state)); // Record the transition before the mirror moves, so the observer sees the // true pre-mutation state. Every mutation runs this, so a later mutation // that completes nothing still refreshes the live snapshot. await runtime.runPromise(withCompletionObserver((observer) => observer.record(state, next))); state = next; await runtime.runPromise( withPersistence((p) => p.save(next)).pipe( Effect.catch((err) => Effect.logWarning( `tracker: persist failed: ${err instanceof Error ? err.message : String(err)}`, ), ), ), ); refreshWidget(ctx); return next; }); const refreshWidget = (ctx: ExtensionContext): void => { // The widget is the opt-in view of the *active* list: hidden when no list // is active (including after a deselect), regardless of list count. if (state.activeListId === null) { ctx.ui.setWidget("tracker", undefined); return; } ctx.ui.setWidget("tracker", (_tui, theme) => { // BorderedBox caches per width; the widget is re-registered on every // state change and invalidate() clears the cache on theme changes, so // stale themed output (ANSI colors baked into the cached strings) is // never served. const widget = makeTrackerWidget(state, theme); // pi calls render/invalidate as methods of the object returned here, // so hand over arrows, never detached methods — an unbound reference // would rebind `this` to this wrapper and crash inside BorderedBox. return { render: (width) => widget.render(width), invalidate: () => widget.invalidate(), }; }); }; /** * Rebuild state from the latest tracker custom entry on the current branch. * Runs through the same queue as a mutation, so a restore cannot land in the * middle of a change and clobber it. */ const reconstructState = (ctx: ExtensionContext): Promise => enqueue(async () => { let snapshot: unknown = null; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) { snapshot = entry.data; } } if (snapshot === null) { state = emptyState(); } else { const result = await runtime.runPromise( Effect.result(withPersistence((p) => p.restore(snapshot))), ); if (Result.isFailure(result)) { ctx.ui.notify("tracker: saved state could not be decoded — starting empty", "warning"); state = emptyState(); } else { state = Option.getOrThrow(Result.getSuccess(result)); } } await runtime.runPromise(withStore((store) => store.reset(state))); refreshWidget(ctx); }); // --- Session lifecycle ------------------------------------------------- pi.on("session_start", async (_event, ctx) => { await reconstructState(ctx); }); pi.on("session_tree", async (_event, ctx) => { await reconstructState(ctx); }); pi.on("session_shutdown", async () => { await runtime.dispose(); }); // --- Smart compaction --------------------------------------------------- /** * Pi rethrows the provider's abort error on cancellation, so the error text * is not a reliable signal. `session_compact_failed` fires first and carries * the fact. */ let compactionAborted = false; pi.on("session_compact_failed", (event) => { compactionAborted = event.aborted; }); /** * The args of in-flight tool calls, so a `tool_execution_end` event can name * the path its `tool_execution_start` carried. pi's end event has no args. */ const toolCallArgs = new Map(); pi.on("tool_execution_start", async (event) => { const args = boundedArgsOf(event.args); if (args === undefined) return; toolCallArgs.set(event.toolCallId, args); await runtime.runPromise( withFootprintRecorder((recorder) => recorder.capture({ phase: "start", toolName: event.toolName, args }), ), ); }); pi.on("tool_execution_end", async (event) => { const args = toolCallArgs.get(event.toolCallId); toolCallArgs.delete(event.toolCallId); const details = detailsOf(event.result); await runtime.runPromise( withFootprintRecorder((recorder) => recorder.capture({ phase: "end", toolName: event.toolName, ...(args === undefined ? {} : { args }), ...(details === undefined ? {} : { details }), }), ), ); }); pi.on("agent_before_settle", async (event, ctx) => { const decision = await runtime.runPromise( withSettleDecider((decider) => decider.decide({ registry: ctx.modelRegistry, outcome: event.outcome, signal: ctx.signal, }), ), ); // The decision record is written before the pointer delivery, so the // harvest can see the raw row and the resolved states for every decision. if (decision.record !== undefined) { pi.appendEntry(DECISION_CUSTOM_TYPE, encodeDecisionRecord(decision.record)); } if (decision.kind !== "compact") return; // The run signal can fire after the verdict, while the classifier is the // last thing between here and compaction. An abort keeps the context. if (ctx.signal?.aborted) return; const pointer = decision.pointer; // Delivery comes from pi's completion contract, which fires only after // compaction has finished. `deliver` is idempotent, so a double callback // cannot start two turns. let resumed = false; const deliver = () => { if (resumed) return; resumed = true; pi.sendMessage( { customType: SMART_COMPACTION_CUSTOM_TYPE, content: pointer, display: true }, { triggerTurn: true }, ); }; compactionAborted = false; ctx.compact({ onComplete: () => deliver(), onError: (error) => { // Compaction failed, but the run already stopped and the context is // intact, so the pointer still resumes the work. A user cancellation // is different: nothing is resumed. ctx.ui.notify(`tracker: smart compaction failed: ${error.message}`, "warning"); if (!compactionAborted) deliver(); }, }); }); // --- Tool --------------------------------------------------------------- /** * Derived view of a list for the two `list` surfaces: the items in derived * order, each item's blocker annotation by index, the refs that are ready * now, and whether the list has any dependency at all. A dependency-free * list renders exactly as before. Returning the ordered array keeps the * annotations and the rendered rows in step. */ const listAnnotations = ( list: DependencyListView, ): { readonly items: readonly T[]; readonly suffixByIndex: readonly string[]; readonly readyRefs: readonly string[]; readonly hasDependencies: boolean; } => { const view = displayList(list); return { items: view.list.items, suffixByIndex: view.ready.map((entry, index) => readinessSuffix(view.list.items[index]!.done, entry.blockers), ), readyRefs: view.ready .filter((entry) => entry.ready) .map((entry) => formatItemRef(list.name, entry.id)), hasDependencies: view.list.items.some((item) => (item.deps ?? []).length > 0), }; }; const readyLine = (refs: readonly string[]): string => ` Ready now: ${refs.length === 0 ? "(none)" : refs.map((ref) => `#${ref}`).join(", ")}`; const listSummary = (current: TrackerState): string => { if (current.lists.length === 0) return "No lists"; return current.lists .map((list) => { const done = list.items.filter((item) => item.done).length; const active = list.id === current.activeListId ? " (active)" : ""; const annotations = listAnnotations(list); const body = annotations.items.length === 0 ? " (no items)" : annotations.items .map( (item, index) => ` [${item.done ? "x" : " "}] #${list.name}:${item.id}: ${item.title}` + `${annotations.suffixByIndex[index]}`, ) .join("\n"); const ready = annotations.hasDependencies ? `\n${readyLine(annotations.readyRefs)}` : ""; return `[${list.id}] ${list.name} — ${done}/${list.items.length}${active}\n${body}${ready}`; }) .join("\n"); }; /** * The dependency set an item carried before the call, or null when the * reference does not resolve there. */ const depsOf = (current: TrackerState, ref: string): readonly string[] | null => { const parsed = parseItemRef(ref); if (parsed === null) return null; const list = current.lists.find((candidate) => candidate.name === parsed.name); const item = list?.items.find((candidate) => candidate.id === parsed.id); return item?.deps ?? null; }; /** * The `deps` change for one patch. `deps` replaces the whole set, so the * result names the set before and after whenever they differ: a dependency * that the call dropped must not disappear from the report. */ const depsChange = (ref: string, next: readonly string[], before: TrackerState): string => { const previous = depsOf(before, ref); const after = next.length === 0 ? "deps cleared" : `deps: ${next.join(", ")}`; if (previous === null || previous.join(",") === next.join(",")) return after; return next.length === 0 ? `deps cleared (was ${previous.join(", ")})` : `deps: ${previous.join(", ")} → ${next.join(", ")}`; }; const toolSuccess = ( params: TrackerToolParams, value: unknown, current: TrackerState, before: TrackerState, ): { content: Array<{ type: "text"; text: string }>; details: TrackerToolDetails } => { const details: TrackerToolDetails = { action: params.action }; let text = ""; switch (params.action) { case "list": details.snapshot = encodeState(current); text = listSummary(current); break; case "create_list": { const list = value as TodoList; details.list = list; text = `Created list #${list.id}: ${list.name}`; if (list.items.length > 0) { text += ` with ${list.items.length} initial item${list.items.length === 1 ? "" : "s"}`; } if (list.id === current.activeListId) text += " (active)"; break; } case "delete_list": details.listId = params.list_id; text = `Deleted list #${params.list_id}`; break; case "set_active": { details.listId = params.list_id; if (params.list_id === undefined) { text = "Active list cleared (widget hidden)"; } else { const list = current.lists.find((l) => l.id === params.list_id); text = `Active list: ${list ? list.name : `#${params.list_id}`}`; } break; } case "add_items": { const items = value as TodoItem[]; const list = current.lists.find((l) => l.id === params.list_id); details.list = list; details.items = items; const listName = list ? list.name : `#${params.list_id}`; // The store assigned each new item its id; report those. const ids = items.map((item) => `${listName}:${item.id}`); text = items.length === 1 ? `Added item #${ids[0]} to ${listName}: ${items[0]!.title}` : `Added ${items.length} items to ${listName}: ${ids.map((id, i) => `#${id}: ${items[i]!.title}`).join(", ")}`; break; } case "update_items": { const items = value as TodoItem[]; details.items = items; // Normalize both forms into patch records the renderer and reminder // share: each has a display id plus optional title/description/done. const batch = params.items as readonly TrackerUpdatePatch[] | undefined; const patches: Array<{ id: string; title?: string; description?: string; done?: boolean; deps?: readonly string[]; }> = batch !== undefined ? batch.map((p) => ({ id: p.item_id, title: p.title, description: p.description, done: p.done, deps: p.deps, })) : [ { id: params.item_id ?? "?", title: typeof params.title === "string" ? params.title : undefined, description: params.description, done: params.done, deps: params.deps, }, ]; const parts = items.map((item, i) => { const patch = patches[i]!; const changes: string[] = []; if (patch.done !== undefined) changes.push(item.done ? "completed" : "uncompleted"); if (patch.title !== undefined) changes.push(`title: ${item.title}`); if (patch.description !== undefined) changes.push("description updated"); if (patch.deps !== undefined) { changes.push(depsChange(patch.id, patch.deps, before)); } return `${patch.id}${changes.length === 0 ? " (no change)" : ` (${changes.join(", ")})`}`; }); text = `Updated ${items.length} item${items.length === 1 ? "" : "s"}: ${parts.join(", ")}`; // Anti-pattern guard: batch-marking the *last* open items at once is // the terminal-batch signature; the reminder lands in the result so // the caller sees it exactly where the behavior happens. `current` is // the post-call state, so remaining open items tell us whether this // batch cleared the tracker (terminal) or just caught up mid-work. const openRemaining = current.lists.reduce( (n, l) => n + l.items.filter((i) => !i.done).length, 0, ); const reminder = doneMarkReminder(patches, openRemaining); if (reminder !== null) text += `\n${reminder}`; // Reopening an item, or giving a done item an open dependency, can // leave done work unsatisfied. The tracker does not cascade, so say it // out loud instead of silently re-planning. const blockedDone = blockedDoneNote(patches, current); if (blockedDone !== null) text += `\n${blockedDone}`; break; } case "remove_item": details.itemId = params.item_id; text = `Removed item ${params.item_id}`; break; } return { content: [{ type: "text", text }], details }; }; const toolError = ( action: TrackerToolAction, message: string, ): { content: Array<{ type: "text"; text: string }>; details: TrackerToolDetails } => ({ content: [{ type: "text", text: message }], details: { action, error: message }, }); pi.registerTool({ ...TRACKER_TOOL_METADATA, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const action = params.action; // Error-nudging gate: reject incorrect calls (missing required fields, // unknown fields, mixed update_items forms) with a precise message that // tells the agent exactly what to fix, before any state is touched. const validation = validateTrackerCall(params); if (!validation.ok) { return toolError(action, validation.message); } let program: Effect.Effect; try { program = buildToolProgram(params); } catch (err) { return toolError(action, err instanceof Error ? err.message : String(err)); } // The state as this call finds it, so a result can name what a change // replaced. Only the actions that report a diff read it. const before = action === "update_items" ? await runtime.runPromise(withStore((store) => store.state)) : state; const result = await runtime.runPromise(Effect.result(program)); if (Result.isFailure(result)) { const failure = Option.getOrThrow(Result.getFailure(result)); return toolError(action, failure.message); } const value = Option.getOrThrow(Result.getSuccess(result)); // `list` reports the store state it just read; a mutation reports the // state it persisted. Both are authoritative, so no result depends on // the widget mirror. const current = action === "list" ? (value as TrackerState) : await applyMutation(ctx); return toolSuccess(params, value, current, before); }, renderCall(args, theme, _context) { // Args can come from failed validation too, so read them loosely. const raw = args as unknown as Record; const action = String(raw.action ?? "list"); let text = theme.style("tracker ", { fg: "toolTitle", bold: true }) + theme.fg("muted", action); if (typeof raw.name === "string") text += ` ${theme.fg("dim", `"${raw.name}"`)}`; if (typeof raw.title === "string") { text += ` ${theme.fg("dim", `"${raw.title}"`)}`; } else if (Array.isArray(raw.title)) { text += ` ${theme.fg( "dim", raw.title .map((entry) => typeof entry === "string" ? `"${entry}"` : `"${String((entry as { title?: unknown }).title ?? "")}"`, ) .join(" "), )}`; } if (Array.isArray(raw.initial_items)) { text += ` ${theme.fg( "dim", raw.initial_items .map((entry) => typeof entry === "string" ? `"${entry}"` : `"${String((entry as { title?: unknown }).title ?? "")}"`, ) .join(" "), )}`; } if (Array.isArray(raw.items)) text += ` ${theme.fg("accent", `${raw.items.length} items`)}`; if (raw.activate === false) text += ` ${theme.fg("muted", "(no auto-switch)")}`; if (typeof raw.list_id === "number") text += ` ${theme.fg("accent", `#${raw.list_id}`)}`; if (typeof raw.item_id === "string") text += ` ${theme.fg("accent", `item #${raw.item_id}`)}`; return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme, _context) { const details = result.details as TrackerToolDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.error) { return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); } if (details.action === "list") { const snapshot = details.snapshot; if (!snapshot || snapshot.lists.length === 0) { return new Text(theme.fg("dim", "No lists"), 0, 0); } const parts: string[] = []; for (const list of snapshot.lists) { const done = list.items.filter((item) => item.done).length; const active = list.id === snapshot.activeListId ? " ●" : ""; parts.push( theme.fg("accent", `[${list.id}] ${list.name}`) + theme.fg("muted", ` (${done}/${list.items.length})${active}`), ); const annotations = listAnnotations(list); const display = expanded ? annotations.items : annotations.items.slice(0, 5); for (const [index, item] of display.entries()) { const check = item.done ? theme.fg("success", "✓") : theme.fg("dim", "○"); const itemText = item.done ? theme.fg("dim", item.title) : item.title; const blocked = theme.fg("dim", annotations.suffixByIndex[index] ?? ""); parts.push( ` ${check} ${theme.fg("accent", `#${list.name}:${item.id}`)} ${itemText}${blocked}`, ); if (expanded && (item.description ?? "").length > 0) { parts.push(` ${theme.fg("dim", item.description ?? "")}`); } } if (!expanded && annotations.items.length > 5) { parts.push(theme.fg("dim", ` ... ${annotations.items.length - 5} more`)); } if (annotations.hasDependencies) { parts.push(theme.fg("muted", readyLine(annotations.readyRefs))); } } return new Text(parts.join("\n"), 0, 0); } const text = result.content[0]; const msg = text?.type === "text" ? text.text : ""; return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); }, }); // --- Command ------------------------------------------------------------ const runUiAction = async ( ctx: ExtensionContext, action: TrackerUiAction, ): Promise => { const result = await runtime.runPromise(Effect.result(uiActionProgram(action))); if (Result.isFailure(result)) { return Option.getOrThrow(Result.getFailure(result)).message; } await applyMutation(ctx); return null; }; pi.registerCommand("tracker", { description: "Open the interactive tracker (lists and items)", handler: async (_args, ctx) => { if (ctx.mode !== "tui") { ctx.ui.notify("/tracker requires interactive mode", "error"); return; } await ctx.ui.custom((tui, theme, _kb, done) => { // The overlay content is framed by the same house rounded box as the // widget. makeBorderedBox's own cache is disabled so an overlay // mutation at an unchanged width is never served stale rails; the // overlay caches by its own (width, state, signature) fingerprint. const overlay = makeTrackerOverlay({ getState: () => state, theme, requestRender: () => tui.requestRender(), onAction: (action) => runUiAction(ctx, action), onClose: () => done(), }); const framed = makeBorderedBox(overlay, theme, { label: "Tracker", color: "border", cache: false, }); return { render: (width) => framed.render(width), invalidate: () => framed.invalidate(), handleInput: (data) => overlay.handleInput(data), handleMouse: (event) => overlay.handleMouse(event), }; }); }, }); }