@oisin-ee/pi-todo 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,9 +27,50 @@ and the list stays as it was.
27
27
 
28
28
  ## State
29
29
 
30
- Each change is saved as a `todo-state` entry on the session branch. There is no other state. So a resumed session,
31
- a `/tree` move, and calls made from inside codemode all see the list of the current branch. A widget shows the list
32
- while it has tasks, and `/todos` prints it.
30
+ Each task change is saved as a `todo-state` entry on the session branch. A resumed session, a `/tree` move,
31
+ and calls made from inside codemode all see the list of the current branch.
32
+
33
+ ## Widget
34
+
35
+ The widget shows only in-progress tasks by default. Active tasks use the theme accent and bold text.
36
+ Long task names wrap to the terminal width. An empty list has no widget.
37
+
38
+ Press **Alt+T** to expand Upcoming, Done, and Dropped sections beneath the active task. Press **Alt+T** again to collapse them.
39
+ `/todos` also toggles these sections. `/todos list` prints the full list.
40
+
41
+ Expansion stays open through task updates. Session changes and `/tree` navigation collapse it. Expansion does not change saved tasks.
42
+ When no task is active, a short status and the shortcut hint remain visible.
43
+ Outside the terminal UI, `/todos` prints the list instead.
44
+
45
+ ## Shared work display
46
+
47
+ When a compatible pi-wrangle extension is active, its `WORK` strip replaces the standalone todo widget.
48
+ Tasks remain on the current Pi session branch. Wrangle receives read-only snapshots through `pi.events`.
49
+ `/todos` and Alt+T request the shared Todos view. `/todos list` still prints the full list.
50
+ When shared presentation ends, the standalone widget returns without changing task state.
51
+
52
+ The [version 1 display contract](https://github.com/oisin-ee/wrangle/blob/main/docs/pi-work.md)
53
+ defines the snapshot request, acknowledgement, release, and read-only view events.
54
+
55
+ ## Plannotator plans
56
+
57
+ With Plannotator external execution, pi-todo continues approved plans in the same session.
58
+ Add this setting to `~/.pi/agent/plannotator.json`:
59
+
60
+ ```json
61
+ {
62
+ "executionMode": "external"
63
+ }
64
+ ```
65
+
66
+ After approval, the agent reads the current todo list and adds the remaining approved work before implementation.
67
+ It preserves unrelated tasks and completed progress. Free-form plans do not need a special checklist format.
68
+ The todo tool owns progress, persistence, reminders, and the shared work display.
69
+ Plannotator returns to idle and does not maintain a second checklist or require `[DONE:n]` markers.
70
+
71
+ This integration uses the supported `plannotator:plan-approved` event, verified with Plannotator `0.28.19`.
72
+ It does not import Plannotator internals or change its code.
73
+ Malformed events, a different working directory, or another owner of the `todo` tool stop the handoff with an error.
33
74
 
34
75
  ## Reminder
35
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A todo tool for pi with a Rust core: one active task, state on the session branch.",
5
5
  "keywords": [
6
6
  "pi-extension",
@@ -21,11 +21,13 @@
21
21
  "devDependencies": {
22
22
  "@earendil-works/pi-ai": "1.3.0",
23
23
  "@earendil-works/pi-coding-agent": "1.3.0",
24
+ "@earendil-works/pi-tui": "1.3.0",
24
25
  "typebox": "1.3.27"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "@earendil-works/pi-ai": "*",
28
29
  "@earendil-works/pi-coding-agent": "*",
30
+ "@earendil-works/pi-tui": "*",
29
31
  "typebox": "*"
30
32
  },
31
33
  "pi": {
@@ -24,6 +24,14 @@ export interface Request {
24
24
  task: string | undefined;
25
25
  }
26
26
 
27
+ /**
28
+ * One row before terminal wrapping and theme application.
29
+ */
30
+ export interface WidgetRow {
31
+ kind: WidgetKind;
32
+ text: string;
33
+ }
34
+
27
35
  /**
28
36
  * One task, addressed by its exact content.
29
37
  */
@@ -37,6 +45,11 @@ export interface Task {
37
45
  */
38
46
  export type ReminderFact = { kind: "user" } | { kind: "snapshot"; list: TodoList } | { kind: "reminder" };
39
47
 
48
+ /**
49
+ * Semantic styling for a terminal row.
50
+ */
51
+ export type WidgetKind = "heading" | "active" | "upcoming" | "done" | "dropped" | "summary";
52
+
40
53
  /**
41
54
  * Supported model-facing operations.
42
55
  */
@@ -102,13 +115,13 @@ export function parse(snapshot: unknown): TodoList;
102
115
  export function view(list: TodoList): string;
103
116
 
104
117
  /**
105
- * The widget lines for `list`, or `undefined` for an empty list.
118
+ * Human-facing rows for `list`, or `undefined` for an empty list.
106
119
  *
107
120
  * # Errors
108
121
  *
109
122
  * Throws when `list` is not a valid todo list.
110
123
  */
111
- export function widget(list: TodoList): string[] | undefined;
124
+ export function widget(list: TodoList, expanded: boolean): WidgetRow[] | undefined;
112
125
 
113
126
  export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
114
127
 
@@ -119,7 +132,7 @@ export interface InitOutput {
119
132
  readonly init: (a: number, b: number, c: number) => void;
120
133
  readonly parse: (a: number, b: number) => void;
121
134
  readonly view: (a: number, b: number) => void;
122
- readonly widget: (a: number, b: number) => void;
135
+ readonly widget: (a: number, b: number, c: number) => void;
123
136
  readonly __wbindgen_export: (a: number, b: number) => number;
124
137
  readonly __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
125
138
  readonly __wbindgen_export3: (a: number) => void;
@@ -149,18 +149,19 @@ export function view(list) {
149
149
  }
150
150
 
151
151
  /**
152
- * The widget lines for `list`, or `undefined` for an empty list.
152
+ * Human-facing rows for `list`, or `undefined` for an empty list.
153
153
  *
154
154
  * # Errors
155
155
  *
156
156
  * Throws when `list` is not a valid todo list.
157
157
  * @param {TodoList} list
158
- * @returns {string[] | undefined}
158
+ * @param {boolean} expanded
159
+ * @returns {WidgetRow[] | undefined}
159
160
  */
160
- export function widget(list) {
161
+ export function widget(list, expanded) {
161
162
  try {
162
163
  const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
163
- wasm.widget(retptr, addBorrowedObject(list));
164
+ wasm.widget(retptr, addBorrowedObject(list), expanded);
164
165
  var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
165
166
  var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
166
167
  var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
Binary file
@@ -6,7 +6,7 @@ export const completion_reminder: (a: number, b: number) => void;
6
6
  export const init: (a: number, b: number, c: number) => void;
7
7
  export const parse: (a: number, b: number) => void;
8
8
  export const view: (a: number, b: number) => void;
9
- export const widget: (a: number, b: number) => void;
9
+ export const widget: (a: number, b: number, c: number) => void;
10
10
  export const __wbindgen_export: (a: number, b: number) => number;
11
11
  export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
12
12
  export const __wbindgen_export3: (a: number) => void;
package/src/index.ts CHANGED
@@ -1,12 +1,26 @@
1
- import { ownsTodo, replay, settle, show, start } from "./lifecycle.ts";
1
+ import { ownsTodo, settle } from "./lifecycle.ts";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { PlannotatorHandoff } from "./plannotator.ts";
3
4
  import { StringEnum } from "@earendil-works/pi-ai";
5
+ import { TodoPresentation } from "./presentation.ts";
6
+ import { TodoWork } from "./work.ts";
4
7
  import { Type } from "typebox";
8
+ import type { WorkHost } from "./work.ts";
5
9
  import { runTodo } from "./tool.ts";
6
10
 
7
- type TodoAPI = Readonly<
8
- Pick<ExtensionAPI, "appendEntry" | "getAllTools" | "on" | "registerCommand" | "registerTool">
9
- >;
11
+ type TodoAPI = WorkHost &
12
+ Readonly<
13
+ Pick<
14
+ ExtensionAPI,
15
+ | "appendEntry"
16
+ | "getAllTools"
17
+ | "on"
18
+ | "registerCommand"
19
+ | "registerShortcut"
20
+ | "registerTool"
21
+ | "sendMessage"
22
+ >
23
+ >;
10
24
 
11
25
  const items = Type.Immutable(
12
26
  Type.Array(Type.String(), { description: "For init or append: tasks, in order." }),
@@ -27,6 +41,10 @@ const items = Type.Immutable(
27
41
  * @param {TodoAPI} pi The extension API methods used here.
28
42
  */
29
43
  export default function piTodo(pi: TodoAPI): void {
44
+ const handoff = new PlannotatorHandoff(pi),
45
+ presentation = new TodoPresentation(),
46
+ work = new TodoWork(pi, presentation);
47
+ work.register(pi);
30
48
  pi.registerTool({
31
49
  description:
32
50
  "Keep the session todo list. Tasks are addressed by their exact content. One task is active; the next open task starts when it closes.",
@@ -37,7 +55,8 @@ export default function piTodo(pi: TodoAPI): void {
37
55
  },
38
56
  branch: () => ctx.sessionManager.getBranch(),
39
57
  });
40
- replay(ctx);
58
+ work.publish(ctx);
59
+ presentation.refresh(ctx);
41
60
  return result;
42
61
  },
43
62
  executionMode: "sequential",
@@ -46,18 +65,19 @@ export default function piTodo(pi: TodoAPI): void {
46
65
  parameters,
47
66
  });
48
67
  pi.on("session_start", (_event, ctx) => {
49
- start(pi, ctx);
68
+ presentation.start(pi, ctx);
69
+ work.start(ctx);
70
+ handoff.start(ctx);
71
+ });
72
+ pi.on("session_shutdown", () => {
73
+ handoff.stop();
74
+ work.stop();
50
75
  });
51
76
  pi.on("session_tree", (_event, ctx) => {
52
77
  if (ownsTodo(pi)) {
53
- replay(ctx);
78
+ work.publish(ctx);
79
+ presentation.reset(ctx);
54
80
  }
55
81
  });
56
82
  pi.on("agent_before_settle", (event, ctx) => settle(pi, event, ctx));
57
- pi.registerCommand("todos", {
58
- description: "Show the todo list on this session branch.",
59
- async handler(_args, ctx) {
60
- show(ctx);
61
- },
62
- });
63
83
  }
package/src/lifecycle.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import type {
2
2
  BoundaryResult,
3
+ ExtensionContext,
3
4
  ExtensionUIContext,
4
5
  SessionBoundaryDraft,
5
6
  SessionEntry,
7
+ Theme,
6
8
  } from "@earendil-works/pi-coding-agent";
7
9
  import { REMINDER_ENTRY, reminder } from "./reminder.ts";
8
10
  import { parse, view, widget } from "./wasm.ts";
11
+ import { TodoWidget } from "./widget.ts";
9
12
  import { isOwnSource } from "./source.ts";
10
13
  import { latestSnapshot } from "./state.ts";
11
14
 
12
15
  interface TodoContext {
16
+ readonly mode: ExtensionContext["mode"];
13
17
  readonly sessionManager: { readonly getBranch: () => readonly SessionEntry[] };
14
18
  readonly ui: Readonly<Pick<ExtensionUIContext, "notify" | "setWidget">>;
15
19
  }
@@ -36,10 +40,21 @@ const ownsTodo = (pi: TodoHost): boolean =>
36
40
  /**
37
41
  * Renders the selected branch snapshot, and removes the widget when the list is empty.
38
42
  * @param {TodoContext} ctx Branch and UI methods.
43
+ * @param {boolean} expanded Whether to reveal secondary task groups.
39
44
  */
40
- replay = (ctx: TodoContext): void => {
41
- const list = parse(latestSnapshot(ctx.sessionManager.getBranch()));
42
- ctx.ui.setWidget("pi-todo", widget(list));
45
+ replay = (ctx: TodoContext, expanded = false): void => {
46
+ if (ctx.mode !== "tui") {
47
+ return;
48
+ }
49
+ const list = parse(latestSnapshot(ctx.sessionManager.getBranch())),
50
+ rows = widget(list, expanded);
51
+ ctx.ui.setWidget(
52
+ "pi-todo",
53
+ rows === undefined
54
+ ? undefined
55
+ : (_tui: unknown, theme: Readonly<Pick<Theme, "fg" | "bold">>): TodoWidget =>
56
+ new TodoWidget(rows, expanded, theme),
57
+ );
43
58
  },
44
59
  /**
45
60
  * Applies the completion decision at pi's final actionable boundary.
@@ -0,0 +1,107 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { TodoContext } from "./lifecycle.ts";
3
+ import type { WorkHost } from "./work.ts";
4
+ import { ownsTodo } from "./lifecycle.ts";
5
+
6
+ /** The supported Plannotator external-execution event. */
7
+ interface ApprovedPlan {
8
+ readonly cwd: string;
9
+ readonly planFilePath: string;
10
+ readonly planContent: string;
11
+ readonly feedback?: string;
12
+ }
13
+
14
+ type PlanHost = WorkHost & Readonly<Pick<ExtensionAPI, "sendMessage">>;
15
+ type PlanContext = TodoContext & { readonly cwd: string };
16
+
17
+ /**
18
+ * Validates the event before it enters the model context.
19
+ * @param {unknown} value The shared event payload.
20
+ * @returns {boolean} Whether the required handoff fields are present.
21
+ */
22
+ const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
23
+ typeof value === "object" &&
24
+ value !== null &&
25
+ "cwd" in value &&
26
+ typeof value.cwd === "string" &&
27
+ "planFilePath" in value &&
28
+ typeof value.planFilePath === "string" &&
29
+ value.planFilePath.trim() !== "" &&
30
+ "planContent" in value &&
31
+ typeof value.planContent === "string" &&
32
+ value.planContent.trim() !== "" &&
33
+ (!("feedback" in value) || value.feedback === undefined || typeof value.feedback === "string"),
34
+ /**
35
+ * Continues the approved plan through the existing todo tool, not a second parser or store.
36
+ * @param {ApprovedPlan} plan The approved document and reviewer feedback.
37
+ * @returns {string} Instructions followed by the exact handoff data.
38
+ */
39
+ continuation = (plan: ApprovedPlan): string =>
40
+ [
41
+ "The user approved this plan in Plannotator. Continue execution in this session.",
42
+ "Use the todo tool as the only task tracker. Do not maintain Plannotator [DONE:n] markers.",
43
+ "First call todo with op=view to inspect the current branch list.",
44
+ "Before implementation, represent the approved plan's remaining work in that list.",
45
+ "If the list is empty, call todo with op=init and the remaining approved tasks in order.",
46
+ "Otherwise, reuse matching tasks and append missing tasks. Preserve unrelated work and completed progress.",
47
+ "If approved work remains, select its next open task with op=start. Never restart a completed task just because the plan was approved again.",
48
+ "If no approved work remains, report completion without changing tasks. Otherwise, update tasks with op=done or op=drop as work proceeds.",
49
+ "Resolve a relative planFilePath against cwd. The approved document and reviewer feedback follow as JSON:",
50
+ JSON.stringify({
51
+ cwd: plan.cwd,
52
+ feedback: plan.feedback,
53
+ planContent: plan.planContent,
54
+ planFilePath: plan.planFilePath,
55
+ }),
56
+ ].join("\n");
57
+
58
+ /** Owns the approval subscription, never the todo list or execution progress. */
59
+ class PlannotatorHandoff {
60
+ readonly #host: PlanHost;
61
+ #unsubscribe: (() => void) | undefined;
62
+
63
+ /**
64
+ * Keeps the supported event bus and conversation API.
65
+ * @param {PlanHost} host The Pi methods used by the handoff.
66
+ */
67
+ public constructor(host: PlanHost) {
68
+ this.#host = host;
69
+ }
70
+
71
+ /**
72
+ * Subscribes with the current session context after startup or replacement.
73
+ * @param {PlanContext} ctx The current branch and working directory.
74
+ */
75
+ public start(ctx: PlanContext): void {
76
+ this.stop();
77
+ this.#unsubscribe = this.#host.events.on(
78
+ "plannotator:plan-approved",
79
+ (value: unknown): void => {
80
+ if (!isApprovedPlan(value)) {
81
+ ctx.ui.notify("pi-todo: Invalid Plannotator approval. No execution started.", "error");
82
+ } else if (value.cwd !== ctx.cwd) {
83
+ ctx.ui.notify(
84
+ "pi-todo: The approved plan belongs to another working directory. No execution started.",
85
+ "error",
86
+ );
87
+ } else if (ownsTodo(this.#host)) {
88
+ this.#host.sendMessage(
89
+ { content: continuation(value), customType: "todo-plan-approved", display: true },
90
+ { deliverAs: "followUp", triggerTurn: true },
91
+ );
92
+ } else {
93
+ ctx.ui.notify("pi-todo: Another extension owns todo. No execution started.", "error");
94
+ }
95
+ },
96
+ );
97
+ }
98
+
99
+ /** Removes the previous session's listener before replacement or shutdown. */
100
+ public stop(): void {
101
+ this.#unsubscribe?.();
102
+ this.#unsubscribe = undefined;
103
+ }
104
+ }
105
+
106
+ export { PlannotatorHandoff };
107
+ export type { PlanContext, PlanHost };
@@ -0,0 +1,70 @@
1
+ import type { TodoContext, TodoHost } from "./lifecycle.ts";
2
+ import { ownsTodo, replay, start } from "./lifecycle.ts";
3
+
4
+ /** Owns transient expansion for one extension instance, never task state. */
5
+ class TodoPresentation {
6
+ #expanded = false;
7
+ #external = false;
8
+
9
+ /**
10
+ * Replays current branch tasks without changing expansion.
11
+ * @param {TodoContext} ctx Current branch and UI.
12
+ */
13
+ public refresh(ctx: TodoContext): void {
14
+ if (!this.#external) {
15
+ replay(ctx, this.#expanded);
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Collapses when the session starts or the selected branch changes.
21
+ * @param {TodoContext} ctx Current branch and UI.
22
+ */
23
+ public reset(ctx: TodoContext): void {
24
+ this.#expanded = false;
25
+ this.refresh(ctx);
26
+ }
27
+
28
+ /**
29
+ * Starts collapsed, or leaves another todo owner untouched.
30
+ * @param {TodoHost} pi Registered tool metadata.
31
+ * @param {TodoContext} ctx Current branch and UI.
32
+ */
33
+ public start(pi: TodoHost, ctx: TodoContext): void {
34
+ this.#expanded = false;
35
+ this.#external = false;
36
+ start(pi, ctx);
37
+ }
38
+
39
+ /**
40
+ * Yields only this extension's widget to an acknowledged work presenter.
41
+ * @param {TodoContext} ctx Current terminal UI.
42
+ */
43
+ public claim(ctx: TodoContext): void {
44
+ this.#external = true;
45
+ ctx.ui.setWidget("pi-todo", undefined);
46
+ }
47
+
48
+ /**
49
+ * Restores the standalone view without changing task state.
50
+ * @param {TodoContext} ctx Current terminal UI.
51
+ */
52
+ public release(ctx: TodoContext): void {
53
+ this.#external = false;
54
+ this.refresh(ctx);
55
+ }
56
+
57
+ /**
58
+ * Reveals or hides secondary groups without writing session state.
59
+ * @param {TodoHost} pi Registered tool metadata.
60
+ * @param {TodoContext} ctx Current branch and UI.
61
+ */
62
+ public toggle(pi: TodoHost, ctx: TodoContext): void {
63
+ if (ownsTodo(pi)) {
64
+ this.#expanded = !this.#expanded;
65
+ this.refresh(ctx);
66
+ }
67
+ }
68
+ }
69
+
70
+ export { TodoPresentation };
package/src/wasm.ts CHANGED
@@ -28,4 +28,6 @@ export type {
28
28
  Status,
29
29
  Task,
30
30
  TodoList,
31
+ WidgetKind,
32
+ WidgetRow,
31
33
  } from "./generated/todo_core.js";
package/src/widget.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import type { WidgetKind, WidgetRow } from "./wasm.ts";
4
+
5
+ const TODO_SHORTCUT = "alt+t",
6
+ horizontalPadding = 1,
7
+ verticalPadding = 0,
8
+ colors: Readonly<Record<WidgetKind, ThemeColor>> = {
9
+ active: "accent",
10
+ done: "success",
11
+ dropped: "dim",
12
+ heading: "muted",
13
+ summary: "muted",
14
+ upcoming: "text",
15
+ };
16
+
17
+ /** The presentation-only component; task state stays on the session branch. */
18
+ class TodoWidget extends Text {
19
+ readonly #rows: readonly Readonly<WidgetRow>[];
20
+ readonly #expanded: boolean;
21
+ readonly #theme: Readonly<Pick<Theme, "fg" | "bold">>;
22
+
23
+ /**
24
+ * Retains unstyled rows so each render uses the current theme.
25
+ * @param {readonly Readonly<WidgetRow>[]} rows Rust-owned status groups.
26
+ * @param {boolean} expanded Whether secondary groups are visible.
27
+ * @param {Readonly<Pick<Theme, "fg" | "bold">>} theme Pi theme helpers.
28
+ */
29
+ public constructor(
30
+ rows: readonly Readonly<WidgetRow>[],
31
+ expanded: boolean,
32
+ theme: Readonly<Pick<Theme, "fg" | "bold">>,
33
+ ) {
34
+ super("", horizontalPadding, verticalPadding);
35
+ this.#rows = rows;
36
+ this.#expanded = expanded;
37
+ this.#theme = theme;
38
+ }
39
+
40
+ /**
41
+ * Wraps all task text with Pi's native terminal component.
42
+ * @param {number} width Available terminal columns.
43
+ * @returns {string[]} Width-bounded, themed terminal lines.
44
+ */
45
+ public override render(width: number): string[] {
46
+ const lines = this.#rows.map((row) => {
47
+ const text = this.#theme.fg(colors[row.kind], row.text);
48
+ return row.kind === "active" ? this.#theme.bold(text) : text;
49
+ }),
50
+ action = this.#expanded ? "collapse" : "upcoming / done";
51
+ lines.push(this.#theme.fg("dim", `Alt+T · ${action}`));
52
+ this.setText(lines.join("\n"));
53
+ return super.render(width).map((line) => truncateToWidth(line, width));
54
+ }
55
+ }
56
+
57
+ export { TODO_SHORTCUT, TodoWidget };
package/src/work.ts ADDED
@@ -0,0 +1,230 @@
1
+ import type { TodoContext, TodoHost } from "./lifecycle.ts";
2
+ import { ownsTodo, show } from "./lifecycle.ts";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { TODO_SHORTCUT } from "./widget.ts";
5
+ import type { TodoPresentation } from "./presentation.ts";
6
+ import { latestSnapshot } from "./state.ts";
7
+ import { parse } from "./wasm.ts";
8
+
9
+ interface WorkHost extends TodoHost {
10
+ readonly events: Readonly<ExtensionAPI["events"]>;
11
+ }
12
+
13
+ type WorkCommands = Readonly<Pick<ExtensionAPI, "registerCommand" | "registerShortcut">>;
14
+ const protocolVersion = 1,
15
+ initialRevision = 0;
16
+
17
+ interface WorkContext extends TodoContext {
18
+ readonly sessionManager: TodoContext["sessionManager"] & {
19
+ readonly getSessionId: () => string;
20
+ readonly getLeafId: () => string | null;
21
+ };
22
+ }
23
+
24
+ /** The identity of one work presenter requesting this provider's snapshot. */
25
+ interface Presenter {
26
+ readonly presenter: string;
27
+ readonly request: string;
28
+ readonly version: typeof protocolVersion;
29
+ }
30
+
31
+ /**
32
+ * Checks a presentation request without trusting an extension's event payload.
33
+ * @param {unknown} value Event payload.
34
+ * @returns {boolean} Whether the envelope uses the supported protocol.
35
+ */
36
+ const isPresenter = (value: unknown): value is Presenter =>
37
+ typeof value === "object" &&
38
+ value !== null &&
39
+ "version" in value &&
40
+ value.version === protocolVersion &&
41
+ "presenter" in value &&
42
+ typeof value.presenter === "string" &&
43
+ "request" in value &&
44
+ typeof value.request === "string";
45
+
46
+ /** Publishes validated branch data; presentation claims never write task state. */
47
+ class TodoWork {
48
+ readonly #host: WorkHost;
49
+ readonly #presentation: Readonly<TodoPresentation>;
50
+ #context: WorkContext | undefined;
51
+ #publisher = "";
52
+ #revision = initialRevision;
53
+ #presenter: Presenter | undefined;
54
+ #claimed = false;
55
+ #subscriptions: (() => void)[] = [];
56
+
57
+ /**
58
+ * Keeps the event bus and the existing standalone presentation owner.
59
+ * @param {WorkHost} host Pi event bus and tool ownership.
60
+ * @param {Readonly<TodoPresentation>} presentation Standalone display owner.
61
+ */
62
+ public constructor(host: WorkHost, presentation: Readonly<TodoPresentation>) {
63
+ this.#host = host;
64
+ this.#presentation = presentation;
65
+ }
66
+
67
+ /**
68
+ * Registers view entrypoints with a shared or standalone presentation target.
69
+ * @param {WorkCommands} commands Pi command registration.
70
+ */
71
+ public register(commands: WorkCommands): void {
72
+ commands.registerShortcut(TODO_SHORTCUT, {
73
+ description: "Show todo details.",
74
+ handler: (ctx): void => {
75
+ this.#toggle(ctx);
76
+ },
77
+ });
78
+ commands.registerCommand("todos", {
79
+ description: "Show todo details; /todos list prints the full branch list.",
80
+ handler: async (args, ctx): Promise<void> => {
81
+ if (ownsTodo(this.#host)) {
82
+ if (args.trim() === "list" || ctx.mode !== "tui") {
83
+ show(ctx);
84
+ } else {
85
+ this.#toggle(ctx);
86
+ }
87
+ }
88
+ },
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Opens the shared view or toggles the existing standalone view.
94
+ * @param {TodoContext} ctx Current UI and branch.
95
+ */
96
+ #toggle(ctx: TodoContext): void {
97
+ if (!this.open()) {
98
+ this.#presentation.toggle(this.#host, ctx);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Subscribes only when this extension owns the terminal todo tool.
104
+ * @param {WorkContext} ctx Current Pi session.
105
+ */
106
+ public start(ctx: WorkContext): void {
107
+ this.stop();
108
+ if (ctx.mode !== "tui" || !ownsTodo(this.#host)) {
109
+ return;
110
+ }
111
+ this.#context = ctx;
112
+ this.#publisher = crypto.randomUUID();
113
+ this.#revision = initialRevision;
114
+ this.#subscriptions = [
115
+ this.#host.events.on("oisin.work.request", (value: unknown): void => {
116
+ if (isPresenter(value)) {
117
+ this.#presenter = value;
118
+ if (this.#context !== undefined) {
119
+ this.publish(this.#context);
120
+ }
121
+ }
122
+ }),
123
+ this.#host.events.on("oisin.work.claim", (value: unknown): void => {
124
+ this.#claim(value);
125
+ }),
126
+ this.#host.events.on("oisin.work.release", (value: unknown): void => {
127
+ if (this.#matches(value)) {
128
+ this.#release();
129
+ }
130
+ }),
131
+ ];
132
+ this.#host.events.emit("oisin.todo.ready", { publisher: this.#publisher, version: 1 });
133
+ }
134
+
135
+ /**
136
+ * Publishes the actual branch snapshot after tool or branch changes.
137
+ * @param {WorkContext} ctx Current Pi session, never a stored file's last entry.
138
+ */
139
+ public publish(ctx: WorkContext): void {
140
+ if (this.#context === undefined || this.#presenter === undefined) {
141
+ return;
142
+ }
143
+ this.#context = ctx;
144
+ this.#revision += 1;
145
+ const list = parse(latestSnapshot(ctx.sessionManager.getBranch()));
146
+ this.#host.events.emit("oisin.todo.snapshot", {
147
+ branch: ctx.sessionManager.getLeafId(),
148
+ presenter: this.#presenter.presenter,
149
+ publisher: this.#publisher,
150
+ request: this.#presenter.request,
151
+ revision: this.#revision,
152
+ session: ctx.sessionManager.getSessionId(),
153
+ tasks: list.tasks,
154
+ version: 1,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Requests the read-only Todos view without changing expansion or task state.
160
+ * @returns {boolean} Whether a shared presenter owns the view.
161
+ */
162
+ public open(): boolean {
163
+ if (!this.#claimed || this.#presenter === undefined) {
164
+ return false;
165
+ }
166
+ this.#host.events.emit("oisin.work.open", {
167
+ presenter: this.#presenter.presenter,
168
+ request: this.#presenter.request,
169
+ version: 1,
170
+ view: "todos",
171
+ });
172
+ return true;
173
+ }
174
+
175
+ /** Releases subscriptions and tells the presenter to discard this provider. */
176
+ public stop(): void {
177
+ for (const unsubscribe of this.#subscriptions) {
178
+ unsubscribe();
179
+ }
180
+ this.#subscriptions = [];
181
+ if (this.#context !== undefined) {
182
+ this.#host.events.emit("oisin.todo.stop", { publisher: this.#publisher, version: 1 });
183
+ }
184
+ this.#release();
185
+ this.#context = undefined;
186
+ }
187
+
188
+ /**
189
+ * Accepts only an acknowledgement of the latest published snapshot.
190
+ * @param {unknown} value Claim envelope.
191
+ */
192
+ #claim(value: unknown): void {
193
+ if (
194
+ this.#matches(value) &&
195
+ "publisher" in value &&
196
+ value.publisher === this.#publisher &&
197
+ "revision" in value &&
198
+ value.revision === this.#revision &&
199
+ this.#context !== undefined
200
+ ) {
201
+ this.#claimed = true;
202
+ this.#presentation.claim(this.#context);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Matches a live presenter, excluding delayed messages from replaced sessions.
208
+ * @param {unknown} value Event envelope.
209
+ * @returns {boolean} Whether the envelope belongs to the current presenter.
210
+ */
211
+ #matches(value: unknown): value is Presenter {
212
+ return (
213
+ isPresenter(value) &&
214
+ value.presenter === this.#presenter?.presenter &&
215
+ value.request === this.#presenter.request
216
+ );
217
+ }
218
+
219
+ /** Restores the standalone display when shared presentation ends. */
220
+ #release(): void {
221
+ this.#claimed = false;
222
+ this.#presenter = undefined;
223
+ if (this.#context !== undefined) {
224
+ this.#presentation.release(this.#context);
225
+ }
226
+ }
227
+ }
228
+
229
+ export { TodoWork };
230
+ export type { WorkContext, WorkHost };