@oisin-ee/pi-todo 0.5.1 → 0.6.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
@@ -1,6 +1,6 @@
1
1
  # @oisin-ee/pi-todo
2
2
 
3
- A `todo` tool for [pi](https://pi.dev). The model keeps one ordered task list for the session. Exactly one task is active.
3
+ A `todo` tool for [pi](https://pi.dev). The model keeps one ordered list of outcomes and bounded subtasks for the session. Multiple units can be active together.
4
4
  The list rules run in Rust, compiled to WebAssembly.
5
5
 
6
6
  ```bash
@@ -13,22 +13,60 @@ To install it with the other packages, use [`@oisin-ee/oisinpi`](https://www.npm
13
13
 
14
14
  The model calls `todo` with one `op`. Tasks are addressed by their exact content.
15
15
 
16
- | `op` | Effect |
17
- | -------- | ------------------------------------------------------------ |
18
- | `init` | Replaces the list with `items`. |
19
- | `append` | Adds `items` to the end. |
20
- | `start` | Makes `task` the active task. |
21
- | `done` | Completes `task`. Without `task`, completes every open task. |
22
- | `drop` | Abandons `task`. Without `task`, abandons every open task. |
23
- | `view` | Returns the list. It does not change it. |
16
+ | `op` | Effect |
17
+ | -------- | --------------------------------------------------------------- |
18
+ | `init` | Replaces the list with `items`. |
19
+ | `append` | Adds `items` to the end. |
20
+ | `start` | Explicitly activates `task` without demoting other active work. |
21
+ | `done` | Completes `task`. Without `task`, completes every open task. |
22
+ | `drop` | Abandons `task`. Without `task`, abandons every open task. |
23
+ | `view` | Returns the list. It does not change it. |
24
24
 
25
- When the active task closes, the next open task starts. An empty task, a repeated task, or an unknown task is an error,
26
- and the list stays as it was.
25
+ Creation and append leave tasks pending. Nothing starts automatically. This replaces the previous single-active, automatic-start behavior.
26
+ Use `start` to activate or reopen work explicitly. Starting a child also opens its parent. Starting a parent does not start its children.
27
+
28
+ `items` accepts strings or outcomes with string subtasks:
29
+
30
+ ```json
31
+ {
32
+ "op": "init",
33
+ "items": [
34
+ {
35
+ "content": "Password reset",
36
+ "subtasks": ["Implement tokens and behavior tests", "Implement form and interaction tests"]
37
+ },
38
+ "Update documentation"
39
+ ]
40
+ }
41
+ ```
42
+
43
+ To target a child, supply its exact `task` content and its exact root `parent` content:
44
+
45
+ ```json
46
+ { "op": "start", "parent": "Password reset", "task": "Implement tokens and behavior tests" }
47
+ ```
48
+
49
+ To add children, use `append` with `parent` and string `items`. Only two levels are permitted.
50
+ Root names are unique. Child names are unique within their parent. Different parents can use the same child name.
51
+ Blank names, duplicate siblings, unknown targets, grandchildren, and malformed requests fail without saving a change.
52
+
53
+ Each executable unit fits one agent session, with one deliverable, clear scope, and an acceptance condition.
54
+ Split independent outcomes or work that needs another session. Combine trivial steps with their deliverable and tests.
55
+ Delegate independent units separately. The lead can execute one unit. Keep dependent units ordered.
56
+ Do not delegate an oversized parent as one assignment.
57
+
58
+ A parent stays open after its children close and shows **Ready for review**.
59
+ Complete the parent explicitly after integration review. Completing a parent with open children fails, including bulk completion.
60
+ Dropping a parent abandons open children but preserves completed and abandoned children.
61
+ Untargeted `done` and `drop` affect open work only. A parent without `task` is not a bulk-child selector.
62
+ Appending children to a closed parent reopens it as pending. An active parent stays active.
63
+ Outcome and subtask progress remain separate. Abandoned work never counts as completed.
27
64
 
28
65
  ## State
29
66
 
30
67
  Each task change is saved as a `todo-state` entry on the session branch. A resumed session, a `/tree` move,
31
68
  and calls made from inside codemode all see the list of the current branch.
69
+ Old flat snapshots retain their recorded statuses and read with empty subtasks.
32
70
 
33
71
  ## Widget
34
72
 
@@ -45,12 +83,15 @@ Outside the terminal UI, `/todos` prints the list instead.
45
83
  ## Shared work display
46
84
 
47
85
  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`.
86
+ Tasks remain on the current Pi session branch. Wrangle receives a read-only presentation capability through `pi.events`.
87
+ Pi-todo owns the renderer in both standalone and shared views. Wrangle hosts it above the editor without opening a popup.
88
+ Alt+J/K scrolls the hosted rows. Alt+I toggles their expansion. Ordinary editor keys remain unchanged.
49
89
  `/todos` and Alt+T request the shared Todos view. `/todos list` still prints the full list.
50
90
  When shared presentation ends, the standalone widget returns without changing task state.
51
91
 
52
- The [version 1 display contract](https://github.com/oisin-ee/wrangle/blob/main/docs/pi-work.md)
92
+ The [version 2 display contract](https://github.com/oisin-ee/wrangle/blob/main/docs/pi-work.md)
53
93
  defines the snapshot request, acknowledgement, release, and read-only view events.
94
+ Incompatible versions leave the standalone widget available. Rendering failures restore it without changing saved tasks.
54
95
 
55
96
  ## Plannotator plans
56
97
 
@@ -74,7 +115,8 @@ Malformed events, a different working directory, or another owner of the `todo`
74
115
 
75
116
  ## Reminder
76
117
 
77
- When the agent stops with open tasks, pi-todo adds a hidden reminder that lists them and continues the turn.
118
+ When the agent stops with open tasks, pi-todo adds a hidden reminder and continues the turn.
119
+ The reminder names open children and parents that await integration review.
78
120
  It sends at most 3 reminders per user message. After a reminder, it waits for the list to change before it sends
79
121
  another. A new user message resets the count.
80
122
 
@@ -84,7 +126,7 @@ If another package already registers `todo`, pi-todo stays inactive and shows a
84
126
 
85
127
  ## Source
86
128
 
87
- The operations, the active-task rule, and the reminder follow
129
+ The original flat operations and the reminder derive from
88
130
  [oh-my-pi](https://github.com/can1357/oh-my-pi/tree/e7fe318/packages/coding-agent/src) (`tools/todo`,
89
131
  `todo-tracker.ts`), MIT. Saving a custom entry on every change follows
90
132
  [rpiv-todo](https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-todo).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oisin-ee/pi-todo",
3
- "version": "0.5.1",
3
+ "version": "0.6.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",
package/src/display.ts ADDED
@@ -0,0 +1,62 @@
1
+ import type { Subtask, TodoList } from "./wasm.ts";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { TodoView } from "./embedded.ts";
4
+ import { view } from "./wasm.ts";
5
+
6
+ /** Host-owned layout and rendering services for an inline todo view. */
7
+ interface ViewHost {
8
+ readonly theme: Readonly<Pick<Theme, "fg" | "bold">>;
9
+ readonly height: () => number;
10
+ readonly requestRender: () => void;
11
+ }
12
+
13
+ /** A provider-owned view. The host forwards updates without interpreting state. */
14
+ interface HostedView {
15
+ readonly render: (width: number) => string[];
16
+ readonly handleInput: (input: string) => void;
17
+ readonly invalidate: () => void;
18
+ readonly update: (state: unknown) => void;
19
+ readonly dispose: () => void;
20
+ }
21
+
22
+ /** Transient presentation data, never persisted in a session entry. */
23
+ interface TodoDisplay {
24
+ readonly empty: boolean;
25
+ readonly summary: string;
26
+ readonly current: string;
27
+ readonly text: string;
28
+ readonly state: unknown;
29
+ readonly createView: (host: ViewHost) => HostedView;
30
+ }
31
+
32
+ const emptyCount = 0,
33
+ headerIndex = 0,
34
+ /**
35
+ * Projects the core's summary and supplies its reusable renderer.
36
+ * @param {TodoList} list The validated current branch snapshot.
37
+ * @returns {TodoDisplay} An in-process presentation capability.
38
+ */
39
+ display = (list: TodoList): TodoDisplay => {
40
+ const text = view(list),
41
+ active = list.tasks.flatMap((task) => [
42
+ ...(task.status === "in_progress" ? [task.content] : []),
43
+ ...(task.subtasks ?? [])
44
+ .filter((child: Readonly<Subtask>) => child.status === "in_progress")
45
+ .map((child: Readonly<Subtask>) => `${task.content} → ${child.content}`),
46
+ ]),
47
+ current =
48
+ active.length > emptyCount
49
+ ? active.join("; ")
50
+ : (list.tasks.find((task) => task.status === "pending")?.content ?? "");
51
+ return {
52
+ createView: (host) => new TodoView(list, host),
53
+ current,
54
+ empty: list.tasks.length === emptyCount,
55
+ state: list,
56
+ summary: text.split("\n").at(headerIndex) ?? "",
57
+ text,
58
+ };
59
+ };
60
+
61
+ export { display };
62
+ export type { HostedView, TodoDisplay, ViewHost };
@@ -0,0 +1,105 @@
1
+ import { Key, ScrollView, matchesKey } from "@earendil-works/pi-tui";
2
+ import { parse, widget } from "./wasm.ts";
3
+ import type { TodoList } from "./wasm.ts";
4
+ import { TodoWidget } from "./widget.ts";
5
+ import type { ViewHost } from "./display.ts";
6
+
7
+ const minimumHeight = 1,
8
+ previousLine = -1,
9
+ nextLine = 1;
10
+
11
+ /** Inline viewport over the same renderer used by the standalone widget. */
12
+ class TodoView {
13
+ readonly #host: ViewHost;
14
+ #list: TodoList;
15
+ #expanded = true;
16
+ #disposed = false;
17
+ readonly #body: TodoWidget;
18
+ readonly #scroll: ScrollView;
19
+
20
+ /**
21
+ * Creates an expansion-local view without event subscriptions or task writes.
22
+ * @param {TodoList} list Validated branch tasks.
23
+ * @param {ViewHost} host Host layout and rendering services.
24
+ */
25
+ public constructor(list: TodoList, host: ViewHost) {
26
+ this.#host = host;
27
+ this.#list = list;
28
+ this.#body = this.#content();
29
+ this.#scroll = new ScrollView(this.#body, { overscroll: "contain", scrollbar: "hidden" });
30
+ }
31
+
32
+ /**
33
+ * Creates the shared widget content.
34
+ * @returns {TodoWidget} Theme-aware rows.
35
+ */
36
+ #content(): TodoWidget {
37
+ const body = new TodoWidget(
38
+ widget(this.#list, this.#expanded) ?? [],
39
+ this.#expanded,
40
+ this.#host.theme,
41
+ );
42
+ body.setShortcut("Alt+I");
43
+ return body;
44
+ }
45
+
46
+ /**
47
+ * Applies a provider snapshot while preserving the viewport.
48
+ * @param {unknown} state The opaque state from the display capability.
49
+ */
50
+ public update(state: unknown): void {
51
+ if (!this.#disposed) {
52
+ this.#list = parse(state);
53
+ this.#body.setRows(widget(this.#list, this.#expanded) ?? [], this.#expanded);
54
+ this.invalidate();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Renders a bounded, scrollable view with the active host theme.
60
+ * @param {number} width Available terminal columns.
61
+ * @returns {string[]} Visible terminal lines.
62
+ */
63
+ public render(width: number): string[] {
64
+ if (this.#disposed) {
65
+ return [];
66
+ }
67
+ const height = Math.max(minimumHeight, this.#host.height());
68
+ this.#scroll.updateLayout(this.#body.render(width).length, height, this.#host.requestRender);
69
+ return this.#scroll
70
+ .render(width)
71
+ .slice(this.#scroll.scrollTop, this.#scroll.scrollTop + height);
72
+ }
73
+
74
+ /**
75
+ * Handles only navigation forwarded by the inline host.
76
+ * @param {string} input A translated host shortcut.
77
+ */
78
+ public handleInput(input: string): void {
79
+ if (this.#disposed) {
80
+ return;
81
+ }
82
+ if (matchesKey(input, Key.enter)) {
83
+ this.#expanded = !this.#expanded;
84
+ this.#body.setRows(widget(this.#list, this.#expanded) ?? [], this.#expanded);
85
+ } else if (matchesKey(input, Key.up)) {
86
+ this.#scroll.scrollBy(previousLine);
87
+ } else if (matchesKey(input, Key.down)) {
88
+ this.#scroll.scrollBy(nextLine);
89
+ }
90
+ this.#host.requestRender();
91
+ }
92
+
93
+ /** Invalidates both native components after a theme or data change. */
94
+ public invalidate(): void {
95
+ this.#body.invalidate();
96
+ this.#scroll.invalidate();
97
+ }
98
+
99
+ /** Ends this view's lifetime without modifying task state. */
100
+ public dispose(): void {
101
+ this.#disposed = true;
102
+ }
103
+ }
104
+
105
+ export { TodoView };
@@ -1,5 +1,13 @@
1
1
  /* tslint:disable */
2
2
  /* eslint-disable */
3
+ /**
4
+ * A leaf work unit. Grandchildren are not accepted.
5
+ */
6
+ export interface Subtask {
7
+ content: string;
8
+ status: Status;
9
+ }
10
+
3
11
  /**
4
12
  * A successful operation; views never persist a snapshot.
5
13
  */
@@ -8,6 +16,15 @@ export interface Change {
8
16
  persist: boolean;
9
17
  }
10
18
 
19
+ /**
20
+ * An outcome, optionally divided into bounded work units.
21
+ */
22
+ export interface Task {
23
+ content: string;
24
+ status: Status;
25
+ subtasks?: Subtask[];
26
+ }
27
+
11
28
  /**
12
29
  * Branch facts supplied to the reminder boundary.
13
30
  */
@@ -16,12 +33,13 @@ export interface ReminderHistory {
16
33
  }
17
34
 
18
35
  /**
19
- * One operation, with task content used as an exact identifier.
36
+ * Exact task content, scoped by an optional root parent.
20
37
  */
21
38
  export interface Request {
22
39
  op: Operation;
23
- items: string[] | undefined;
24
- task: string | undefined;
40
+ items?: readonly Item[] | undefined;
41
+ task?: string | undefined;
42
+ parent?: string | undefined;
25
43
  }
26
44
 
27
45
  /**
@@ -33,17 +51,14 @@ export interface WidgetRow {
33
51
  }
34
52
 
35
53
  /**
36
- * One task, addressed by its exact content.
54
+ * Relevant facts in branch order, including proposed boundary entries.
37
55
  */
38
- export interface Task {
39
- content: string;
40
- status: Status;
41
- }
56
+ export type ReminderFact = { kind: "user" } | { kind: "snapshot"; list: TodoList } | { kind: "reminder" };
42
57
 
43
58
  /**
44
- * Relevant facts in branch order, including proposed boundary entries.
59
+ * Root creation accepts standalone outcomes or outcomes with leaf work units.
45
60
  */
46
- export type ReminderFact = { kind: "user" } | { kind: "snapshot"; list: TodoList } | { kind: "reminder" };
61
+ export type Item = string | { content: string; subtasks: readonly string[] };
47
62
 
48
63
  /**
49
64
  * Semantic styling for a terminal row.
@@ -56,7 +71,7 @@ export type WidgetKind = "heading" | "active" | "upcoming" | "done" | "dropped"
56
71
  export type Operation = "init" | "start" | "done" | "drop" | "append" | "view";
57
72
 
58
73
  /**
59
- * The state of one task.
74
+ * The state of one outcome or work unit.
60
75
  */
61
76
  export type Status = "pending" | "in_progress" | "completed" | "abandoned";
62
77
 
Binary file
@@ -0,0 +1,8 @@
1
+ /** Shared instructions for creation results, tool calls, and approved plans. */
2
+ const workUnitGuidance =
3
+ "Size each executable unit for one agent session, with one deliverable, clear scope, and an acceptance condition. " +
4
+ "Split independent outcomes or work that needs another session. Combine trivial steps with their deliverable and tests. " +
5
+ "Delegate independent units separately; the lead can execute one unit. Keep dependent units ordered. " +
6
+ "Do not delegate an oversized parent as one assignment. Close children before explicit parent integration review.";
7
+
8
+ export { workUnitGuidance };
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ import { TodoWork } from "./work.ts";
7
7
  import { Type } from "typebox";
8
8
  import type { WorkHost } from "./work.ts";
9
9
  import { runTodo } from "./tool.ts";
10
+ import { workUnitGuidance } from "./guidance.ts";
10
11
 
11
12
  type TodoAPI = WorkHost &
12
13
  Readonly<
@@ -22,17 +23,34 @@ type TodoAPI = WorkHost &
22
23
  >
23
24
  >;
24
25
 
25
- const items = Type.Immutable(
26
- Type.Array(Type.String(), { description: "For init or append: tasks, in order." }),
26
+ const subtasks = Type.Immutable(Type.Array(Type.String())),
27
+ outcome = Type.Immutable(
28
+ Type.Object({ content: Type.String(), subtasks }, { additionalProperties: false }),
29
+ ),
30
+ item = Type.Union([Type.String(), outcome]),
31
+ items = Type.Immutable(
32
+ Type.Array(item, {
33
+ description:
34
+ "Pending outcomes for init/append. With parent, append accepts string work units only. Two levels only.",
35
+ }),
27
36
  ),
28
37
  parameters = Type.Object({
29
38
  items: Type.Optional(items),
30
39
  op: StringEnum(["init", "start", "done", "drop", "append", "view"] as const, {
31
40
  description:
32
- "init replaces; start selects; done completes; drop abandons; append adds; view reads.",
41
+ "init replaces; start explicitly activates without demoting others; done completes; drop abandons open work; append adds pending work; view reads.",
33
42
  }),
43
+ parent: Type.Optional(
44
+ Type.String({
45
+ description:
46
+ "Exact root content to target a child. Child start/done/drop also require task.",
47
+ }),
48
+ ),
34
49
  task: Type.Optional(
35
- Type.String({ description: "Exact task content. Omit for done/drop to close all tasks." }),
50
+ Type.String({
51
+ description:
52
+ "Exact task content within parent, or at root. Omit for done/drop to close open roots. Complete children before parents.",
53
+ }),
36
54
  ),
37
55
  });
38
56
 
@@ -46,8 +64,7 @@ export default function piTodo(pi: TodoAPI): void {
46
64
  work = new TodoWork(pi, presentation);
47
65
  work.register(pi);
48
66
  pi.registerTool({
49
- description:
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.",
67
+ description: `Keep branch-local outcomes and optional bounded subtasks. Address exact content plus optional parent. Creation is pending. Start explicitly; parallel starts preserve other active work. Parents require explicit integration review after children close. ${workUnitGuidance}`,
51
68
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
52
69
  const result = runTodo(params, {
53
70
  append: (customType, list) => {
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import type { TodoContext } from "./lifecycle.ts";
3
3
  import type { WorkHost } from "./work.ts";
4
4
  import { ownsTodo } from "./lifecycle.ts";
5
+ import { workUnitGuidance } from "./guidance.ts";
5
6
 
6
7
  /** The supported Plannotator external-execution event. */
7
8
  interface ApprovedPlan {
@@ -41,10 +42,11 @@ const isApprovedPlan = (value: unknown): value is ApprovedPlan =>
41
42
  "The user approved this plan in Plannotator. Continue execution in this session.",
42
43
  "Use the todo tool as the only task tracker. Do not maintain Plannotator [DONE:n] markers.",
43
44
  "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
+ "Before implementation, represent remaining outcomes with optional bounded subtasks in that list. Use parent plus exact task for child operations.",
46
+ workUnitGuidance,
45
47
  "If the list is empty, call todo with op=init and the remaining approved tasks in order.",
46
48
  "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.",
49
+ "If approved work remains, explicitly start its open units with op=start. Independent units can stay active together. Never restart completed work because the plan was approved again.",
48
50
  "If no approved work remains, report completion without changing tasks. Otherwise, update tasks with op=done or op=drop as work proceeds.",
49
51
  "Resolve a relative planFilePath against cwd. The approved document and reviewer feedback follow as JSON:",
50
52
  JSON.stringify({
package/src/tool.ts CHANGED
@@ -2,11 +2,16 @@ import { STATE_ENTRY, latestSnapshot } from "./state.ts";
2
2
  import { apply, parse, view } from "./wasm.ts";
3
3
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
4
  import type { TodoList } from "./wasm.ts";
5
+ import { workUnitGuidance } from "./guidance.ts";
5
6
 
6
7
  /** One `todo` call, as the schema in `index.ts` admits it. */
7
8
  interface TodoParams {
8
9
  readonly op: "init" | "start" | "done" | "drop" | "append" | "view";
9
- readonly items?: readonly string[];
10
+ readonly items?: readonly (
11
+ | string
12
+ | { readonly content: string; readonly subtasks: readonly string[] }
13
+ )[];
14
+ readonly parent?: string;
10
15
  readonly task?: string;
11
16
  }
12
17
 
@@ -25,10 +30,13 @@ interface TodoResult {
25
30
  /**
26
31
  * The result for `list`.
27
32
  * @param {TodoList} list A valid list.
33
+ * @param {boolean} creation Whether the call created work.
28
34
  * @returns {TodoResult} The summary and the list.
29
35
  */
30
- const result = (list: TodoList): TodoResult => ({
31
- content: [{ text: view(list), type: "text" }],
36
+ const result = (list: TodoList, creation: boolean): TodoResult => ({
37
+ content: [
38
+ { text: creation ? `${view(list)}\n\n${workUnitGuidance}` : view(list), type: "text" },
39
+ ],
32
40
  details: list,
33
41
  }),
34
42
  /**
@@ -40,15 +48,11 @@ const result = (list: TodoList): TodoResult => ({
40
48
  */
41
49
  runTodo = (params: TodoParams, session: TodoSession): TodoResult => {
42
50
  const current = parse(latestSnapshot(session.branch())),
43
- { list, persist } = apply(current, {
44
- items: params.items ? [...params.items] : params.items,
45
- op: params.op,
46
- task: params.task,
47
- });
51
+ { list, persist } = apply(current, params);
48
52
  if (persist) {
49
53
  session.append(STATE_ENTRY, list);
50
54
  }
51
- return result(list);
55
+ return result(list, params.op === "init" || params.op === "append");
52
56
  };
53
57
 
54
58
  export { runTodo };
package/src/wasm.ts CHANGED
@@ -26,6 +26,7 @@ export type {
26
26
  ReminderFact,
27
27
  Request,
28
28
  Status,
29
+ Subtask,
29
30
  Task,
30
31
  TodoList,
31
32
  WidgetKind,
package/src/widget.ts CHANGED
@@ -16,8 +16,9 @@ const TODO_SHORTCUT = "alt+t",
16
16
 
17
17
  /** The presentation-only component; task state stays on the session branch. */
18
18
  class TodoWidget extends Text {
19
- readonly #rows: readonly Readonly<WidgetRow>[];
20
- readonly #expanded: boolean;
19
+ #rows: readonly Readonly<WidgetRow>[];
20
+ #expanded: boolean;
21
+ #shortcut = "Alt+T";
21
22
  readonly #theme: Readonly<Pick<Theme, "fg" | "bold">>;
22
23
 
23
24
  /**
@@ -37,6 +38,25 @@ class TodoWidget extends Text {
37
38
  this.#theme = theme;
38
39
  }
39
40
 
41
+ /**
42
+ * Updates rows without replacing the host's scrollable component.
43
+ * @param {readonly Readonly<WidgetRow>[]} rows Updated semantic rows.
44
+ * @param {boolean} expanded Whether secondary groups are visible.
45
+ */
46
+ public setRows(rows: readonly Readonly<WidgetRow>[], expanded: boolean): void {
47
+ this.#rows = rows;
48
+ this.#expanded = expanded;
49
+ this.invalidate();
50
+ }
51
+
52
+ /**
53
+ * Uses the shortcut owned by the containing surface.
54
+ * @param {string} shortcut The host's display shortcut.
55
+ */
56
+ public setShortcut(shortcut: string): void {
57
+ this.#shortcut = shortcut;
58
+ }
59
+
40
60
  /**
41
61
  * Wraps all task text with Pi's native terminal component.
42
62
  * @param {number} width Available terminal columns.
@@ -48,7 +68,7 @@ class TodoWidget extends Text {
48
68
  return row.kind === "active" ? this.#theme.bold(text) : text;
49
69
  }),
50
70
  action = this.#expanded ? "collapse" : "upcoming / done";
51
- lines.push(this.#theme.fg("dim", `Alt+T · ${action}`));
71
+ lines.push(this.#theme.fg("dim", `${this.#shortcut} · ${action}`));
52
72
  this.setText(lines.join("\n"));
53
73
  return super.render(width).map((line) => truncateToWidth(line, width));
54
74
  }
package/src/work.ts CHANGED
@@ -3,6 +3,7 @@ import { ownsTodo, show } from "./lifecycle.ts";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
4
  import { TODO_SHORTCUT } from "./widget.ts";
5
5
  import type { TodoPresentation } from "./presentation.ts";
6
+ import { display } from "./display.ts";
6
7
  import { latestSnapshot } from "./state.ts";
7
8
  import { parse } from "./wasm.ts";
8
9
 
@@ -11,7 +12,7 @@ interface WorkHost extends TodoHost {
11
12
  }
12
13
 
13
14
  type WorkCommands = Readonly<Pick<ExtensionAPI, "registerCommand" | "registerShortcut">>;
14
- const protocolVersion = 1,
15
+ const protocolVersion = 2,
15
16
  initialRevision = 0;
16
17
 
17
18
  interface WorkContext extends TodoContext {
@@ -129,7 +130,10 @@ class TodoWork {
129
130
  }
130
131
  }),
131
132
  ];
132
- this.#host.events.emit("oisin.todo.ready", { publisher: this.#publisher, version: 1 });
133
+ this.#host.events.emit("oisin.todo.ready", {
134
+ publisher: this.#publisher,
135
+ version: protocolVersion,
136
+ });
133
137
  }
134
138
 
135
139
  /**
@@ -145,13 +149,13 @@ class TodoWork {
145
149
  const list = parse(latestSnapshot(ctx.sessionManager.getBranch()));
146
150
  this.#host.events.emit("oisin.todo.snapshot", {
147
151
  branch: ctx.sessionManager.getLeafId(),
152
+ display: display(list),
148
153
  presenter: this.#presenter.presenter,
149
154
  publisher: this.#publisher,
150
155
  request: this.#presenter.request,
151
156
  revision: this.#revision,
152
157
  session: ctx.sessionManager.getSessionId(),
153
- tasks: list.tasks,
154
- version: 1,
158
+ version: protocolVersion,
155
159
  });
156
160
  }
157
161
 
@@ -166,7 +170,7 @@ class TodoWork {
166
170
  this.#host.events.emit("oisin.work.open", {
167
171
  presenter: this.#presenter.presenter,
168
172
  request: this.#presenter.request,
169
- version: 1,
173
+ version: protocolVersion,
170
174
  view: "todos",
171
175
  });
172
176
  return true;
@@ -179,7 +183,10 @@ class TodoWork {
179
183
  }
180
184
  this.#subscriptions = [];
181
185
  if (this.#context !== undefined) {
182
- this.#host.events.emit("oisin.todo.stop", { publisher: this.#publisher, version: 1 });
186
+ this.#host.events.emit("oisin.todo.stop", {
187
+ publisher: this.#publisher,
188
+ version: protocolVersion,
189
+ });
183
190
  }
184
191
  this.#release();
185
192
  this.#context = undefined;