@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 +55 -16
- package/package.json +1 -1
- package/src/panel.test.ts +36 -15
- package/src/panel.ts +44 -13
- package/src/rpc.test.ts +51 -0
- package/src/rpc.ts +117 -8
- package/src/storage.test.ts +56 -1
- package/src/storage.ts +66 -2
- package/src/tools.test.ts +60 -3
- package/src/tools.ts +137 -6
- package/src/tui.tsx +95 -10
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),
|
|
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.
|
|
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
|
-
|
|
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`
|
|
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
|
|
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 (`
|
|
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
|
-
|
|
151
|
-
the
|
|
152
|
-
|
|
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
|
|
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` (
|
|
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.
|
|
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(
|
|
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
|
|
177
|
-
expect(popoverBodyHeight(40)).toBe(
|
|
178
|
-
expect(popoverBodyHeight(24)).toBe(
|
|
179
|
-
expect(popoverBodyHeight(24, 1)).toBe(
|
|
180
|
-
expect(popoverBodyHeight(14)).toBe(
|
|
181
|
-
expect(popoverBodyHeight(50, 2)).toBe(
|
|
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(
|
|
186
|
-
expect(dialogWidth("large", 200)).toBe(
|
|
187
|
-
expect(dialogWidth("xlarge", 200)).toBe(
|
|
188
|
-
expect(dialogWidth("medium", 80)).toBe(
|
|
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(
|
|
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
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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
|
-
|
|
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
|
|
281
|
-
*
|
|
282
|
-
* terminals
|
|
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
|
|
287
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
224
|
-
if (
|
|
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
|
|
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)
|
package/src/storage.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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(
|
|
162
|
-
metadata: { items:
|
|
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
|
|
382
|
-
{ title: "Todo: remove
|
|
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
|
|
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(
|
|
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 : ""),
|
|
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}`,
|
|
553
|
+
{prefix() + (row.collapsed ? "▸ " : "▾ ") + truncate(`${label} · ${detail}`, width() - 6)}
|
|
472
554
|
</text>
|
|
473
555
|
</box>
|
|
474
556
|
)
|
|
475
557
|
}
|
|
476
|
-
const
|
|
477
|
-
|
|
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
|
-
{
|
|
566
|
+
{parts.join(" ")}
|
|
482
567
|
</text>
|
|
483
568
|
</box>
|
|
484
569
|
)
|