import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { makeBorderedBox } from "@ftrdotdev/pi-tui";
import { Effect, Layer, ManagedRuntime, Option, Result } from "effect";
import {
formatItemRef,
parseItemRef,
type DependencyItem,
type DependencyListView,
} from "./deps.ts";
import { TodoItem, TodoList, TrackerState, emptyState, encodeState } from "./domain.ts";
import { TrackerPersistence } from "./persistence.ts";
import { TrackerError, TrackerStore, type ItemSpec, type UpdateItemPatch } from "./store.ts";
import {
TRACKER_TOOL_METADATA,
blockedDoneNote,
doneMarkReminder,
validateTrackerCall,
type TrackerToolAction,
type TrackerToolDetails,
type TrackerToolParams,
} from "./tool-metadata.ts";
import {
displayList,
makeTrackerOverlay,
makeTrackerWidget,
readinessSuffix,
type TrackerUiAction,
} from "./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);
/** 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;
};
/**
* Normalize the tool's item shapes (a text string, an item object, or an array
* of either) into the store's item specs.
*/
const toItemSpecs = (raw: NonNullable): ItemSpec[] => {
const entries = Array.isArray(raw) ? raw : [raw];
return entries.map((entry) =>
typeof entry === "string"
? entry
: { text: entry.text, ...(entry.deps === undefined ? {} : { deps: entry.deps }) },
);
};
// --------------------------------------------------------------------------
// Extension
// --------------------------------------------------------------------------
export default function (pi: ExtensionAPI): void {
const runtime = ManagedRuntime.make(
TrackerStore.layer.pipe(
Layer.provideMerge(
TrackerPersistence.layer((encoded) => pi.appendEntry(CUSTOM_TYPE, encoded)),
),
),
);
/** 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_item": {
const specs = toItemSpecs(requireParam(params.text, "text"));
return withStore((store) => store.addItems(requireParam(params.list_id, "list_id"), specs));
}
case "update_item": {
if (params.items !== undefined) {
// Batch form: list_id + item-id patches within one list (mirrors
// add_item's list_id + text[]). 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.map((patch) => ({
itemId: patch.item_id,
...(patch.text !== undefined ? { text: patch.text } : {}),
...(patch.done !== undefined ? { done: patch.done } : {}),
...(patch.deps !== undefined ? { deps: patch.deps } : {}),
}));
return withStore((store) => store.updateItems(listId, batch));
}
const patch: UpdateItemPatch = {
...(typeof params.text === "string" ? { text: params.text } : {}),
...(params.done !== undefined ? { done: params.done } : {}),
...(params.deps !== undefined ? { deps: params.deps } : {}),
};
// Both update_item forms return an affected-items array, so the
// result contract matches add_item 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.text));
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));
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();
});
// --- 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.text}` +
`${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_item": {
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]!.text}`
: `Added ${items.length} items to ${listName}: ${ids.map((id, i) => `#${id}: ${items[i]!.text}`).join(", ")}`;
break;
}
case "update_item": {
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 text/done.
const batch = params.items;
const patches: Array<{
id: string;
text?: string;
done?: boolean;
deps?: readonly string[];
}> =
batch !== undefined
? batch.map((p) => ({
id: p.item_id,
text: p.text,
done: p.done,
deps: p.deps,
}))
: [
{
id: params.item_id ?? "?",
text: typeof params.text === "string" ? params.text : undefined,
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.text !== undefined) changes.push(`text: ${item.text}`);
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_item 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_item"
? 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.fg("toolTitle", theme.bold("tracker ")) + theme.fg("muted", action);
if (typeof raw.name === "string") text += ` ${theme.fg("dim", `"${raw.name}"`)}`;
if (typeof raw.text === "string") {
text += ` ${theme.fg("dim", `"${raw.text}"`)}`;
} else if (Array.isArray(raw.text)) {
text += ` ${theme.fg("dim", raw.text.map((t) => `"${t}"`).join(" "))}`;
}
if (Array.isArray(raw.initial_items)) {
text += ` ${theme.fg("dim", raw.initial_items.map((t) => `"${t}"`).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.text) : item.text;
const blocked = theme.fg("dim", annotations.suffixByIndex[index] ?? "");
parts.push(
` ${check} ${theme.fg("accent", `#${list.name}:${item.id}`)} ${itemText}${blocked}`,
);
}
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),
};
});
},
});
}