@hacky.software/opencode-todo-supervisor 0.3.4 → 0.3.5

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
@@ -4,7 +4,8 @@ An [OpenCode](https://opencode.ai) **V2** plugin that gives every session a real
4
4
  todo list — and then keeps the agent working on it. A live progress strip in the
5
5
  composer (`📋 ███░░░░░ 8/17 open`) tracks the list.
6
6
 
7
- - `todo_write` / `todo_read` tools (V2 ships none), stored per session;
7
+ - `todo_write` / `todo_read` / `todo_add` / `todo_remove` tools (V2 ships none),
8
+ stored per session, with stable per-item ids;
8
9
  - a **supervisor** that re-prompts an idle session while its list still has open
9
10
  items — and stays quiet while the agent is actually working (thinking,
10
11
  preparing a tool, a running tool or `task` subagent, background shells, child
@@ -22,7 +23,7 @@ Tested against OpenCode `2.0.24`.
22
23
  ## Install
23
24
 
24
25
  ```sh
25
- opencode plugin add @hacky.software/opencode-todo-supervisor@0.3.4
26
+ opencode plugin add @hacky.software/opencode-todo-supervisor@0.3.5
26
27
  ```
27
28
 
28
29
  This also loads the bundled TUI entry. To pass options, use the object form:
@@ -77,9 +78,14 @@ and makes finishing it the path of least resistance:
77
78
 
78
79
  ## Tools
79
80
 
80
- Four tools, all registered with `codemode: true` so Code Mode agents can call
81
+ Six tools, all registered with `codemode: true` so Code Mode agents can call
81
82
  them too. Together they are the whole interface the model uses.
82
83
 
84
+ Every item has a **stable id** (a number, e.g. `3`), assigned when the item is
85
+ first written and preserved across list rewrites by matching content. Ids are
86
+ shown as `#3` in `todo_read`, in the injected context block, and in the panel,
87
+ and are what `todo_remove` and the panel's remove key take.
88
+
83
89
  ### `todo_write`
84
90
 
85
91
  Replaces the session's list.
@@ -88,6 +94,7 @@ Replaces the session's list.
88
94
  {
89
95
  "todos": [
90
96
  {
97
+ "id": "3",
91
98
  "content": "string",
92
99
  "status": "pending | in_progress | completed | canceled",
93
100
  "priority": "high | medium | low"
@@ -96,14 +103,35 @@ Replaces the session's list.
96
103
  }
97
104
  ```
98
105
 
99
- `content` and `status` are required; `priority` is optional.
106
+ `content` and `status` are required; `id` and `priority` are optional.
100
107
 
101
- - The list **replaces** the previous one — always send the complete list.
108
+ - The list **replaces** the previous one — always send the complete list. Echo
109
+ each item's `id` to keep it; items without an id are matched to the previous
110
+ list by content, and anything new gets a fresh id.
102
111
  - At most one item may be `in_progress`; extra ones are demoted to `pending` and
103
112
  the result says so.
104
113
  - Content is trimmed, empty entries dropped, duplicates removed.
105
- - Returns the summary plus the canonical list, and resets the supervisor's
106
- no-progress counter and any pause.
114
+ - Returns the summary plus the canonical list (with ids), and resets the
115
+ supervisor's no-progress counter and any pause.
116
+
117
+ ### `todo_add`
118
+
119
+ ```json
120
+ { "content": "string", "priority": "high | medium | low", "status": "pending | …" }
121
+ ```
122
+
123
+ Appends one item without resending the whole list, and returns its new id.
124
+ `content` is required; `priority` and `status` are optional (`pending` by
125
+ default). Adding content that is already on the list returns the existing id
126
+ instead of creating a duplicate.
127
+
128
+ ### `todo_remove`
129
+
130
+ ```json
131
+ { "id": "3" }
132
+ ```
133
+
134
+ Removes one item by its id.
107
135
 
108
136
  ### `todo_read`
109
137
 
@@ -128,7 +156,7 @@ Same input as `todo_subscribe`. Stops supervising a file, and also shadows a
128
156
 
129
157
  ## Markdown mode
130
158
 
131
- Subscribe files with `todo_subscribe`, the panel (`a` / `d`), or config. The
159
+ Subscribe files with `todo_subscribe`, the panel (`A` / `D`), or config. The
132
160
  effective set is `config.markdownFiles ∪ stored files − removed` per session.
133
161
 
134
162
  - Conventions: `- [ ]` open, `- [x]`/`- [X]` completed, `- [~]` canceled. `-`,
@@ -147,17 +175,21 @@ effective set is `config.markdownFiles ∪ stored files − removed` per session
147
175
  - **Composer strip** (`session.composer.top`): `📋 ███░░░░░ 8/17 open` (plus `⏸`
148
176
  while paused), hidden when there is neither a list nor a subscription.
149
177
  - **Popover** (`/todos`, palette "Todo: open todos"): a centered overlay dialog.
150
- The body is height-bounded (up to 16 rows) so the key legend is always visible;
151
- the header shows `open/total` and the selected `row n/m`. `f` cycles the dialog
152
- size (medium → large → xlarge), `esc` closes it.
178
+ Its body and width scale with the terminal (up to 48 rows / a share of the
179
+ window), so the key legend stays visible on a short terminal and a tall one
180
+ shows more rows; the header shows `open/total` and the selected `row n/m`. `f`
181
+ cycles the dialog size (medium → large → xlarge), `esc` closes it.
153
182
  - header: progress bar, `open/total`, and a supervision chip (`idle · quiet
154
183
  12s · nudges 2`, `working: 1 tool · 1 shell · quiet 4s`, `paused
155
184
  (no-progress)`, `not supervised`);
156
185
  - body: a `SESSION` section, then one collapsible section per subscribed file
157
- (`n open of m`, `(missing)` badge, `:line` refs);
186
+ (`n open of m`, `(missing)` badge, `:line` refs). Session rows lead with the
187
+ item's `#id` and a priority column (`high` / `med` / `low`), so both are
188
+ visible at a glance and columns line up across the list;
158
189
  - keys: `↑↓`/`jk` move, `pgup`/`pgdn`, `home`/`end`/`g`/`G`, `space`/`x` toggle
159
190
  (session item, markdown item, or collapse a file row), `enter` collapse, `n`
160
- nudge now, `s` pause/resume, `a` add file, `d` remove file, `r` refresh, `/`
191
+ nudge now, `s` pause/resume, `a` add task, `d` remove task (confirmed), `p`
192
+ cycle a task's priority, `A` add file, `D` remove file, `r` refresh, `/`
161
193
  filter, `f` resize, `c` clear (confirm), `?` help, `escape` close;
162
194
  - it re-reads every 3 s while open, so edits made outside OpenCode show up
163
195
  without waiting for an event.
@@ -188,7 +220,10 @@ Contract id `todo` (`opencode-todo-supervisor/rpc`). The TUI uses
188
220
  | `clear` | `{ sessionID }` | Drop the stored list (subscriptions stay) |
189
221
  | `nudge` | `{ sessionID }` | Send a nudge now (same dedupe/ownership rules as automatic nudges) |
190
222
  | `setEnabled` | `{ sessionID, enabled }` | Per-session supervision kill switch |
191
- | `toggle` | `{ sessionID, index }` | Flip one session item between completed and pending |
223
+ | `toggle` | `{ sessionID, id }` or `{ sessionID, index }` | Flip one session item between completed and pending |
224
+ | `addItem` | `{ sessionID, content, priority? }` | Append one item; returns its new id |
225
+ | `removeItem` | `{ sessionID, id }` | Remove one session item by id |
226
+ | `setPriority` | `{ sessionID, id, priority }` | Set a priority (`high`/`medium`/`low`) or clear it with `none` |
192
227
  | `toggleFile` | `{ sessionID, path, line }` | Toggle a checklist item inside a subscribed markdown file |
193
228
  | `subscribe` | `{ sessionID, path }` | Subscribe a markdown file |
194
229
  | `unsubscribe` | `{ sessionID, path }` | Shadow a subscription |
@@ -307,12 +342,14 @@ A thin wrapper over `ctx.storage` (durable JSON scoped to the plugin):
307
342
 
308
343
  | Key | Value |
309
344
  | --- | --- |
310
- | `todos/<sessionID>` | `{ version, sessionID, items[], updatedAt }` |
345
+ | `todos/<sessionID>` | `{ version, sessionID, items[{ id, content, status, priority? }], updatedAt }` |
311
346
  | `subs/<sessionID>` | `{ version, sessionID, files[], removed[], updatedAt }` |
312
347
  | `claims/<sessionID>` | `{ tag, at }` — hot-reload ownership |
313
348
 
314
349
  On startup the plugin scans `todos/` and reconciles against `ctx.session.get()`
315
350
  (session gone → drop the entry; session alive → register and evaluate once).
351
+ Ids are assigned centrally on every write (and backfilled deterministically when
352
+ loading rows that predate them), so a list read from disk always has ids.
316
353
  Supervision counters (nudge counts, no-progress, pause) are in-memory only; a
317
354
  restart resets them, which is deliberate and simpler. Sessions are dropped on
318
355
  `session.deleted`.
@@ -340,7 +377,7 @@ There is no filesystem watcher. Instead:
340
377
  (source edits and config content changes; `touch` alone does not).
341
378
  - `opencode2 reload` forces reconciliation; `opencode2 service restart` only as a
342
379
  last resort (it interrupts sessions).
343
- - Tests: `bun test` (106 tests across parser, storage, tools, RPC, state machine,
380
+ - Tests: `bun test` (121 tests across parser, storage, tools, RPC, state machine,
344
381
  panel helpers).
345
382
  - Watch logs: `tail -f ~/.local/share/opencode/log/todo-supervisor.log`.
346
383
 
@@ -348,6 +385,8 @@ There is no filesystem watcher. Instead:
348
385
 
349
386
  - The TUI panel/keyboard actions and toasts need a running TUI; the strip counts
350
387
  refresh on RPC `updated` (i.e. immediately after `todo_write`).
388
+ - The panel adds and removes **session** tasks (`a` / `d`). Markdown items are
389
+ owned by their file: toggle one with `x`/`space`, edit the file to remove it.
351
390
  - Markdown write-back edits your files: `x` on a markdown row rewrites that
352
391
  checkbox in place (temp file + atomic rename, only for subscribed paths). Use
353
392
  the panel filter and `d` if you only want to read.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hacky.software/opencode-todo-supervisor",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "OpenCode V2 plugin that gives every session a todo list (todo_write/todo_read), supervises idle sessions until the list is done, and can drive checklists in markdown files from a TUI panel.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/panel.test.ts CHANGED
@@ -6,9 +6,12 @@ import {
6
6
  countOpen,
7
7
  dialogWidth,
8
8
  footerHints,
9
+ idTag,
9
10
  isSelectable,
11
+ itemColumns,
10
12
  moveCursor,
11
13
  popoverBodyHeight,
14
+ priorityTag,
12
15
  progressBar,
13
16
  quietLabel,
14
17
  statusMark,
@@ -20,9 +23,9 @@ import {
20
23
 
21
24
  const snapshot = (): PanelSnapshot => ({
22
25
  items: [
23
- { content: "first task", status: "pending", priority: "high" },
24
- { content: "second task", status: "in_progress" },
25
- { content: "third task", status: "completed" },
26
+ { id: "1", content: "first task", status: "pending", priority: "high" },
27
+ { id: "2", content: "second task", status: "in_progress" },
28
+ { id: "3", content: "third task", status: "completed" },
26
29
  ],
27
30
  files: [
28
31
  {
@@ -104,6 +107,7 @@ describe("panel rows", () => {
104
107
  expect(sessionHeader.kind === "section" && sessionHeader.detail).toBe("2 open of 3")
105
108
  const firstItem = rows[1]!
106
109
  expect(firstItem.kind === "item" && firstItem.key).toBe("session:0")
110
+ expect(firstItem.kind === "item" && firstItem.id).toBe("1")
107
111
  expect(firstItem.kind === "item" && firstItem.priority).toBe("high")
108
112
  const fileRow = rows[4]!
109
113
  expect(fileRow.kind === "file" && fileRow.open).toBe(1)
@@ -168,26 +172,43 @@ describe("panel cursor", () => {
168
172
  })
169
173
 
170
174
  test("footer hints shrink with the terminal width", () => {
171
- expect(footerHints(140).length).toBe(13)
175
+ expect(footerHints(140).length).toBe(16)
176
+ expect(footerHints(140)).toContain("a add task")
177
+ expect(footerHints(140)).toContain("p priority")
172
178
  expect(footerHints(90).length).toBe(8)
173
179
  expect(footerHints(40)).toEqual(["↑↓", "space toggle", "n nudge", "/ filter", "? help", "esc"])
174
180
  })
175
181
 
176
- test("popover body height stays a popover on any terminal", () => {
177
- expect(popoverBodyHeight(40)).toBe(16)
178
- expect(popoverBodyHeight(24)).toBe(14)
179
- expect(popoverBodyHeight(24, 1)).toBe(13)
180
- expect(popoverBodyHeight(14)).toBe(4)
181
- expect(popoverBodyHeight(50, 2)).toBe(16)
182
+ test("popover body height scales with the terminal", () => {
183
+ expect(popoverBodyHeight(40)).toBe(28)
184
+ expect(popoverBodyHeight(24)).toBe(16)
185
+ expect(popoverBodyHeight(24, 1)).toBe(15)
186
+ expect(popoverBodyHeight(14)).toBe(6)
187
+ expect(popoverBodyHeight(50, 2)).toBe(35)
188
+ // Never taller than a popover on a very tall terminal.
189
+ expect(popoverBodyHeight(200)).toBe(48)
182
190
  })
183
191
 
184
192
  test("popover width follows the dialog size and clamps", () => {
185
- expect(dialogWidth("medium", 200)).toBe(72)
186
- expect(dialogWidth("large", 200)).toBe(140)
187
- expect(dialogWidth("xlarge", 200)).toBe(140)
188
- expect(dialogWidth("medium", 80)).toBe(56)
193
+ expect(dialogWidth("medium", 200)).toBe(120)
194
+ expect(dialogWidth("large", 200)).toBe(160)
195
+ expect(dialogWidth("xlarge", 200)).toBe(180)
196
+ expect(dialogWidth("medium", 80)).toBe(48)
189
197
  expect(dialogWidth("large", 80)).toBe(64)
190
- expect(dialogWidth("xlarge", 80)).toBe(72)
198
+ expect(dialogWidth("xlarge", 80)).toBe(76)
191
199
  expect(dialogWidth("xlarge", 20)).toBe(36)
200
+ // xlarge tracks the window instead of a fixed cap.
201
+ expect(dialogWidth("xlarge", 120)).toBe(114)
202
+ })
203
+
204
+ test("id and priority tags and their column widths", () => {
205
+ expect(idTag(undefined)).toBe("")
206
+ expect(idTag("12")).toBe("#12")
207
+ expect(priorityTag(undefined)).toBe("")
208
+ expect(priorityTag("high")).toBe("high")
209
+ expect(priorityTag("medium")).toBe("med")
210
+ expect(priorityTag("low")).toBe("low")
211
+ expect(itemColumns([{ id: "3", priority: "high" }, { id: "12" }])).toEqual({ id: 3, priority: 4 })
212
+ expect(itemColumns([{ content: "x", status: "pending" }])).toEqual({ id: 0, priority: 0 })
192
213
  })
193
214
  })
package/src/panel.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // and truncation are unit-tested without an OpenTUI renderer. src/tui.tsx only
5
5
  // maps these rows to <text>/<box> elements.
6
6
 
7
- export type PanelItem = { content: string; status: string; priority?: string }
7
+ export type PanelItem = { id?: string; content: string; status: string; priority?: string }
8
8
  export type PanelFileItem = { content: string; status: string; line?: number }
9
9
  export type PanelFile = { path: string; exists: boolean; items: PanelFileItem[] }
10
10
 
@@ -47,6 +47,7 @@ export type Row =
47
47
  kind: "item"
48
48
  key: string
49
49
  source: "session" | "file"
50
+ id?: string
50
51
  content: string
51
52
  status: string
52
53
  priority?: string
@@ -69,6 +70,29 @@ export function statusMark(status: string): string {
69
70
  return "[ ]"
70
71
  }
71
72
 
73
+ /** `#3` for a session item (empty when the item has no id / is a file item). */
74
+ export function idTag(id: string | undefined): string {
75
+ return id ? `#${id}` : ""
76
+ }
77
+
78
+ /** Short priority word for a fixed-width column: `high` / `med` / `low` / ``. */
79
+ export function priorityTag(priority: string | undefined): string {
80
+ if (priority === "high") return "high"
81
+ if (priority === "medium") return "med"
82
+ if (priority === "low") return "low"
83
+ return ""
84
+ }
85
+
86
+ /**
87
+ * Width of the `#id` and priority columns for a list, so rows line up. Returns
88
+ * 0 for a column when no item in the list uses it (keeps narrow lists compact).
89
+ */
90
+ export function itemColumns(items: readonly { id?: string; priority?: string }[]): { id: number; priority: number } {
91
+ const id = items.reduce((max, item) => Math.max(max, idTag(item.id).length), 0)
92
+ const priority = items.reduce((max, item) => Math.max(max, priorityTag(item.priority).length), 0)
93
+ return { id, priority }
94
+ }
95
+
72
96
  export function countOpen(items: readonly { status: string }[]): number {
73
97
  return items.filter((item) => item.status !== "completed").length
74
98
  }
@@ -153,6 +177,7 @@ export function buildRows(snapshot: PanelSnapshot, options: BuildOptions = {}):
153
177
  key: `session:${index}`,
154
178
  source: "session",
155
179
  index,
180
+ id: item.id,
156
181
  content: item.content,
157
182
  status: item.status,
158
183
  priority: item.priority,
@@ -249,8 +274,11 @@ export function footerHints(width: number): string[] {
249
274
  "enter collapse",
250
275
  "n nudge",
251
276
  "s pause",
252
- "a add",
253
- "d remove",
277
+ "a add task",
278
+ "d remove task",
279
+ "p priority",
280
+ "A add file",
281
+ "D remove file",
254
282
  "r refresh",
255
283
  "/ filter",
256
284
  "f size",
@@ -265,25 +293,28 @@ export function footerHints(width: number): string[] {
265
293
 
266
294
  /**
267
295
  * Rows the popover list body may occupy. The dialog hugs its content, so the
268
- * body must be bounded or the overlay grows to the whole screen; the cap keeps
269
- * it a popover and leaves room for the header and the key legend. `extraRows`
270
- * accounts for conditional chrome (e.g. the filter line).
296
+ * body must be bounded or the overlay grows to the whole screen; the budget
297
+ * scales with the terminal (a taller window gets a taller popover) and is
298
+ * capped so it stays a popover. `extraRows` accounts for conditional chrome
299
+ * (e.g. the filter line).
271
300
  */
272
301
  export function popoverBodyHeight(viewportHeight: number, extraRows = 0): number {
273
302
  const total = Math.max(10, viewportHeight)
274
- return Math.max(4, Math.min(total - 10 - Math.max(0, extraRows), 16))
303
+ const cap = Math.max(8, Math.min(48, Math.floor(total * 0.7)))
304
+ return Math.max(4, Math.min(total - 8 - Math.max(0, extraRows), cap))
275
305
  }
276
306
 
277
307
  export type DialogSizeName = "medium" | "large" | "xlarge"
278
308
 
309
+ const DIALOG_FACTOR: Record<DialogSizeName, number> = { medium: 0.6, large: 0.8, xlarge: 0.95 }
310
+
279
311
  /**
280
- * Content width for the popover dialog. The host owns the dialog box, so the
281
- * list sizes its rows from the render region instead and clamps for small
282
- * terminals (a wider dialog just shows more of each row).
312
+ * Content width for the popover dialog, as a fraction of the terminal width so
313
+ * it scales with the window (`medium` 60%, `large` 80%, `xlarge` 95%), clamped
314
+ * for small terminals and capped so it never spans an ultrawide screen.
283
315
  */
284
316
  export function dialogWidth(size: DialogSizeName, viewportWidth: number): number {
285
317
  const total = Math.max(40, viewportWidth)
286
- const bySize =
287
- size === "xlarge" ? total - 8 : size === "large" ? total - 16 : Math.min(72, Math.max(48, total - 24))
288
- return Math.max(36, Math.min(bySize, 140))
318
+ const target = Math.round(total * DIALOG_FACTOR[size])
319
+ return Math.max(36, Math.min(target, total - 4, 180))
289
320
  }
package/src/rpc.test.ts CHANGED
@@ -81,10 +81,13 @@ describe("RPC contract (§5.6)", () => {
81
81
  test("contract exposes all methods and events", () => {
82
82
  expect(TodoRpc.id).toBe("todo")
83
83
  expect(Object.keys(TodoRpc.methods).sort()).toEqual([
84
+ "addItem",
84
85
  "clear",
85
86
  "list",
86
87
  "nudge",
88
+ "removeItem",
87
89
  "setEnabled",
90
+ "setPriority",
88
91
  "subscribe",
89
92
  "toggle",
90
93
  "toggleFile",
@@ -149,6 +152,54 @@ describe("RPC contract (§5.6)", () => {
149
152
  await expect(w.handlers.toggle!({ sessionID: "s1", index: 9 })).rejects.toThrow(/range/)
150
153
  })
151
154
 
155
+ test("toggle by id finds the item and reports its id", async () => {
156
+ const w = await setup([
157
+ { id: "1", content: "one", status: "pending" },
158
+ { id: "2", content: "two", status: "pending" },
159
+ ])
160
+ const result = (await w.handlers.toggle!({ sessionID: "s1", id: "2" })) as Record<string, unknown>
161
+ expect(result).toMatchObject({ id: "2", index: 1, status: "completed" })
162
+ expect(w.todos.get("s1")![1]!.status).toBe("completed")
163
+ await expect(w.handlers.toggle!({ sessionID: "s1", id: "9" })).rejects.toThrow(/no item with id/)
164
+ await expect(w.handlers.toggle!({ sessionID: "s1" })).rejects.toThrow(/index/)
165
+ })
166
+
167
+ test("addItem appends a pending item with a fresh id and emits updated", async () => {
168
+ const w = await setup([{ id: "1", content: "one", status: "pending" }])
169
+ const result = (await w.handlers.addItem!({ sessionID: "s1", content: " two ", priority: "high" })) as Record<
170
+ string,
171
+ unknown
172
+ >
173
+ expect(result).toMatchObject({ id: "2", content: "two", priority: "high" })
174
+ expect(w.todos.get("s1")!.map((item) => [item.id, item.content, item.status, item.priority])).toEqual([
175
+ ["1", "one", "pending", undefined],
176
+ ["2", "two", "pending", "high"],
177
+ ])
178
+ expect(w.calls.filter((call) => call === "changed s1").length).toBe(1)
179
+ expect(w.emitted.filter((event) => event.name === "updated").length).toBe(1)
180
+ await expect(w.handlers.addItem!({ sessionID: "s1", content: " " })).rejects.toThrow(/content/)
181
+ })
182
+
183
+ test("removeItem drops the item by id and rejects unknown ids", async () => {
184
+ const w = await setup([
185
+ { id: "1", content: "one", status: "pending" },
186
+ { id: "2", content: "two", status: "pending" },
187
+ ])
188
+ await w.handlers.removeItem!({ sessionID: "s1", id: "1" })
189
+ expect(w.todos.get("s1")!.map((item) => item.id)).toEqual(["2"])
190
+ await expect(w.handlers.removeItem!({ sessionID: "s1", id: "9" })).rejects.toThrow(/no item with id/)
191
+ })
192
+
193
+ test("setPriority sets and clears a priority", async () => {
194
+ const w = await setup([{ id: "1", content: "one", status: "pending" }])
195
+ await w.handlers.setPriority!({ sessionID: "s1", id: "1", priority: "high" })
196
+ expect(w.todos.get("s1")![0]!.priority).toBe("high")
197
+ await w.handlers.setPriority!({ sessionID: "s1", id: "1", priority: "none" })
198
+ expect(w.todos.get("s1")![0]!.priority).toBeUndefined()
199
+ await expect(w.handlers.setPriority!({ sessionID: "s1", id: "1", priority: "urgent" })).rejects.toThrow(/priority/)
200
+ await expect(w.handlers.setPriority!({ sessionID: "s1", id: "9", priority: "low" })).rejects.toThrow(/no item with id/)
201
+ })
202
+
152
203
  test("toggleFile rewrites a subscribed file, refuses other paths and notifies", async () => {
153
204
  const dir = "/tmp/opencode"
154
205
  mkdirSync(dir, { recursive: true })
package/src/rpc.ts CHANGED
@@ -10,7 +10,7 @@ import type { Plugin } from "@opencode/plugin"
10
10
  import { Rpc } from "@opencode/plugin/rpc"
11
11
  import { effectiveFiles, MarkdownCache, resolveMarkdownPath, toggleMarkdownItem } from "./markdown"
12
12
  import type { Options } from "./options"
13
- import type { TodoStore } from "./storage"
13
+ import { nextId, type TodoItem, type TodoStore } from "./storage"
14
14
  import type { Supervisor } from "./supervisor"
15
15
 
16
16
  // Kept for consumers/tests that import the resolver from the RPC module.
@@ -55,8 +55,46 @@ export const TodoRpc = Rpc.define({
55
55
  toggle: {
56
56
  input: {
57
57
  type: "object",
58
- properties: { sessionID: { type: "string" }, index: { type: "number" } },
59
- required: ["sessionID", "index"],
58
+ properties: { sessionID: { type: "string" }, id: { type: "string" }, index: { type: "number" } },
59
+ required: ["sessionID"],
60
+ additionalProperties: false,
61
+ } as const,
62
+ output: objectOutput,
63
+ },
64
+ /** Append one item to the session list; returns its new id. */
65
+ addItem: {
66
+ input: {
67
+ type: "object",
68
+ properties: {
69
+ sessionID: { type: "string" },
70
+ content: { type: "string" },
71
+ priority: { type: "string", enum: ["high", "medium", "low"] },
72
+ },
73
+ required: ["sessionID", "content"],
74
+ additionalProperties: false,
75
+ } as const,
76
+ output: objectOutput,
77
+ },
78
+ /** Remove one session item by id. */
79
+ removeItem: {
80
+ input: {
81
+ type: "object",
82
+ properties: { sessionID: { type: "string" }, id: { type: "string" } },
83
+ required: ["sessionID", "id"],
84
+ additionalProperties: false,
85
+ } as const,
86
+ output: objectOutput,
87
+ },
88
+ /** Set one item's priority (or clear it with `none`). */
89
+ setPriority: {
90
+ input: {
91
+ type: "object",
92
+ properties: {
93
+ sessionID: { type: "string" },
94
+ id: { type: "string" },
95
+ priority: { type: "string", enum: ["high", "medium", "low", "none"] },
96
+ },
97
+ required: ["sessionID", "id", "priority"],
60
98
  additionalProperties: false,
61
99
  } as const,
62
100
  output: objectOutput,
@@ -217,18 +255,89 @@ export async function registerTodoRpc(ctx: Plugin.Context, deps: RpcDeps): Promi
217
255
  toggle: async (raw) => {
218
256
  const input = requireObject(raw)
219
257
  const sessionID = requireString(input.sessionID, "sessionID")
220
- const index = requireNumber(input.index, "index")
221
258
  const state = await store.refreshTodos(sessionID)
222
259
  const items = state?.items ?? []
223
- const item = items[index]
224
- if (!item) throw new Error(`invalid input: "index" ${index} is out of range`)
260
+ let index: number
261
+ if (input.id !== undefined) {
262
+ const id = requireString(input.id, "id")
263
+ index = items.findIndex((item) => item.id === id)
264
+ if (index === -1) throw new Error(`invalid input: no item with id ${JSON.stringify(id)}`)
265
+ } else {
266
+ index = requireNumber(input.index, "index")
267
+ if (!items[index]) throw new Error(`invalid input: "index" ${index} is out of range`)
268
+ }
269
+ const item = items[index]!
225
270
  const status: "pending" | "completed" = item.status === "completed" ? "pending" : "completed"
226
271
  const next = items.map((entry, i) => (i === index ? { ...entry, status } : entry))
227
272
  await store.setTodos(sessionID, next)
228
273
  supervisor.notifyTodosChanged(sessionID)
229
274
  await emitEvent("updated", { sessionID })
230
- log(`rpc toggle (${sessionID}) index=${index} → ${status}`)
231
- return { sessionID, index, status }
275
+ log(`rpc toggle (${sessionID}) ${item.id ? `#${item.id}` : `index ${index}`} → ${status}`)
276
+ return { sessionID, index, id: item.id, status }
277
+ },
278
+ addItem: async (raw) => {
279
+ const input = requireObject(raw)
280
+ const sessionID = requireString(input.sessionID, "sessionID")
281
+ const content = requireString(input.content, "content").trim()
282
+ const priority =
283
+ input.priority === "high" || input.priority === "medium" || input.priority === "low"
284
+ ? input.priority
285
+ : undefined
286
+ const state = await store.refreshTodos(sessionID)
287
+ const items = state?.items ?? []
288
+ const item: TodoItem = { id: nextId(items), content, status: "pending" }
289
+ if (priority) item.priority = priority
290
+ const saved = await store.setTodos(sessionID, [...items, item])
291
+ supervisor.notifyTodosChanged(sessionID)
292
+ await emitEvent("updated", { sessionID })
293
+ const added = saved.items[saved.items.length - 1]!
294
+ log(`rpc addItem (${sessionID}) #${added.id} ${content}`)
295
+ return { sessionID, id: added.id, content, priority: added.priority ?? null }
296
+ },
297
+ removeItem: async (raw) => {
298
+ const input = requireObject(raw)
299
+ const sessionID = requireString(input.sessionID, "sessionID")
300
+ const id = requireString(input.id, "id")
301
+ const state = await store.refreshTodos(sessionID)
302
+ const items = state?.items ?? []
303
+ if (!items.some((item) => item.id === id)) {
304
+ throw new Error(`invalid input: no item with id ${JSON.stringify(id)}`)
305
+ }
306
+ await store.setTodos(
307
+ sessionID,
308
+ items.filter((item) => item.id !== id),
309
+ )
310
+ supervisor.notifyTodosChanged(sessionID)
311
+ await emitEvent("updated", { sessionID })
312
+ log(`rpc removeItem (${sessionID}) #${id}`)
313
+ return { sessionID, id }
314
+ },
315
+ setPriority: async (raw) => {
316
+ const input = requireObject(raw)
317
+ const sessionID = requireString(input.sessionID, "sessionID")
318
+ const id = requireString(input.id, "id")
319
+ const priority = input.priority
320
+ if (priority !== "high" && priority !== "medium" && priority !== "low" && priority !== "none") {
321
+ throw new Error('invalid input: "priority" must be high, medium, low or none')
322
+ }
323
+ const state = await store.refreshTodos(sessionID)
324
+ const items = state?.items ?? []
325
+ if (!items.some((item) => item.id === id)) {
326
+ throw new Error(`invalid input: no item with id ${JSON.stringify(id)}`)
327
+ }
328
+ const next = items.map((item) => {
329
+ if (item.id !== id) return item
330
+ if (priority === "none") {
331
+ const { priority: _dropped, ...rest } = item
332
+ return rest
333
+ }
334
+ return { ...item, priority }
335
+ })
336
+ await store.setTodos(sessionID, next)
337
+ supervisor.notifyTodosChanged(sessionID)
338
+ await emitEvent("updated", { sessionID })
339
+ log(`rpc setPriority (${sessionID}) #${id} → ${priority}`)
340
+ return { sessionID, id, priority: priority === "none" ? null : priority }
232
341
  },
233
342
  toggleFile: async (raw) => {
234
343
  const input = requireObject(raw)
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import {
3
+ assignIds,
3
4
  hashList,
4
5
  isDone,
6
+ maxId,
7
+ nextId,
5
8
  openItems,
6
9
  TodoStore,
7
10
  type StorageLike,
@@ -34,7 +37,7 @@ export class FakeStorage implements StorageLike {
34
37
  }
35
38
 
36
39
  const items = (n: number): TodoItem[] =>
37
- Array.from({ length: n }, (_, i) => ({ content: `task ${i}`, status: "pending" as const }))
40
+ Array.from({ length: n }, (_, i) => ({ id: String(i + 1), content: `task ${i}`, status: "pending" as const }))
38
41
 
39
42
  describe("TodoStore", () => {
40
43
  test("round-trips items identically in order", async () => {
@@ -98,4 +101,56 @@ describe("helpers", () => {
98
101
  expect(hashList(a)).toBe(hashList(b))
99
102
  expect(hashList(a)).not.toBe(hashList([{ content: "a", status: "completed", priority: "high" }]))
100
103
  })
104
+
105
+ test("hashList ignores ids (renumbering is not a content change)", () => {
106
+ const withA: TodoItem[] = [{ id: "1", content: "a", status: "pending" }]
107
+ const withB: TodoItem[] = [{ id: "9", content: "a", status: "pending" }]
108
+ expect(hashList(withA)).toBe(hashList(withB))
109
+ })
110
+ })
111
+
112
+ describe("stable ids", () => {
113
+ test("assignIds numbers a fresh list and is stable", () => {
114
+ const first = assignIds([{ content: "a", status: "pending" }, { content: "b", status: "pending" }])
115
+ expect(first.map((item) => item.id)).toEqual(["1", "2"])
116
+ // Re-running over its own output changes nothing.
117
+ expect(assignIds(first)).toEqual(first)
118
+ })
119
+
120
+ test("assignIds preserves ids across a content rewrite, matching by content", () => {
121
+ const previous = assignIds([{ content: "a", status: "pending" }, { content: "b", status: "pending" }])
122
+ const rewritten = assignIds([{ content: "b", status: "completed" }, { content: "c", status: "pending" }], previous)
123
+ expect(rewritten).toEqual([
124
+ { id: "2", content: "b", status: "completed" },
125
+ { id: "3", content: "c", status: "pending" },
126
+ ])
127
+ })
128
+
129
+ test("assignIds keeps explicit ids and never reuses a freed one immediately", () => {
130
+ const previous: TodoItem[] = [{ id: "1", content: "a", status: "pending" }]
131
+ const explicit = assignIds([{ id: "7", content: "z", status: "pending" }], previous)
132
+ expect(explicit[0]!.id).toBe("7")
133
+
134
+ // ids continue above the highest ever used, even after removals.
135
+ const grown = assignIds([{ content: "fresh", status: "pending" }], [{ id: "5", content: "gone", status: "completed" }])
136
+ expect(grown[0]!.id).toBe("6")
137
+ })
138
+
139
+ test("maxId / nextId", () => {
140
+ expect(maxId([])).toBe(0)
141
+ expect(maxId([{ content: "a", status: "pending" }, { id: "4", content: "b", status: "pending" }])).toBe(4)
142
+ expect(nextId([{ id: "4", content: "b", status: "pending" }])).toBe("5")
143
+ })
144
+
145
+ test("the store backfills ids for legacy rows", async () => {
146
+ const storage = new FakeStorage()
147
+ await storage.set("todos/legacy", {
148
+ version: 1,
149
+ sessionID: "legacy",
150
+ items: [{ content: "one", status: "pending" }, { content: "two", status: "pending" }],
151
+ updatedAt: 0,
152
+ })
153
+ const store = new TodoStore(storage)
154
+ expect((await store.loadTodos("legacy"))?.items.map((item) => item.id)).toEqual(["1", "2"])
155
+ })
101
156
  })
package/src/storage.ts CHANGED
@@ -10,6 +10,14 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "canceled"
10
10
  export type TodoPriority = "high" | "medium" | "low"
11
11
 
12
12
  export type TodoItem = {
13
+ /**
14
+ * Stable per-session identifier (a numeric string, e.g. `"3"`), assigned by
15
+ * the store when an item is first written and preserved across full-list
16
+ * rewrites by matching content. Optional in the type so callers and tests can
17
+ * build ad-hoc items; anything reaching storage is normalised by
18
+ * {@link assignIds}.
19
+ */
20
+ id?: string
13
21
  content: string
14
22
  status: TodoStatus
15
23
  priority?: TodoPriority
@@ -54,6 +62,7 @@ export function parseTodoState(value: unknown): TodoState | undefined {
54
62
  const status = entry.status
55
63
  if (status !== "pending" && status !== "in_progress" && status !== "completed" && status !== "canceled") continue
56
64
  const item: TodoItem = { content: entry.content, status }
65
+ if (typeof entry.id === "string" && entry.id !== "") item.id = entry.id
57
66
  if (entry.priority === "high" || entry.priority === "medium" || entry.priority === "low") {
58
67
  item.priority = entry.priority
59
68
  }
@@ -62,7 +71,8 @@ export function parseTodoState(value: unknown): TodoState | undefined {
62
71
  return {
63
72
  version: 1,
64
73
  sessionID: value.sessionID,
65
- items,
74
+ // Legacy rows predate ids; backfill them deterministically on load.
75
+ items: assignIds(items),
66
76
  updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
67
77
  }
68
78
  }
@@ -113,7 +123,10 @@ export class TodoStore {
113
123
  }
114
124
 
115
125
  async setTodos(sessionID: string, items: TodoItem[]): Promise<TodoState> {
116
- const state: TodoState = { version: 1, sessionID, items, updatedAt: Date.now() }
126
+ // Central id assignment: every stored list has stable ids, and a rewrite
127
+ // that keeps an item's content keeps its id (see assignIds).
128
+ const previous = this.todos.get(sessionID)?.items ?? []
129
+ const state: TodoState = { version: 1, sessionID, items: assignIds(items, previous), updatedAt: Date.now() }
117
130
  await this.storage.set(todosKey(sessionID), state)
118
131
  this.todos.set(sessionID, state)
119
132
  return state
@@ -184,3 +197,54 @@ export function isDone(items: readonly TodoItem[]): boolean {
184
197
  export function hashList(items: readonly TodoItem[]): string {
185
198
  return JSON.stringify(items.map((item) => [item.content, item.status, item.priority ?? ""]))
186
199
  }
200
+
201
+ // ---- stable ids (§5.3) ------------------------------------------------------
202
+
203
+ function numericId(id: string | undefined): number {
204
+ const value = Number(id)
205
+ return Number.isInteger(value) && value > 0 ? value : 0
206
+ }
207
+
208
+ /** Highest numeric id in a list (0 when the list is empty or unnumbered). */
209
+ export function maxId(items: readonly TodoItem[]): number {
210
+ return items.reduce((max, item) => Math.max(max, numericId(item.id)), 0)
211
+ }
212
+
213
+ /** Next free id for a list (one above the highest id ever used). */
214
+ export function nextId(items: readonly TodoItem[]): string {
215
+ return String(maxId(items) + 1)
216
+ }
217
+
218
+ /**
219
+ * Give every item a stable id. An id already on the item is kept when it is
220
+ * valid and unique; otherwise the id of a `previous` item with the same content
221
+ * is reused (so `todo_write`, which resends the whole list, does not renumber
222
+ * everything); otherwise a fresh id above every id seen so far is allocated.
223
+ *
224
+ * Ids are deliberately not part of {@link hashList}: renumbering must never look
225
+ * like a content change to the supervisor.
226
+ */
227
+ export function assignIds(items: readonly TodoItem[], previous: readonly TodoItem[] = []): TodoItem[] {
228
+ const pool = [...previous]
229
+ const used = new Set<string>()
230
+ let counter = Math.max(maxId(previous), maxId(items))
231
+ const out: TodoItem[] = []
232
+
233
+ for (const item of items) {
234
+ let id = typeof item.id === "string" && item.id !== "" && !used.has(item.id) ? item.id : undefined
235
+ if (id === undefined) {
236
+ const index = pool.findIndex(
237
+ (entry) => entry.content === item.content && typeof entry.id === "string" && !used.has(entry.id),
238
+ )
239
+ if (index !== -1) {
240
+ id = pool[index]!.id
241
+ pool.splice(index, 1)
242
+ }
243
+ }
244
+ if (id === undefined) id = String(++counter)
245
+ used.add(id)
246
+ out.push(item.id === id ? item : { ...item, id })
247
+ }
248
+
249
+ return out
250
+ }
package/src/tools.test.ts CHANGED
@@ -7,7 +7,9 @@ import { DEFAULTS } from "./options"
7
7
  import { MarkdownCache } from "./markdown"
8
8
  import {
9
9
  makeContextInjector,
10
+ makeTodoAdd,
10
11
  makeTodoRead,
12
+ makeTodoRemove,
11
13
  makeTodoSubscribe,
12
14
  makeTodoUnsubscribe,
13
15
  makeTodoWrite,
@@ -55,6 +57,14 @@ describe("renderers", () => {
55
57
  expect(renderList([])).toBe("No todos.")
56
58
  })
57
59
 
60
+ test("renderList prefixes ids when present", () => {
61
+ const list: TodoItem[] = [
62
+ { id: "1", content: "done", status: "completed" },
63
+ { id: "2", content: "open", status: "pending", priority: "high" },
64
+ ]
65
+ expect(renderList(list)).toBe("- #1 [x] done\n- #2 [ ] open (high)")
66
+ })
67
+
58
68
  test("renderSummary counts and flags open work", () => {
59
69
  const summary = renderSummary([
60
70
  { content: "done", status: "completed" },
@@ -80,11 +90,11 @@ describe("todo tools round-trip", () => {
80
90
  { todos: [{ content: "step 1", status: "in_progress" }, { content: "step 2", status: "pending" }] },
81
91
  { sessionID: "ses_1" },
82
92
  )
83
- expect(writeResult.content[0]?.text).toContain("- [~] step 1")
93
+ expect(writeResult.content[0]?.text).toContain("- #1 [~] step 1")
84
94
  expect(changed).toEqual(["ses_1"])
85
95
 
86
96
  const readResult = await read.execute({}, { sessionID: "ses_1" })
87
- expect(readResult.content[0]?.text).toContain("- [ ] step 2")
97
+ expect(readResult.content[0]?.text).toContain("- #2 [ ] step 2")
88
98
  expect(readResult.content[0]?.text).toContain("2 open of 2 items.")
89
99
  })
90
100
 
@@ -110,6 +120,53 @@ describe("todo tools round-trip", () => {
110
120
  })
111
121
  })
112
122
 
123
+ describe("todo_add / todo_remove", () => {
124
+ test("add appends with a fresh id and reports it", async () => {
125
+ const store = new TodoStore(new FakeStorage())
126
+ await store.setTodos("s1", [{ content: "one", status: "pending" }])
127
+ const changed: string[] = []
128
+ const add = makeTodoAdd(store, { onTodosChanged: (id) => changed.push(id) })
129
+ const result = await add.execute({ content: " two ", priority: "high" }, { sessionID: "s1" })
130
+ expect(result.content[0]?.text).toContain("Added #2 two (high)")
131
+ expect((result.metadata as { id?: string }).id).toBe("2")
132
+ expect(store.getTodos("s1")?.items.map((item) => [item.id, item.content, item.priority])).toEqual([
133
+ ["1", "one", undefined],
134
+ ["2", "two", "high"],
135
+ ])
136
+ expect(changed).toEqual(["s1"])
137
+ })
138
+
139
+ test("add refuses a duplicate content and an empty content", async () => {
140
+ const store = new TodoStore(new FakeStorage())
141
+ await store.setTodos("s1", [{ content: "one", status: "pending" }])
142
+ const add = makeTodoAdd(store, {})
143
+ const duplicate = await add.execute({ content: "one" }, { sessionID: "s1" })
144
+ expect(duplicate.content[0]?.text).toContain("Already on the list as #1")
145
+ expect(store.getTodos("s1")?.items.length).toBe(1)
146
+
147
+ const empty = await add.execute({ content: " " }, { sessionID: "s1" })
148
+ expect(empty.content[0]?.text).toContain("todo_add failed")
149
+ expect(store.getTodos("s1")?.items.length).toBe(1)
150
+ })
151
+
152
+ test("remove drops one item by id and reports it", async () => {
153
+ const store = new TodoStore(new FakeStorage())
154
+ await store.setTodos("s1", [
155
+ { content: "one", status: "pending" },
156
+ { content: "two", status: "pending" },
157
+ ])
158
+ const changed: string[] = []
159
+ const remove = makeTodoRemove(store, { onTodosChanged: (id) => changed.push(id) })
160
+ const result = await remove.execute({ id: "1" }, { sessionID: "s1" })
161
+ expect(result.content[0]?.text).toContain("Removed #1: one")
162
+ expect(store.getTodos("s1")?.items.map((item) => item.id)).toEqual(["2"])
163
+ expect(changed).toEqual(["s1"])
164
+
165
+ const missing = await remove.execute({ id: "9" }, { sessionID: "s1" })
166
+ expect(missing.content[0]?.text).toContain("todo_remove failed")
167
+ })
168
+ })
169
+
113
170
  describe("markdown tools (§5.2, aspect 14)", () => {
114
171
  const dir = "/tmp/opencode"
115
172
  const file = `${dir}/tools-md-${process.pid}.md`
@@ -158,7 +215,7 @@ describe("markdown tools (§5.2, aspect 14)", () => {
158
215
  const result = await read.execute({}, { sessionID: "s1" })
159
216
  const text = result.content[0]?.text ?? ""
160
217
  expect(text).toContain("2 open of 2 items.")
161
- expect(text).toContain("- [ ] session task")
218
+ expect(text).toContain("- #1 [ ] session task")
162
219
  expect(text).toContain("- [ ] file task (" + shown + ":1)")
163
220
  })
164
221
  })
package/src/tools.ts CHANGED
@@ -3,7 +3,7 @@ import { isAbsolute, resolve as resolvePath } from "node:path"
3
3
  import type { Plugin } from "@opencode/plugin"
4
4
  import { effectiveFiles, MarkdownCache, renderFileSection, resolveMarkdownPath } from "./markdown"
5
5
  import type { Options } from "./options"
6
- import { openItems, type TodoItem, type TodoStore, type TodoStatus } from "./storage"
6
+ import { nextId, openItems, type TodoItem, type TodoPriority, type TodoStore, type TodoStatus } from "./storage"
7
7
 
8
8
  export interface ToolHooks {
9
9
  /** Fired after a successful `todo_write` (supervisor reset, RPC notify, …). */
@@ -45,6 +45,7 @@ export function normalizeItems(raw: readonly TodoItem[]): { items: TodoItem[]; n
45
45
  }
46
46
 
47
47
  const item: TodoItem = { content, status }
48
+ if (typeof entry.id === "string" && entry.id.trim() !== "") item.id = entry.id.trim()
48
49
  if (entry.priority === "high" || entry.priority === "medium" || entry.priority === "low") {
49
50
  item.priority = entry.priority
50
51
  }
@@ -68,7 +69,10 @@ export function normalizeItems(raw: readonly TodoItem[]): { items: TodoItem[]; n
68
69
  export function renderList(items: readonly TodoItem[]): string {
69
70
  if (items.length === 0) return "No todos."
70
71
  return items
71
- .map((item) => `- ${MARKERS[item.status]} ${item.content}${item.priority ? ` (${item.priority})` : ""}`)
72
+ .map((item) => {
73
+ const tag = item.id ? `#${item.id} ` : ""
74
+ return `- ${tag}${MARKERS[item.status]} ${item.content}${item.priority ? ` (${item.priority})` : ""}`
75
+ })
72
76
  .join("\n")
73
77
  }
74
78
 
@@ -93,6 +97,7 @@ export function renderSummary(items: readonly TodoItem[], notes: readonly string
93
97
  const TODO_ITEM_SCHEMA = {
94
98
  type: "object",
95
99
  properties: {
100
+ id: { type: "string" },
96
101
  content: { type: "string" },
97
102
  status: { type: "string", enum: ["pending", "in_progress", "completed", "canceled"] },
98
103
  priority: { type: "string", enum: ["high", "medium", "low"] },
@@ -104,7 +109,15 @@ const TODO_ITEM_SCHEMA = {
104
109
  const WRITE_DESCRIPTION =
105
110
  "Replace the session's todo list. Use this to plan multi-step work and keep progress visible: " +
106
111
  "send the complete list every time (it replaces the previous one). Keep exactly one item `in_progress`. " +
107
- "Before ending your turn every item must be `completed` or `canceled` — a supervisor will continue the session while items remain open."
112
+ "Before ending your turn every item must be `completed` or `canceled` — a supervisor will continue the session while items remain open. " +
113
+ "Each item has a stable `id`; include it to keep the same item across rewrites (otherwise it is matched by content)."
114
+
115
+ const ADD_DESCRIPTION =
116
+ "Add one item to the session's todo list without resending the whole list. Returns the new item's id. " +
117
+ "Prefer this (or todo_write) over rewriting the list to append a single task."
118
+
119
+ const REMOVE_DESCRIPTION =
120
+ "Remove one item from the session's todo list by its id (see the ids in todo_read / the injected todo block)."
108
121
 
109
122
  const READ_DESCRIPTION =
110
123
  "Read the session's current todo list (canonical state as stored by todo_write). " +
@@ -155,11 +168,12 @@ export function makeTodoWrite(store: TodoStore, hooks: ToolHooks) {
155
168
  async execute(input: { todos: TodoItem[] }, context: ToolContextLike) {
156
169
  try {
157
170
  const { items, notes } = normalizeItems(Array.isArray(input?.todos) ? input.todos : [])
158
- await store.setTodos(context.sessionID, items)
171
+ const state = await store.setTodos(context.sessionID, items)
159
172
  hooks.onTodosChanged?.(context.sessionID)
173
+ const saved = state.items
160
174
  return {
161
- content: [{ type: "text" as const, text: `${renderSummary(items, notes)}\n\n${renderList(items)}` }],
162
- metadata: { items: items.length, open: openItems(items).length },
175
+ content: [{ type: "text" as const, text: `${renderSummary(saved, notes)}\n\n${renderList(saved)}` }],
176
+ metadata: { items: saved.length, open: openItems(saved).length },
163
177
  }
164
178
  } catch (err) {
165
179
  hooks.log?.(`todo_write failed (${context.sessionID}): ${String(err)}`)
@@ -216,6 +230,121 @@ export function makeTodoRead(store: TodoStore, hooks: ToolHooks = {}, deps?: Mar
216
230
  }
217
231
  }
218
232
 
233
+ function validPriority(value: unknown): TodoPriority | undefined {
234
+ return value === "high" || value === "medium" || value === "low" ? value : undefined
235
+ }
236
+
237
+ function validStatus(value: unknown): TodoStatus | undefined {
238
+ return value === "pending" || value === "in_progress" || value === "completed" || value === "canceled"
239
+ ? value
240
+ : undefined
241
+ }
242
+
243
+ export function makeTodoAdd(store: TodoStore, hooks: ToolHooks) {
244
+ return {
245
+ name: "todo_add",
246
+ description: ADD_DESCRIPTION,
247
+ input: {
248
+ type: "object",
249
+ properties: {
250
+ content: { type: "string" },
251
+ priority: { type: "string", enum: ["high", "medium", "low"] },
252
+ status: { type: "string", enum: ["pending", "in_progress", "completed", "canceled"] },
253
+ },
254
+ required: ["content"],
255
+ additionalProperties: false,
256
+ },
257
+ options: { codemode: true as const },
258
+ async execute(input: { content?: string; priority?: string; status?: string }, context: ToolContextLike) {
259
+ try {
260
+ const content = String(input?.content ?? "").trim()
261
+ if (!content) throw new Error('"content" must be a non-empty string')
262
+ const state = await store.refreshTodos(context.sessionID)
263
+ const existing = state?.items ?? []
264
+ const duplicate = existing.find((item) => item.content === content)
265
+ if (duplicate) {
266
+ return {
267
+ content: [
268
+ {
269
+ type: "text" as const,
270
+ text: `Already on the list as #${duplicate.id ?? "?"}: ${content}\n\n${renderList(existing)}`,
271
+ },
272
+ ],
273
+ metadata: { items: existing.length, open: openItems(existing).length, id: duplicate.id },
274
+ }
275
+ }
276
+ const item: TodoItem = { id: nextId(existing), content, status: validStatus(input?.status) ?? "pending" }
277
+ const priority = validPriority(input?.priority)
278
+ if (priority) item.priority = priority
279
+ const saved = await store.setTodos(context.sessionID, [...existing, item])
280
+ hooks.onTodosChanged?.(context.sessionID)
281
+ const added = saved.items[saved.items.length - 1]!
282
+ return {
283
+ content: [
284
+ {
285
+ type: "text" as const,
286
+ text:
287
+ `Added #${added.id} ${added.content}${added.priority ? ` (${added.priority})` : ""}.\n\n` +
288
+ `${renderSummary(saved.items)}\n\n${renderList(saved.items)}`,
289
+ },
290
+ ],
291
+ metadata: { items: saved.items.length, open: openItems(saved.items).length, id: added.id },
292
+ }
293
+ } catch (err) {
294
+ hooks.log?.(`todo_add failed (${context.sessionID}): ${String(err)}`)
295
+ return {
296
+ content: [{ type: "text" as const, text: `todo_add failed: ${String(err)}` }],
297
+ metadata: { error: String(err) },
298
+ }
299
+ }
300
+ },
301
+ }
302
+ }
303
+
304
+ export function makeTodoRemove(store: TodoStore, hooks: ToolHooks) {
305
+ return {
306
+ name: "todo_remove",
307
+ description: REMOVE_DESCRIPTION,
308
+ input: {
309
+ type: "object",
310
+ properties: { id: { type: "string" } },
311
+ required: ["id"],
312
+ additionalProperties: false,
313
+ },
314
+ options: { codemode: true as const },
315
+ async execute(input: { id?: string }, context: ToolContextLike) {
316
+ try {
317
+ const id = String(input?.id ?? "").trim()
318
+ if (!id) throw new Error('"id" must be a non-empty string')
319
+ const state = await store.refreshTodos(context.sessionID)
320
+ const existing = state?.items ?? []
321
+ const removed = existing.find((item) => item.id === id)
322
+ if (!removed) throw new Error(`no todo with id ${JSON.stringify(id)}`)
323
+ const saved = await store.setTodos(
324
+ context.sessionID,
325
+ existing.filter((item) => item.id !== id),
326
+ )
327
+ hooks.onTodosChanged?.(context.sessionID)
328
+ return {
329
+ content: [
330
+ {
331
+ type: "text" as const,
332
+ text: `Removed #${id}: ${removed.content}\n\n${renderSummary(saved.items)}\n\n${renderList(saved.items)}`,
333
+ },
334
+ ],
335
+ metadata: { items: saved.items.length, open: openItems(saved.items).length, id },
336
+ }
337
+ } catch (err) {
338
+ hooks.log?.(`todo_remove failed (${context.sessionID}): ${String(err)}`)
339
+ return {
340
+ content: [{ type: "text" as const, text: `todo_remove failed: ${String(err)}` }],
341
+ metadata: { error: String(err) },
342
+ }
343
+ }
344
+ },
345
+ }
346
+ }
347
+
219
348
  export function makeTodoSubscribe(store: TodoStore, hooks: ToolHooks, directory: string) {
220
349
  return {
221
350
  name: "todo_subscribe",
@@ -302,6 +431,8 @@ export async function registerTools(
302
431
  const registration = await ctx.tool.transform((editor) => {
303
432
  editor.add(makeTodoWrite(store, hooks))
304
433
  editor.add(makeTodoRead(store, hooks, { ...markdownDeps, options }))
434
+ editor.add(makeTodoAdd(store, hooks))
435
+ editor.add(makeTodoRemove(store, hooks))
305
436
  editor.add(makeTodoSubscribe(store, hooks, markdownDeps.directory))
306
437
  editor.add(makeTodoUnsubscribe(store, hooks, markdownDeps.directory))
307
438
  })
package/src/tui.tsx CHANGED
@@ -23,8 +23,11 @@ import {
23
23
  countOpen,
24
24
  dialogWidth,
25
25
  footerHints,
26
+ idTag,
27
+ itemColumns,
26
28
  moveCursor,
27
29
  popoverBodyHeight,
30
+ priorityTag,
28
31
  progressBar,
29
32
  rowIndex,
30
33
  statusMark,
@@ -322,6 +325,68 @@ export default Plugin.define({
322
325
  notify("Unsubscribed", "success")
323
326
  })
324
327
 
328
+ const addTask = () =>
329
+ guard("add task", async () => {
330
+ const content = await context.ui.dialog.prompt({
331
+ title: "Add todo",
332
+ description: "Press p on the new row to set its priority.",
333
+ placeholder: "what needs doing?",
334
+ })
335
+ if (!content || !content.trim()) return
336
+ const result = (await rpc.addItem({ sessionID: sessionID(), content })) as { id?: string } | undefined
337
+ notify(result?.id ? `Added #${result.id}` : "Added", "success")
338
+ })
339
+
340
+ /** The selected row, when it is an editable session item. */
341
+ const selectedSessionItem = (): Extract<Row, { kind: "item" }> | undefined => {
342
+ const row = current()
343
+ return row && row.kind === "item" && row.source === "session" && row.id ? row : undefined
344
+ }
345
+
346
+ const removeTask = () => {
347
+ const row = current()
348
+ if (!row || row.kind !== "item") {
349
+ notify("Select a task first", "warning")
350
+ return
351
+ }
352
+ if (row.source === "file" || !row.id) {
353
+ notify("File items are removed by editing the file", "warning")
354
+ return
355
+ }
356
+ const { id, content } = row
357
+ guard("remove task", async () => {
358
+ const ok = await context.ui.dialog.confirm({
359
+ title: "Remove task",
360
+ message: `Remove #${id}: ${truncate(content, 48)}?`,
361
+ label: { confirm: "Remove", cancel: "Cancel" },
362
+ })
363
+ if (!ok) return
364
+ await rpc.removeItem({ sessionID: sessionID(), id })
365
+ notify(`Removed #${id}`, "success")
366
+ })
367
+ }
368
+
369
+ const cyclePriority = () => {
370
+ const row = selectedSessionItem()
371
+ if (!row || !row.id) {
372
+ notify("Select a task first", "warning")
373
+ return
374
+ }
375
+ const id = row.id
376
+ const next =
377
+ row.priority === undefined
378
+ ? "low"
379
+ : row.priority === "low"
380
+ ? "medium"
381
+ : row.priority === "medium"
382
+ ? "high"
383
+ : "none"
384
+ guard("priority", async () => {
385
+ await rpc.setPriority({ sessionID: sessionID(), id, priority: next })
386
+ notify(next === "none" ? `#${id} priority cleared` : `#${id} priority: ${next}`, "success")
387
+ })
388
+ }
389
+
325
390
  const pauseResume = () => {
326
391
  const paused = snap().supervisor?.paused === true
327
392
  guard("supervision", async () => {
@@ -378,8 +443,11 @@ export default Plugin.define({
378
443
  }),
379
444
  },
380
445
  { title: "Todo: pause/resume supervision", bind: "s", run: pauseResume },
381
- { title: "Todo: add markdown file", bind: "a", run: addFile },
382
- { title: "Todo: remove markdown file", bind: "d", run: removeFile },
446
+ { title: "Todo: add task", bind: "a", run: addTask },
447
+ { title: "Todo: remove task", bind: "d", run: removeTask },
448
+ { title: "Todo: cycle priority", bind: "p", run: cyclePriority },
449
+ { title: "Todo: add markdown file", bind: "A", run: addFile },
450
+ { title: "Todo: remove markdown file", bind: "D", run: removeFile },
383
451
  {
384
452
  title: "Todo: refresh",
385
453
  bind: "r",
@@ -417,7 +485,8 @@ export default Plugin.define({
417
485
  title: "Todo panel",
418
486
  message:
419
487
  "↑↓/jk move · space/x toggle · enter collapse · n nudge · s pause/resume\n" +
420
- "a add file · d remove file · r refresh · / filter · f resize · c clear · esc close",
488
+ "a add task · d remove task · p priority · A add file · D remove file\n" +
489
+ "r refresh · / filter · f resize · c clear · esc close",
421
490
  })
422
491
  }),
423
492
  },
@@ -448,17 +517,30 @@ export default Plugin.define({
448
517
  return Math.max(3, available - filtered)
449
518
  }
450
519
 
451
- const barWidth = () => Math.max(6, Math.min(24, width() - 44))
520
+ const barWidth = () => Math.max(6, Math.min(40, Math.floor(width() * 0.25)))
521
+
522
+ // Column widths for the `#id` / priority columns, sized to the list.
523
+ const columns = createMemo(() => {
524
+ const current = snap()
525
+ return itemColumns([...current.items, ...current.files.flatMap((file) => file.items)])
526
+ })
527
+ const contentWidth = () =>
528
+ Math.max(
529
+ 8,
530
+ width() -
531
+ 6 -
532
+ (columns().id > 0 ? columns().id + 2 : 0) -
533
+ (columns().priority > 0 ? columns().priority + 2 : 0),
534
+ )
452
535
 
453
536
  const renderRow = (row: Row) => {
454
537
  const isCurrent = () => row.key === cursor()
455
538
  const prefix = () => (isCurrent() ? "❯ " : " ")
456
- const contentWidth = () => Math.max(8, width() - 6)
457
539
  const background = () => (isCurrent() ? selectedBg() : undefined)
458
540
  if (row.kind === "section") {
459
541
  return (
460
542
  <text fg={muted()}>
461
- {prefix() + truncate(row.label + (row.detail ? " · " + row.detail : ""), contentWidth())}
543
+ {prefix() + truncate(row.label + (row.detail ? " · " + row.detail : ""), width() - 6)}
462
544
  </text>
463
545
  )
464
546
  }
@@ -468,17 +550,20 @@ export default Plugin.define({
468
550
  return (
469
551
  <box backgroundColor={background()}>
470
552
  <text fg={isCurrent() ? accent() : base()}>
471
- {prefix() + (row.collapsed ? "▸ " : "▾ ") + truncate(`${label} · ${detail}`, contentWidth())}
553
+ {prefix() + (row.collapsed ? "▸ " : "▾ ") + truncate(`${label} · ${detail}`, width() - 6)}
472
554
  </text>
473
555
  </box>
474
556
  )
475
557
  }
476
- const suffix =
477
- (row.priority ? ` (${row.priority})` : "") + (row.line !== undefined ? ` :${row.line}` : "")
558
+ const cols = columns()
559
+ const parts = [prefix() + statusMark(row.status)]
560
+ if (cols.id > 0) parts.push(idTag(row.id).padEnd(cols.id))
561
+ if (cols.priority > 0) parts.push(priorityTag(row.priority).padEnd(cols.priority))
562
+ parts.push(truncate(row.content + (row.line !== undefined ? ` :${row.line}` : ""), contentWidth()))
478
563
  return (
479
564
  <box backgroundColor={background()}>
480
565
  <text fg={row.status === "completed" ? muted() : isCurrent() ? accent() : base()}>
481
- {prefix() + statusMark(row.status) + " " + truncate(row.content + suffix, contentWidth())}
566
+ {parts.join(" ")}
482
567
  </text>
483
568
  </box>
484
569
  )