opencode-session-todo 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +31 -2
- package/USAGE.md +83 -0
- package/docs/development.md +28 -0
- package/index.ts +211 -0
- package/package.json +60 -4
- package/rpc.ts +22 -0
- package/src/diff.ts +64 -0
- package/src/instructions.ts +10 -0
- package/src/model.ts +51 -0
- package/src/sidebar.ts +44 -0
- package/src/store.ts +162 -0
- package/src/todotxt.ts +150 -0
- package/tui.tsx +299 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexandru Nedelcu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenCode TODO
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**A task list that stays with your session.**
|
|
4
|
+
|
|
5
|
+
Session-local [todo.txt](https://github.com/todotxt/todo.txt) for OpenCode V2.
|
|
6
|
+
Tools for the agent. A sidebar for you. Plain text you can edit.
|
|
7
|
+
|
|
8
|
+
## Get started
|
|
9
|
+
|
|
10
|
+
Add to `opencode.jsonc`:
|
|
11
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
{
|
|
14
|
+
"$schema": "https://opencode.ai/config.json",
|
|
15
|
+
"plugins": [
|
|
16
|
+
{
|
|
17
|
+
"package": "opencode-session-todo",
|
|
18
|
+
"options": {
|
|
19
|
+
"sidebarLimit": 5,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Reload OpenCode and reopen the TUI.
|
|
27
|
+
|
|
28
|
+
- **Read and write** with `todoread` and `todowrite`, with conflict detection and change diffs.
|
|
29
|
+
- **Keep tasks in view** in the sidebar; open `/todos` for the full list.
|
|
30
|
+
- **Own the file** at `.agents/sessions/<session-id>/todo.txt`.
|
|
31
|
+
|
|
32
|
+
[Usage & permissions](USAGE.md) · [Development & publishing](docs/development.md) · [MIT](LICENSE)
|
package/USAGE.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Session TODOs
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
Configure the plugin in `opencode.jsonc`:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"$schema": "https://opencode.ai/config.json",
|
|
10
|
+
"plugins": [{ "package": "opencode-session-todo", "options": { "sidebarLimit": 5 } }]
|
|
11
|
+
}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then reload OpenCode:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
opencode reload
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Reopen the TUI after configuration changes. `sidebarLimit` is a positive integer, default **5**.
|
|
21
|
+
|
|
22
|
+
## Tools
|
|
23
|
+
|
|
24
|
+
Only root sessions expose these tools and receive the TODO lifecycle instructions. Subagents see neither tool.
|
|
25
|
+
|
|
26
|
+
- `todoread({})` reads the current session's tasks and file path. Missing files return an empty list.
|
|
27
|
+
- `todowrite({ previous, todos })` replaces the ordered list only when the stored list still equals `previous`; take `previous` from the latest `todoread` or `todowrite` result. A mismatch fails with an instruction to `todoread` and retry. The result reports a before/after diff. An empty `todos` array clears the list.
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"previous": [],
|
|
32
|
+
"todos": [
|
|
33
|
+
{ "content": "Run the tests", "status": "in_progress", "priority": "high" }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Statuses: `pending`, `in_progress`, `completed`, `cancelled`. Priorities: `high`, `medium`, `low`.
|
|
39
|
+
At most one task may be `in_progress`. Content must be one nonempty trimmed line,
|
|
40
|
+
cannot begin with a priority/date, and cannot contain `status:` or `pri:` tokens.
|
|
41
|
+
|
|
42
|
+
## TUI
|
|
43
|
+
|
|
44
|
+
- Click **TODO** or its `▼` / `▶` arrow to expand or collapse the sidebar list.
|
|
45
|
+
- Above `sidebarLimit` total tasks, the sidebar shows only unfinished tasks, capped at the limit,
|
|
46
|
+
with the active task first.
|
|
47
|
+
- **Show all …**, `/todos`, or **Ctrl+P → Toggle session todos** opens the full scrollable panel.
|
|
48
|
+
- Close with **Close ×**, **Esc** while focused, or the same command-palette action.
|
|
49
|
+
- Each `todowrite` pops a toast with the before/after diff of the change.
|
|
50
|
+
- Manual file edits appear within two seconds. Collapsing does not change the tasks.
|
|
51
|
+
|
|
52
|
+
## Storage
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
<project/worktree>/.agents/sessions/<session-id>/todo.txt
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
(A) Run the tests status:in_progress
|
|
60
|
+
(B) Review the changes
|
|
61
|
+
x Read the docs pri:C
|
|
62
|
+
x Superseded approach pri:B status:cancelled
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`A` / `B` / `C` map to high / medium / low; other priority letters are unsupported.
|
|
66
|
+
Add `.agents/sessions/` to your project's ignore rules to keep these files out of version control.
|
|
67
|
+
|
|
68
|
+
## Permissions
|
|
69
|
+
|
|
70
|
+
Both tools use `todowrite`. Writes require agent/session rules resolving to `allow`; `ask` and
|
|
71
|
+
`deny` are rejected. Approval prompts, saved approvals, and experimental policies are unsupported.
|
|
72
|
+
Read-only agents should deny `todowrite`.
|
|
73
|
+
|
|
74
|
+
## Commands
|
|
75
|
+
|
|
76
|
+
From the repository root:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
bun run check
|
|
80
|
+
bun test
|
|
81
|
+
bun run format # dprint fmt
|
|
82
|
+
bun run format:check # dprint check
|
|
83
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Development
|
|
2
|
+
|
|
3
|
+
Requires Bun >= 1.4.2.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bun ci
|
|
7
|
+
make test
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`make test` runs typecheck, tests, and formatting checks.
|
|
11
|
+
`make upgrade-dependencies` updates pinned dependencies and the lockfile.
|
|
12
|
+
|
|
13
|
+
## Package entry points
|
|
14
|
+
|
|
15
|
+
The package ships TypeScript sources for OpenCode's Bun runtime; no build step is required.
|
|
16
|
+
|
|
17
|
+
- `opencode-session-todo`: tools and RPC handlers.
|
|
18
|
+
- `opencode-session-todo/tui`: sidebar and full panel.
|
|
19
|
+
- `opencode-session-todo/rpc`: shared RPC definitions.
|
|
20
|
+
|
|
21
|
+
## Publishing
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm login
|
|
25
|
+
make publish
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`make publish` runs the checks and a package dry-run before `npm publish --access public`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
2
|
+
import type { Registration } from "@opencode/plugin/effect/registration"
|
|
3
|
+
import type { RpcHandlers } from "@opencode/plugin/effect/rpc"
|
|
4
|
+
import type { SessionContext } from "@opencode/plugin/effect/session"
|
|
5
|
+
import { Agent } from "@opencode/schema/agent"
|
|
6
|
+
import { Permission } from "@opencode/schema/permission"
|
|
7
|
+
import { Session } from "@opencode/schema/session"
|
|
8
|
+
import { Tool } from "@opencode/schema/tool"
|
|
9
|
+
import { Effect, Schema, type Scope } from "effect"
|
|
10
|
+
import { resolve } from "node:path"
|
|
11
|
+
import { TodoRpc } from "./rpc.ts"
|
|
12
|
+
import { diffTodos } from "./src/diff.ts"
|
|
13
|
+
import { TodoInstructions } from "./src/instructions.ts"
|
|
14
|
+
import { Snapshot, TodoError, Todos } from "./src/model.ts"
|
|
15
|
+
import { makeTodoStore, SessionDirectory, type TodoStore, type WriteResult } from "./src/store.ts"
|
|
16
|
+
|
|
17
|
+
const WriteInput = Schema.Struct({ previous: Todos, todos: Todos })
|
|
18
|
+
const ReadInput = Schema.Struct({})
|
|
19
|
+
|
|
20
|
+
const toolError = (error: TodoError): Tool.Error => new Tool.Error({ message: error.message })
|
|
21
|
+
|
|
22
|
+
export interface TodoPluginContext {
|
|
23
|
+
readonly location: Pick<Plugin.Context["location"], "directory">
|
|
24
|
+
readonly tool: Pick<Plugin.Context["tool"], "transform">
|
|
25
|
+
readonly rpc: Pick<Plugin.Context["rpc"], "register">
|
|
26
|
+
readonly session: Pick<Plugin.Context["session"], "get"> & {
|
|
27
|
+
readonly hook: (
|
|
28
|
+
name: "context",
|
|
29
|
+
callback: (event: SessionContext) => Effect.Effect<void>,
|
|
30
|
+
) => Effect.Effect<Registration, never, Scope.Scope>
|
|
31
|
+
}
|
|
32
|
+
readonly agent: Pick<Plugin.Context["agent"], "get">
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const matches = (value: string, pattern: string): boolean => {
|
|
36
|
+
const normalized: string = value.replaceAll("\\", "/")
|
|
37
|
+
let expression: string = pattern
|
|
38
|
+
.replaceAll("\\", "/")
|
|
39
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
40
|
+
.replace(/\*/g, ".*")
|
|
41
|
+
.replace(/\?/g, ".")
|
|
42
|
+
if (expression.endsWith(" .*")) expression = `${expression.slice(0, -3)}( .*)?`
|
|
43
|
+
return new RegExp(`^${expression}$`, process.platform === "win32" ? "si" : "s").test(normalized)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const allowed = (action: string, resource: string, rules: readonly Permission.Rule[]): boolean =>
|
|
47
|
+
rules.findLast(
|
|
48
|
+
(rule): boolean => matches(action, rule.action) && matches(resource, rule.resource),
|
|
49
|
+
)?.effect === "allow"
|
|
50
|
+
|
|
51
|
+
export const register = (ctx: TodoPluginContext): Effect.Effect<void, never, Scope.Scope> =>
|
|
52
|
+
Effect.gen(function*(): Effect.fn.Return<void, unknown, Scope.Scope> {
|
|
53
|
+
const store: TodoStore = yield* makeTodoStore
|
|
54
|
+
yield* ctx.session.hook(
|
|
55
|
+
"context",
|
|
56
|
+
(event): Effect.Effect<void> =>
|
|
57
|
+
Effect.gen(function*(): Effect.fn.Return<void> {
|
|
58
|
+
if (!Object.hasOwn(event.tools, "todowrite") && !Object.hasOwn(event.tools, "todoread")) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const root: boolean = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(
|
|
62
|
+
Effect.match({
|
|
63
|
+
onSuccess: (session): boolean => session.parentID === undefined,
|
|
64
|
+
onFailure: (): boolean => false,
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
if (!root) {
|
|
68
|
+
delete event.tools.todowrite
|
|
69
|
+
delete event.tools.todoread
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (!Object.hasOwn(event.tools, "todowrite") || !Object.hasOwn(event.tools, "todoread")) {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
event.system.some(
|
|
77
|
+
(part): boolean => part.type === "text" && part.text === TodoInstructions,
|
|
78
|
+
)
|
|
79
|
+
) {
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
event.system.push({ type: "text", text: TodoInstructions })
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
const getSession = Effect.fnUntraced(function*(
|
|
86
|
+
sessionID: Session.ID,
|
|
87
|
+
): Effect.fn.Return<Session.Info, TodoError> {
|
|
88
|
+
const session: Session.Info = yield* ctx.session
|
|
89
|
+
.get({ sessionID })
|
|
90
|
+
.pipe(
|
|
91
|
+
Effect.mapError(
|
|
92
|
+
(error): TodoError =>
|
|
93
|
+
new TodoError({ message: `Cannot find session: ${String(error)}` }),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
if (resolve(session.location.directory) !== resolve(ctx.location.directory)) {
|
|
97
|
+
return yield* new TodoError({
|
|
98
|
+
message: "The todo session belongs to another project location",
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
return session
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const directoryFor = (session: Session.Info): Effect.Effect<SessionDirectory, TodoError> =>
|
|
105
|
+
Schema.decodeUnknownEffect(SessionDirectory)({
|
|
106
|
+
directory: session.location.directory,
|
|
107
|
+
name: session.id,
|
|
108
|
+
}).pipe(Effect.mapError((error): TodoError => new TodoError({ message: error.message })))
|
|
109
|
+
|
|
110
|
+
const read = Effect.fnUntraced(function*(
|
|
111
|
+
sessionID: Session.ID,
|
|
112
|
+
): Effect.fn.Return<Snapshot, TodoError> {
|
|
113
|
+
return yield* store.read(yield* directoryFor(yield* getSession(sessionID)))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const rpcHandlers: RpcHandlers<typeof TodoRpc> = {
|
|
117
|
+
read: (input, context): ReturnType<RpcHandlers<typeof TodoRpc>["read"]> =>
|
|
118
|
+
read(input.sessionID).pipe(
|
|
119
|
+
Effect.mapError(
|
|
120
|
+
(error): ReturnType<typeof context.error<"storage">> =>
|
|
121
|
+
context.error("storage", error.message, { message: error.message }),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
const rpc = yield* ctx.rpc.register(TodoRpc, rpcHandlers)
|
|
126
|
+
|
|
127
|
+
const executeWrite: Tool.Info<typeof WriteInput, typeof Snapshot>["execute"] = Effect.fn(
|
|
128
|
+
"todo.write",
|
|
129
|
+
)(function*(input, context): Effect.fn.Return<Tool.Result<typeof Snapshot>, Tool.Error> {
|
|
130
|
+
const session: Session.Info = yield* getSession(context.sessionID).pipe(
|
|
131
|
+
Effect.mapError(toolError),
|
|
132
|
+
)
|
|
133
|
+
const agent: Agent.Info = (yield* ctx.agent
|
|
134
|
+
.get({ agentID: context.agent })
|
|
135
|
+
.pipe(
|
|
136
|
+
Effect.mapError(
|
|
137
|
+
(error): Tool.Error =>
|
|
138
|
+
new Tool.Error({ message: `Cannot resolve todo permissions: ${String(error)}` }),
|
|
139
|
+
),
|
|
140
|
+
)).data
|
|
141
|
+
const rules: readonly Permission.Rule[] = [
|
|
142
|
+
...agent.permissions,
|
|
143
|
+
...(session.permissions ?? []),
|
|
144
|
+
]
|
|
145
|
+
const resource: string = `.agents/sessions/${session.id}/todo.txt`
|
|
146
|
+
if (!allowed("todowrite", resource, rules)) {
|
|
147
|
+
return yield* new Tool.Error({
|
|
148
|
+
message:
|
|
149
|
+
"todowrite requires an allow permission. OpenCode V2 does not expose custom-tool approval prompts; deny and ask are rejected.",
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
const target: SessionDirectory = yield* directoryFor(session).pipe(
|
|
153
|
+
Effect.mapError(toolError),
|
|
154
|
+
)
|
|
155
|
+
const saved: WriteResult = yield* store.write(target, input.todos, input.previous).pipe(
|
|
156
|
+
Effect.mapError(toolError),
|
|
157
|
+
)
|
|
158
|
+
const diff: string = diffTodos(saved.previous, saved.todos)
|
|
159
|
+
yield* rpc.events
|
|
160
|
+
.emit("updated", { sessionID: context.sessionID, diff })
|
|
161
|
+
.pipe(
|
|
162
|
+
Effect.catch(
|
|
163
|
+
(error): Effect.Effect<void> =>
|
|
164
|
+
Effect.logWarning("Todo saved, but notification failed", error),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
return {
|
|
168
|
+
output: { path: saved.path, todos: saved.todos },
|
|
169
|
+
content: diff === "" ? "Todo list unchanged." : `Todo list updated:\n${diff}`,
|
|
170
|
+
metadata: { todos: saved.todos, path: saved.path },
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
const executeRead: Tool.Info<typeof ReadInput, typeof Snapshot>["execute"] = (
|
|
175
|
+
_input,
|
|
176
|
+
context,
|
|
177
|
+
): Effect.Effect<Tool.Result<typeof Snapshot>, Tool.Error> => {
|
|
178
|
+
return read(context.sessionID).pipe(
|
|
179
|
+
Effect.map(
|
|
180
|
+
(snapshot): Tool.Result<typeof Snapshot> => ({
|
|
181
|
+
output: snapshot,
|
|
182
|
+
content: JSON.stringify(snapshot),
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
Effect.mapError(toolError),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
yield* ctx.tool.transform((editor): void => {
|
|
190
|
+
editor.add({
|
|
191
|
+
name: "todowrite",
|
|
192
|
+
description:
|
|
193
|
+
`Replace this session's ordered todo list when it still equals the \`previous\` list you send; take \`previous\` from the latest todoread or todowrite result in this conversation. A mismatch fails the write: use todoread, then retry. Status: pending, in_progress, completed, cancelled; at most one in_progress. Priority: high, medium, low. Text is one trimmed line; status:/pri: are reserved. An empty todos array clears the list. Persists as project-local todo.txt and updates the TUI.\n\n${TodoInstructions}`,
|
|
194
|
+
input: WriteInput,
|
|
195
|
+
output: Snapshot,
|
|
196
|
+
options: { codemode: false, permission: "todowrite" },
|
|
197
|
+
execute: executeWrite,
|
|
198
|
+
})
|
|
199
|
+
editor.add({
|
|
200
|
+
name: "todoread",
|
|
201
|
+
description:
|
|
202
|
+
`Read this session's todo.txt list and storage path. Use it to obtain the \`previous\` list todowrite expects, or to retry after a failed write; missing files mean an empty list.\n\n${TodoInstructions}`,
|
|
203
|
+
input: ReadInput,
|
|
204
|
+
output: Snapshot,
|
|
205
|
+
options: { codemode: false, permission: "todowrite" },
|
|
206
|
+
execute: executeRead,
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
}).pipe(Effect.orDie)
|
|
210
|
+
|
|
211
|
+
export default Plugin.define({ id: "session-todo", effect: register })
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-session-todo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Session-local todo.txt tools and TUI for OpenCode V2",
|
|
5
|
+
"homepage": "https://github.com/alexandru/opencode-todo#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/alexandru/opencode-todo/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/alexandru/opencode-todo.git"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./index.ts",
|
|
17
|
+
"./tui": "./tui.tsx",
|
|
18
|
+
"./rpc": "./rpc.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.ts",
|
|
22
|
+
"tui.tsx",
|
|
23
|
+
"rpc.ts",
|
|
24
|
+
"src",
|
|
25
|
+
"USAGE.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"README.md",
|
|
28
|
+
"docs/development.md"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"check": "tsc --noEmit",
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"format": "dprint fmt",
|
|
34
|
+
"format:check": "dprint check",
|
|
35
|
+
"prepublishOnly": "bun run check && bun run test && bun run format:check"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@opencode/plugin": "2.0.27",
|
|
39
|
+
"@opencode/schema": "2.0.27",
|
|
40
|
+
"effect": "4.0.1"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@opencode/theme": "2.0.27",
|
|
44
|
+
"@opentui/core": "0.5.17",
|
|
45
|
+
"@opentui/solid": "0.5.17",
|
|
46
|
+
"@types/bun": "1.3.11",
|
|
47
|
+
"dprint": "0.63.0",
|
|
48
|
+
"solid-js": "1.9.12",
|
|
49
|
+
"typescript": "5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@opentui/core": ">=0.5.17",
|
|
53
|
+
"@opentui/solid": ">=0.5.17",
|
|
54
|
+
"solid-js": ">=1.9.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"bun": ">=1.4.2"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/rpc.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Rpc } from "@opencode/schema/rpc"
|
|
2
|
+
import { Session } from "@opencode/schema/session"
|
|
3
|
+
import { Schema } from "effect"
|
|
4
|
+
import { Snapshot } from "./src/model.ts"
|
|
5
|
+
|
|
6
|
+
export const TodoRpc = Rpc.define({
|
|
7
|
+
id: "session-todo",
|
|
8
|
+
methods: {
|
|
9
|
+
read: {
|
|
10
|
+
input: Schema.toStandardSchemaV1(Schema.Struct({ sessionID: Session.ID })),
|
|
11
|
+
output: Schema.toStandardSchemaV1(Snapshot),
|
|
12
|
+
errors: { storage: Schema.toStandardSchemaV1(Schema.Struct({ message: Schema.String })) },
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
events: {
|
|
16
|
+
updated: {
|
|
17
|
+
schema: Schema.toStandardSchemaV1(
|
|
18
|
+
Schema.Struct({ sessionID: Session.ID, diff: Schema.String }),
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Todo, Todos } from "./model.ts"
|
|
2
|
+
|
|
3
|
+
const lineFor = (todo: Todo): string => `[${todo.status}|${todo.priority}] ${todo.content}`
|
|
4
|
+
|
|
5
|
+
const MAX_CELLS: number = 1 << 20
|
|
6
|
+
|
|
7
|
+
const replaced = (previous: readonly string[], next: readonly string[]): readonly string[] => [
|
|
8
|
+
...previous.map((line: string): string => `- ${line}`),
|
|
9
|
+
...next.map((line: string): string => `+ ${line}`),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const advance = (cursor: Iterator<string>): string | undefined => {
|
|
13
|
+
const step: IteratorResult<string> = cursor.next()
|
|
14
|
+
return step.done ? undefined : step.value
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const diffLines = (previous: readonly string[], next: readonly string[]): readonly string[] => {
|
|
18
|
+
if (
|
|
19
|
+
previous.length === 0 || next.length === 0
|
|
20
|
+
|| previous.length * next.length > MAX_CELLS
|
|
21
|
+
) return replaced(previous, next)
|
|
22
|
+
const width: number = next.length + 1
|
|
23
|
+
const lengths: Int32Array = new Int32Array((previous.length + 1) * width)
|
|
24
|
+
const cell = (index: number): number => lengths[index] ?? 0
|
|
25
|
+
previous.reduceRight<void>((_, prevLine: string, i: number): void => {
|
|
26
|
+
next.reduceRight<void>((_, nextLine: string, j: number): void => {
|
|
27
|
+
lengths[i * width + j] = prevLine === nextLine
|
|
28
|
+
? cell((i + 1) * width + j + 1) + 1
|
|
29
|
+
: Math.max(cell((i + 1) * width + j), cell(i * width + j + 1))
|
|
30
|
+
}, undefined)
|
|
31
|
+
}, undefined)
|
|
32
|
+
const lines: string[] = []
|
|
33
|
+
const previousCursor: Iterator<string> = previous[Symbol.iterator]()
|
|
34
|
+
const nextCursor: Iterator<string> = next[Symbol.iterator]()
|
|
35
|
+
let i: number = 0
|
|
36
|
+
let j: number = 0
|
|
37
|
+
let previousLine: string | undefined = advance(previousCursor)
|
|
38
|
+
let nextLine: string | undefined = advance(nextCursor)
|
|
39
|
+
while (previousLine !== undefined || nextLine !== undefined) {
|
|
40
|
+
if (previousLine === nextLine) {
|
|
41
|
+
i += 1
|
|
42
|
+
j += 1
|
|
43
|
+
previousLine = advance(previousCursor)
|
|
44
|
+
nextLine = advance(nextCursor)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (
|
|
48
|
+
previousLine !== undefined
|
|
49
|
+
&& (nextLine === undefined || cell((i + 1) * width + j) >= cell(i * width + j + 1))
|
|
50
|
+
) {
|
|
51
|
+
lines.push(`- ${previousLine}`)
|
|
52
|
+
i += 1
|
|
53
|
+
previousLine = advance(previousCursor)
|
|
54
|
+
} else if (nextLine !== undefined) {
|
|
55
|
+
lines.push(`+ ${nextLine}`)
|
|
56
|
+
j += 1
|
|
57
|
+
nextLine = advance(nextCursor)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const diffTodos = (previous: Todos, next: Todos): string =>
|
|
64
|
+
diffLines(previous.map(lineFor), next.map(lineFor)).join("\n")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const TodoInstructions: string = `TODO lifecycle
|
|
2
|
+
|
|
3
|
+
- You MUST keep this session's TODO list synchronized with the requested work and its actual progress.
|
|
4
|
+
- You MUST pass the list you expect to be stored as \`previous\` to every todowrite; when the write reports the stored list changed, you MUST use todoread and retry with the refreshed list. You MUST preserve existing tasks, order, statuses, and priorities unless actual progress or the user's instructions require a change.
|
|
5
|
+
- You MUST mark a task in_progress before starting it. You MUST keep at most one task in_progress.
|
|
6
|
+
- You MUST mark a task completed immediately after finishing its work and required verification.
|
|
7
|
+
- You MUST NOT mark blocked, failed, skipped, unfinished, or unverified work completed. Unstarted or deferred work MUST remain pending; cancelled work MUST be represented as cancelled.
|
|
8
|
+
- You MUST update the list after each meaningful status transition. You MUST NOT batch status updates until the end of the conversation or leave finished work in_progress.
|
|
9
|
+
- Before your final response, you MUST reconcile the list with what was actually done and accurately retain unfinished work.
|
|
10
|
+
- You SHOULD keep task descriptions concise and actionable.`
|
package/src/model.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
|
|
3
|
+
export const Content = Schema.String.check(
|
|
4
|
+
Schema.isMinLength(1),
|
|
5
|
+
Schema.isTrimmed(),
|
|
6
|
+
Schema.isPattern(/^[^\r\n\x00]+$/),
|
|
7
|
+
Schema.makeFilter(
|
|
8
|
+
(value: string): boolean =>
|
|
9
|
+
!/^(?:\([A-Z]\) |\d{4}-\d{2}-\d{2}(?: |$))/.test(value)
|
|
10
|
+
&& !/(?:^|\s)(?:status|pri):/.test(value),
|
|
11
|
+
{
|
|
12
|
+
message:
|
|
13
|
+
"Task text must not start with a priority/date or contain reserved status:/pri: tokens",
|
|
14
|
+
},
|
|
15
|
+
),
|
|
16
|
+
).pipe(Schema.brand("TodoContent"))
|
|
17
|
+
|
|
18
|
+
export const Status = Schema.Literals(["pending", "in_progress", "completed", "cancelled"])
|
|
19
|
+
export const Priority = Schema.Literals(["high", "medium", "low"])
|
|
20
|
+
export const Todo = Schema.Struct({ content: Content, status: Status, priority: Priority })
|
|
21
|
+
export type Todo = typeof Todo.Type
|
|
22
|
+
|
|
23
|
+
export const Todos = Schema.Array(Todo)
|
|
24
|
+
.check(
|
|
25
|
+
Schema.makeFilter(
|
|
26
|
+
(todos: readonly Todo[]): boolean => {
|
|
27
|
+
let active: boolean = false
|
|
28
|
+
for (const todo of todos) {
|
|
29
|
+
if (todo.status !== "in_progress") continue
|
|
30
|
+
if (active) return false
|
|
31
|
+
active = true
|
|
32
|
+
}
|
|
33
|
+
return true
|
|
34
|
+
},
|
|
35
|
+
{ message: "At most one todo may be in_progress" },
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
.pipe(Schema.brand("TodoList"))
|
|
39
|
+
export type Todos = typeof Todos.Type
|
|
40
|
+
|
|
41
|
+
export const SessionName = Schema.String.check(
|
|
42
|
+
Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
|
|
43
|
+
).pipe(Schema.brand("TodoSessionName"))
|
|
44
|
+
export type SessionName = typeof SessionName.Type
|
|
45
|
+
|
|
46
|
+
export const Snapshot = Schema.Struct({ path: Schema.String, todos: Todos })
|
|
47
|
+
export type Snapshot = typeof Snapshot.Type
|
|
48
|
+
|
|
49
|
+
export class TodoError extends Schema.TaggedError<TodoError>()("TodoError", {
|
|
50
|
+
message: Schema.String,
|
|
51
|
+
}) {}
|
package/src/sidebar.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect"
|
|
2
|
+
import type { Todo, Todos } from "./model.ts"
|
|
3
|
+
|
|
4
|
+
export const SidebarLimit = Schema.Int.check(Schema.isGreaterThan(0)).pipe(
|
|
5
|
+
Schema.brand("TodoSidebarLimit"),
|
|
6
|
+
)
|
|
7
|
+
export type SidebarLimit = typeof SidebarLimit.Type
|
|
8
|
+
|
|
9
|
+
export const SidebarOptions = Schema.Struct({
|
|
10
|
+
sidebarLimit: SidebarLimit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(5))),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export type TodoProjection =
|
|
14
|
+
| { readonly kind: "all"; readonly items: readonly Todo[]; readonly remaining: number }
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "limited"
|
|
17
|
+
readonly items: readonly Todo[]
|
|
18
|
+
readonly remaining: number
|
|
19
|
+
readonly total: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const incomplete = (todo: Todo): boolean =>
|
|
23
|
+
todo.status === "pending" || todo.status === "in_progress"
|
|
24
|
+
|
|
25
|
+
export const projectTodos = (todos: Todos, mode: "full" | SidebarLimit): TodoProjection => {
|
|
26
|
+
if (mode === "full" || todos.length <= mode) {
|
|
27
|
+
return {
|
|
28
|
+
kind: "all",
|
|
29
|
+
items: todos,
|
|
30
|
+
remaining: todos.reduce((count, todo): number => count + Number(incomplete(todo)), 0),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const items: Todo[] = []
|
|
34
|
+
let remaining: number = 0
|
|
35
|
+
for (const todo of todos) {
|
|
36
|
+
if (!incomplete(todo)) continue
|
|
37
|
+
remaining += 1
|
|
38
|
+
if (todo.status === "in_progress") {
|
|
39
|
+
if (items.length === mode) items.pop()
|
|
40
|
+
items.unshift(todo)
|
|
41
|
+
} else if (items.length < mode) items.push(todo)
|
|
42
|
+
}
|
|
43
|
+
return { kind: "limited", items, remaining, total: todos.length }
|
|
44
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { DateTime, Effect, Schema, Semaphore } from "effect"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
import type { Stats } from "node:fs"
|
|
4
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
|
|
5
|
+
import { isAbsolute, join } from "node:path"
|
|
6
|
+
import { SessionName, Snapshot, Todo, TodoError, Todos } from "./model.ts"
|
|
7
|
+
import { type Document, parse, render } from "./todotxt.ts"
|
|
8
|
+
|
|
9
|
+
export const SessionDirectory = Schema.Struct({
|
|
10
|
+
directory: Schema.String.check(
|
|
11
|
+
Schema.makeFilter((directory: string): boolean => isAbsolute(directory), {
|
|
12
|
+
message: "The todo project directory must be absolute",
|
|
13
|
+
}),
|
|
14
|
+
).pipe(Schema.brand("TodoProjectDirectory")),
|
|
15
|
+
name: SessionName,
|
|
16
|
+
})
|
|
17
|
+
export type SessionDirectory = typeof SessionDirectory.Type
|
|
18
|
+
|
|
19
|
+
export interface WriteResult extends Snapshot {
|
|
20
|
+
readonly previous: Todos
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TodoStore {
|
|
24
|
+
readonly read: (session: SessionDirectory) => Effect.Effect<Snapshot, TodoError>
|
|
25
|
+
readonly write: (
|
|
26
|
+
session: SessionDirectory,
|
|
27
|
+
todos: Todos,
|
|
28
|
+
expected?: Todos,
|
|
29
|
+
) => Effect.Effect<WriteResult, TodoError>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const sameTodos = (left: readonly Todo[], right: readonly Todo[]): boolean => {
|
|
33
|
+
if (left.length !== right.length) return false
|
|
34
|
+
const cursor: Iterator<Todo> = right[Symbol.iterator]()
|
|
35
|
+
for (const todo of left) {
|
|
36
|
+
const step: IteratorResult<Todo> = cursor.next()
|
|
37
|
+
if (
|
|
38
|
+
step.done
|
|
39
|
+
|| step.value.content !== todo.content
|
|
40
|
+
|| step.value.status !== todo.status
|
|
41
|
+
|| step.value.priority !== todo.priority
|
|
42
|
+
) {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const io = <A>(message: string, run: () => Promise<A>): Effect.Effect<A, TodoError> =>
|
|
50
|
+
Effect.tryPromise({
|
|
51
|
+
try: run,
|
|
52
|
+
catch: (error): TodoError => new TodoError({ message: `${message}: ${String(error)}` }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const missing = (error: unknown): boolean =>
|
|
56
|
+
Schema.is(Schema.Struct({ code: Schema.Literal("ENOENT") }))(error)
|
|
57
|
+
|
|
58
|
+
const pathFor = (session: SessionDirectory): string =>
|
|
59
|
+
join(session.directory, ".agents", "sessions", session.name, "todo.txt")
|
|
60
|
+
|
|
61
|
+
const requireDirectory = async (directory: string): Promise<void> => {
|
|
62
|
+
const info: Stats = await lstat(directory)
|
|
63
|
+
if (!info.isDirectory()) {
|
|
64
|
+
throw new Error(`${directory} must be a real directory`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const load = Effect.fnUntraced(function*(path: string): Effect.fn.Return<Document, TodoError> {
|
|
69
|
+
const text: string = yield* io(`Cannot read ${path}`, async (): Promise<string> => {
|
|
70
|
+
try {
|
|
71
|
+
const info: Stats = await lstat(path)
|
|
72
|
+
if (!info.isFile()) {
|
|
73
|
+
throw new Error("todo.txt must be a regular file")
|
|
74
|
+
}
|
|
75
|
+
return await readFile(path, "utf8")
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (missing(error)) return ""
|
|
78
|
+
throw error
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
return yield* parse(text)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const checkDirectory = Effect.fnUntraced(function*(
|
|
85
|
+
session: SessionDirectory,
|
|
86
|
+
): Effect.fn.Return<void, TodoError> {
|
|
87
|
+
yield* io("Cannot access session directory", async (): Promise<void> => {
|
|
88
|
+
let directory: string = await realpath(session.directory)
|
|
89
|
+
for (const segment of [".agents", "sessions", session.name]) {
|
|
90
|
+
directory = join(directory, segment)
|
|
91
|
+
try {
|
|
92
|
+
await requireDirectory(directory)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (missing(error)) return
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
const ensureDirectory = Effect.fnUntraced(function*(
|
|
102
|
+
session: SessionDirectory,
|
|
103
|
+
): Effect.fn.Return<void, TodoError> {
|
|
104
|
+
yield* io("Cannot prepare session directory", async (): Promise<void> => {
|
|
105
|
+
let directory: string = await realpath(session.directory)
|
|
106
|
+
for (const segment of [".agents", "sessions", session.name]) {
|
|
107
|
+
directory = join(directory, segment)
|
|
108
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
109
|
+
await requireDirectory(directory)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
export const makeTodoStore: Effect.Effect<TodoStore> = Effect.gen(
|
|
115
|
+
function*(): Effect.fn.Return<TodoStore> {
|
|
116
|
+
const lock: Semaphore.Semaphore = yield* Semaphore.make(1)
|
|
117
|
+
const read: TodoStore["read"] = Effect.fnUntraced(function*(
|
|
118
|
+
session: SessionDirectory,
|
|
119
|
+
): Effect.fn.Return<Snapshot, TodoError> {
|
|
120
|
+
const path: string = pathFor(session)
|
|
121
|
+
yield* checkDirectory(session)
|
|
122
|
+
const document: Document = yield* load(path)
|
|
123
|
+
return { path, todos: document.todos }
|
|
124
|
+
})
|
|
125
|
+
const write: TodoStore["write"] = (
|
|
126
|
+
session,
|
|
127
|
+
todos,
|
|
128
|
+
expected,
|
|
129
|
+
): Effect.Effect<WriteResult, TodoError> =>
|
|
130
|
+
lock.withPermit(
|
|
131
|
+
Effect.uninterruptible(
|
|
132
|
+
Effect.gen(function*(): Effect.fn.Return<WriteResult, TodoError> {
|
|
133
|
+
const path: string = pathFor(session)
|
|
134
|
+
yield* ensureDirectory(session)
|
|
135
|
+
const previous: Document = yield* load(path)
|
|
136
|
+
if (expected !== undefined && !sameTodos(expected, previous.todos)) {
|
|
137
|
+
return yield* new TodoError({
|
|
138
|
+
message:
|
|
139
|
+
"The stored todo list does not match the `previous` list you sent. Use todoread, then retry todowrite with the refreshed `previous` list.",
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
const temporary: string = `${path}.${randomUUID()}.tmp`
|
|
143
|
+
const today: string = DateTime.formatIsoDateUtc(yield* DateTime.now)
|
|
144
|
+
yield* io(`Cannot write ${path}`, async (): Promise<void> => {
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(temporary, render(todos, previous.records, today), {
|
|
147
|
+
encoding: "utf8",
|
|
148
|
+
mode: 0o600,
|
|
149
|
+
flag: "wx",
|
|
150
|
+
})
|
|
151
|
+
await rename(temporary, path)
|
|
152
|
+
} finally {
|
|
153
|
+
await rm(temporary, { force: true })
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
return { path, todos, previous: previous.todos }
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
return { read, write }
|
|
161
|
+
},
|
|
162
|
+
)
|
package/src/todotxt.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect"
|
|
2
|
+
import { Todo, TodoError, Todos } from "./model.ts"
|
|
3
|
+
|
|
4
|
+
export type Record =
|
|
5
|
+
& Pick<Todo, "content" | "priority">
|
|
6
|
+
& { readonly created: string | undefined }
|
|
7
|
+
& (
|
|
8
|
+
| { readonly status: "pending" | "in_progress" }
|
|
9
|
+
| {
|
|
10
|
+
readonly status: "completed" | "cancelled"
|
|
11
|
+
readonly completed: string | undefined
|
|
12
|
+
}
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
const Letters = Schema.Literals(["A", "B", "C"])
|
|
16
|
+
type Letter = typeof Letters.Type
|
|
17
|
+
const priorities: Readonly<{ A: "high"; B: "medium"; C: "low" }> = {
|
|
18
|
+
A: "high",
|
|
19
|
+
B: "medium",
|
|
20
|
+
C: "low",
|
|
21
|
+
}
|
|
22
|
+
const letters: Readonly<{ high: "A"; medium: "B"; low: "C" }> = {
|
|
23
|
+
high: "A",
|
|
24
|
+
medium: "B",
|
|
25
|
+
low: "C",
|
|
26
|
+
}
|
|
27
|
+
const STATUS_PREFIX: string = "status:"
|
|
28
|
+
const PRIORITY_PREFIX: string = "pri:"
|
|
29
|
+
|
|
30
|
+
export interface Document {
|
|
31
|
+
readonly records: readonly Record[]
|
|
32
|
+
readonly todos: Todos
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const decodeLetter = (value: unknown): Effect.Effect<Letter, TodoError> =>
|
|
36
|
+
Schema.decodeUnknownEffect(Letters)(value).pipe(
|
|
37
|
+
Effect.mapError(
|
|
38
|
+
(): TodoError => new TodoError({ message: "Supported todo.txt priorities are A, B, and C" }),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const parseLine = Effect.fnUntraced(function*(line: string): Effect.fn.Return<Record, TodoError> {
|
|
43
|
+
const closed: boolean = line.startsWith("x ")
|
|
44
|
+
let body: string = closed ? line.slice(2) : line
|
|
45
|
+
let letter: Letter | undefined
|
|
46
|
+
const priority: RegExpMatchArray | null = body.match(/^\(([A-Z])\) /)
|
|
47
|
+
if (priority) {
|
|
48
|
+
letter = yield* decodeLetter(priority[1])
|
|
49
|
+
body = body.slice(priority[0].length)
|
|
50
|
+
}
|
|
51
|
+
const datePrefix: RegExpMatchArray | null = body.match(
|
|
52
|
+
closed ? /^\d{4}-\d{2}-\d{2} (?:\d{4}-\d{2}-\d{2} )?/ : /^\d{4}-\d{2}-\d{2} /,
|
|
53
|
+
)
|
|
54
|
+
const dates: string = datePrefix?.[0] ?? ""
|
|
55
|
+
body = body.slice(dates.length)
|
|
56
|
+
let statusToken: string | undefined
|
|
57
|
+
const words: string[] = []
|
|
58
|
+
for (const word of body.split(" ")) {
|
|
59
|
+
if (word.startsWith(STATUS_PREFIX)) {
|
|
60
|
+
if (statusToken !== undefined) {
|
|
61
|
+
return yield* new TodoError({ message: "Duplicate status: token" })
|
|
62
|
+
}
|
|
63
|
+
statusToken = word.slice(STATUS_PREFIX.length)
|
|
64
|
+
} else if (word.startsWith(PRIORITY_PREFIX)) {
|
|
65
|
+
if (letter !== undefined) return yield* new TodoError({ message: "Duplicate priority" })
|
|
66
|
+
letter = yield* decodeLetter(word.slice(PRIORITY_PREFIX.length))
|
|
67
|
+
} else {
|
|
68
|
+
words.push(word)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const status: string = statusToken ?? (closed ? "completed" : "pending")
|
|
72
|
+
if (closed !== (status === "completed" || status === "cancelled")) {
|
|
73
|
+
return yield* new TodoError({ message: "The x marker and status: token disagree" })
|
|
74
|
+
}
|
|
75
|
+
const todo: Todo = yield* Schema.decodeUnknownEffect(Todo)({
|
|
76
|
+
content: words.join(" ").trim(),
|
|
77
|
+
status,
|
|
78
|
+
priority: priorities[letter ?? "B"],
|
|
79
|
+
}).pipe(Effect.mapError((error): TodoError => new TodoError({ message: error.message })))
|
|
80
|
+
const [first, second] = dates.trim().split(" ")
|
|
81
|
+
switch (todo.status) {
|
|
82
|
+
case "pending":
|
|
83
|
+
case "in_progress":
|
|
84
|
+
return { ...todo, status: todo.status, created: dates ? first : undefined }
|
|
85
|
+
case "completed":
|
|
86
|
+
case "cancelled":
|
|
87
|
+
return { ...todo, status: todo.status, completed: dates ? first : undefined, created: second }
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
export const parse = Effect.fnUntraced(function*(
|
|
92
|
+
text: string,
|
|
93
|
+
): Effect.fn.Return<Document, TodoError> {
|
|
94
|
+
const records: Record[] = []
|
|
95
|
+
let lineNumber: number = 0
|
|
96
|
+
for (const line of text.replace(/^\uFEFF/, "").split(/\r?\n/)) {
|
|
97
|
+
lineNumber += 1
|
|
98
|
+
if (!line.trim()) continue
|
|
99
|
+
const record: Record = yield* parseLine(line).pipe(
|
|
100
|
+
Effect.mapError(
|
|
101
|
+
(error): TodoError =>
|
|
102
|
+
new TodoError({ message: `todo.txt line ${lineNumber}: ${error.message}` }),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
records.push(record)
|
|
106
|
+
}
|
|
107
|
+
const todos: Todos = yield* Schema.decodeUnknownEffect(Todos)(records).pipe(
|
|
108
|
+
Effect.mapError((error): TodoError => new TodoError({ message: error.message })),
|
|
109
|
+
)
|
|
110
|
+
return { records, todos }
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const renderLine = (todo: Todo, old: Record | undefined, today: string): string => {
|
|
114
|
+
const created: string | undefined = old?.created
|
|
115
|
+
const openBody: string = created ? `${created} ${todo.content}` : todo.content
|
|
116
|
+
const letter: Letter = letters[todo.priority]
|
|
117
|
+
switch (todo.status) {
|
|
118
|
+
case "pending":
|
|
119
|
+
return `(${letter}) ${openBody}\n`
|
|
120
|
+
case "in_progress":
|
|
121
|
+
return `(${letter}) ${openBody} status:in_progress\n`
|
|
122
|
+
case "completed":
|
|
123
|
+
case "cancelled": {
|
|
124
|
+
const completed: string | undefined =
|
|
125
|
+
old?.status === "completed" || old?.status === "cancelled"
|
|
126
|
+
? old.completed
|
|
127
|
+
: created
|
|
128
|
+
? today
|
|
129
|
+
: undefined
|
|
130
|
+
const closedBody: string = completed ? `${completed} ${openBody}` : todo.content
|
|
131
|
+
const status: string = todo.status === "cancelled" ? " status:cancelled" : ""
|
|
132
|
+
return `x ${closedBody} pri:${letter}${status}\n`
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const render = (todos: Todos, previous: readonly Record[], today: string): string => {
|
|
138
|
+
const records: Map<Todo["content"], Record[]> = previous.reduceRight(
|
|
139
|
+
(grouped: Map<Todo["content"], Record[]>, record: Record): Map<Todo["content"], Record[]> => {
|
|
140
|
+
const existing: Record[] | undefined = grouped.get(record.content)
|
|
141
|
+
if (existing) existing.push(record)
|
|
142
|
+
else grouped.set(record.content, [record])
|
|
143
|
+
return grouped
|
|
144
|
+
},
|
|
145
|
+
new Map<Todo["content"], Record[]>(),
|
|
146
|
+
)
|
|
147
|
+
return todos.map(
|
|
148
|
+
(todo: Todo): string => renderLine(todo, records.get(todo.content)?.pop(), today),
|
|
149
|
+
).join("")
|
|
150
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
2
|
+
import type { KeymapLayer, PanelInput } from "@opencode/plugin/tui/context"
|
|
3
|
+
import { Session } from "@opencode/schema/session"
|
|
4
|
+
import type { ResolvedTheme } from "@opencode/theme/tui"
|
|
5
|
+
import type { MouseEvent } from "@opentui/core"
|
|
6
|
+
import type { JSX } from "@opentui/solid"
|
|
7
|
+
import { Effect, Result, Schema } from "effect"
|
|
8
|
+
import {
|
|
9
|
+
type Accessor,
|
|
10
|
+
createEffect,
|
|
11
|
+
createMemo,
|
|
12
|
+
createSignal,
|
|
13
|
+
For,
|
|
14
|
+
onCleanup,
|
|
15
|
+
Show,
|
|
16
|
+
} from "solid-js"
|
|
17
|
+
import { TodoRpc } from "./rpc.ts"
|
|
18
|
+
import { type Snapshot, type Todo } from "./src/model.ts"
|
|
19
|
+
import { projectTodos, SidebarOptions, type TodoProjection } from "./src/sidebar.ts"
|
|
20
|
+
|
|
21
|
+
type View =
|
|
22
|
+
| { readonly status: "loading" }
|
|
23
|
+
| { readonly status: "ready"; readonly snapshot: Snapshot }
|
|
24
|
+
| { readonly status: "error"; readonly message: string }
|
|
25
|
+
|
|
26
|
+
interface Props {
|
|
27
|
+
readonly sessionID: string
|
|
28
|
+
readonly full?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const symbols: Readonly<Record<Todo["status"], string>> = {
|
|
32
|
+
pending: "[ ]",
|
|
33
|
+
in_progress: "[•]",
|
|
34
|
+
completed: "[✓]",
|
|
35
|
+
cancelled: "[–]",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const colorFor = (
|
|
39
|
+
status: Todo["status"],
|
|
40
|
+
theme: ResolvedTheme,
|
|
41
|
+
): ResolvedTheme["text"]["base"] => {
|
|
42
|
+
switch (status) {
|
|
43
|
+
case "pending":
|
|
44
|
+
return theme.text.base
|
|
45
|
+
case "in_progress":
|
|
46
|
+
return theme.text.feedback.warning.base
|
|
47
|
+
case "completed":
|
|
48
|
+
return theme.text.muted
|
|
49
|
+
case "cancelled":
|
|
50
|
+
return theme.text.feedback.error.base
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const Message = Schema.Struct({ message: Schema.String })
|
|
55
|
+
const messageFor = (error: unknown): string =>
|
|
56
|
+
Schema.is(Message)(error) ? error.message : String(error)
|
|
57
|
+
|
|
58
|
+
export default Plugin.define({
|
|
59
|
+
id: "session-todo",
|
|
60
|
+
setup(ctx): () => void {
|
|
61
|
+
const options = Schema.decodeUnknownResult(SidebarOptions)(ctx.options)
|
|
62
|
+
if (Result.isFailure(options)) {
|
|
63
|
+
ctx.ui.toast.show({
|
|
64
|
+
title: "Invalid TODO sidebar options",
|
|
65
|
+
message: options.failure.message,
|
|
66
|
+
variant: "error",
|
|
67
|
+
})
|
|
68
|
+
return (): void => {}
|
|
69
|
+
}
|
|
70
|
+
const api = ctx.client.rpc(TodoRpc)
|
|
71
|
+
const [sidebarView, updateSidebarView] = ctx.storage.store("sidebar", {
|
|
72
|
+
initial: { open: true },
|
|
73
|
+
})
|
|
74
|
+
const notify: () => void = api.events.on("updated", (event): void => {
|
|
75
|
+
if (event.data.diff === "") return
|
|
76
|
+
ctx.ui.toast.show({
|
|
77
|
+
title: "TODO updated",
|
|
78
|
+
message: event.data.diff,
|
|
79
|
+
variant: "info",
|
|
80
|
+
sessionID: event.data.sessionID,
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
const List = (props: Props): JSX.Element => {
|
|
84
|
+
const [view, setView] = createSignal<View>({ status: "loading" })
|
|
85
|
+
createEffect((): void => {
|
|
86
|
+
const sessionID: string = props.sessionID
|
|
87
|
+
setView({ status: "loading" })
|
|
88
|
+
const controller: AbortController = new AbortController()
|
|
89
|
+
let running: boolean = false
|
|
90
|
+
const refresh = (): void => {
|
|
91
|
+
if (running || controller.signal.aborted) return
|
|
92
|
+
running = true
|
|
93
|
+
const request: Effect.Effect<Snapshot, unknown> = Schema.decodeUnknownEffect(Session.ID)(
|
|
94
|
+
sessionID,
|
|
95
|
+
).pipe(
|
|
96
|
+
Effect.flatMap(
|
|
97
|
+
(id): Effect.Effect<Snapshot, unknown> =>
|
|
98
|
+
Effect.tryPromise({
|
|
99
|
+
try: (): Promise<Snapshot> =>
|
|
100
|
+
api.read(
|
|
101
|
+
{ sessionID: id },
|
|
102
|
+
{
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
location: ctx.data.session.get(sessionID)?.location
|
|
105
|
+
?? ctx.location
|
|
106
|
+
?? ctx.data.location.default(),
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
catch: (error): unknown => error,
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
void Effect.runPromise(
|
|
114
|
+
request.pipe(
|
|
115
|
+
Effect.match({
|
|
116
|
+
onSuccess: (snapshot): View => ({ status: "ready", snapshot }),
|
|
117
|
+
onFailure: (error): View => ({ status: "error", message: messageFor(error) }),
|
|
118
|
+
}),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
.then((next): void => {
|
|
122
|
+
if (!controller.signal.aborted) setView(next)
|
|
123
|
+
})
|
|
124
|
+
.catch((error: unknown): void => {
|
|
125
|
+
if (!controller.signal.aborted) {
|
|
126
|
+
setView({ status: "error", message: messageFor(error) })
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
.finally((): void => {
|
|
130
|
+
running = false
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
refresh()
|
|
134
|
+
const timer: ReturnType<typeof setInterval> = setInterval(refresh, 2000)
|
|
135
|
+
const unsubscribe: () => void = api.events.on("updated", (event): void => {
|
|
136
|
+
if (event.data.sessionID === sessionID) refresh()
|
|
137
|
+
})
|
|
138
|
+
onCleanup((): void => {
|
|
139
|
+
controller.abort()
|
|
140
|
+
clearInterval(timer)
|
|
141
|
+
unsubscribe()
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const snapshot: Accessor<Snapshot | undefined> = (): Snapshot | undefined => {
|
|
146
|
+
const current: View = view()
|
|
147
|
+
return current.status === "ready" ? current.snapshot : undefined
|
|
148
|
+
}
|
|
149
|
+
const projection: Accessor<TodoProjection | undefined> = createMemo(
|
|
150
|
+
(): TodoProjection | undefined => {
|
|
151
|
+
const current: Snapshot | undefined = snapshot()
|
|
152
|
+
return current === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: projectTodos(current.todos, props.full ? "full" : options.success.sidebarLimit)
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
const todos: Accessor<readonly Todo[]> = (): readonly Todo[] => projection()?.items ?? []
|
|
158
|
+
const active: Accessor<number> = (): number => projection()?.remaining ?? 0
|
|
159
|
+
const more: Accessor<number | undefined> = (): number | undefined => {
|
|
160
|
+
const current: TodoProjection | undefined = projection()
|
|
161
|
+
return current?.kind === "limited" ? current.total : undefined
|
|
162
|
+
}
|
|
163
|
+
const error: Accessor<string | undefined> = (): string | undefined => {
|
|
164
|
+
const current: View = view()
|
|
165
|
+
return current.status === "error" ? current.message : undefined
|
|
166
|
+
}
|
|
167
|
+
const visible: Accessor<boolean> = (): boolean =>
|
|
168
|
+
Boolean(props.full || snapshot()?.todos.length || error())
|
|
169
|
+
return (
|
|
170
|
+
<Show when={visible()}>
|
|
171
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
172
|
+
<box
|
|
173
|
+
flexDirection="row"
|
|
174
|
+
gap={1}
|
|
175
|
+
onMouseDown={(event: MouseEvent): void => {
|
|
176
|
+
if (props.full) return
|
|
177
|
+
event.stopPropagation()
|
|
178
|
+
void updateSidebarView((draft): void => {
|
|
179
|
+
draft.open = !draft.open
|
|
180
|
+
}).catch((error: unknown): void => {
|
|
181
|
+
ctx.ui.toast.show({
|
|
182
|
+
title: "Todo sidebar",
|
|
183
|
+
message: `Cannot save expanded state: ${messageFor(error)}`,
|
|
184
|
+
variant: "error",
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
<Show when={!props.full}>
|
|
190
|
+
<text fg={ctx.theme.text.base}>{sidebarView.open ? "▼" : "▶"}</text>
|
|
191
|
+
</Show>
|
|
192
|
+
<text fg={ctx.theme.text.base}>
|
|
193
|
+
<b>TODO</b>
|
|
194
|
+
<span style={{ fg: ctx.theme.text.muted }}>{" "}({active()} remaining)</span>
|
|
195
|
+
</text>
|
|
196
|
+
</box>
|
|
197
|
+
<Show when={props.full || sidebarView.open}>
|
|
198
|
+
<Show when={props.full}>
|
|
199
|
+
<text fg={ctx.theme.text.muted}>{snapshot()?.path ?? "Loading todo.txt…"}</text>
|
|
200
|
+
</Show>
|
|
201
|
+
<Show when={error()}>
|
|
202
|
+
<text fg={ctx.theme.text.feedback.error.base}>Cannot read todos: {error()}</text>
|
|
203
|
+
</Show>
|
|
204
|
+
<Show when={props.full && view().status === "ready" && todos().length === 0}>
|
|
205
|
+
<text fg={ctx.theme.text.muted}>No todos for this session.</text>
|
|
206
|
+
</Show>
|
|
207
|
+
<For each={todos()}>
|
|
208
|
+
{(todo): JSX.Element => (
|
|
209
|
+
<text fg={colorFor(todo.status, ctx.theme)}>
|
|
210
|
+
{symbols[todo.status]} {todo.content} · {todo.priority}
|
|
211
|
+
</text>
|
|
212
|
+
)}
|
|
213
|
+
</For>
|
|
214
|
+
<Show when={more()}>
|
|
215
|
+
<text
|
|
216
|
+
fg={ctx.theme.text.feedback.info.base}
|
|
217
|
+
onMouseDown={(event: MouseEvent): void => {
|
|
218
|
+
event.stopPropagation()
|
|
219
|
+
ctx.ui.panel.open("session-todo.list")
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
Show all {more()} todos →
|
|
223
|
+
</text>
|
|
224
|
+
</Show>
|
|
225
|
+
</Show>
|
|
226
|
+
</box>
|
|
227
|
+
</Show>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const sidebar: () => void = ctx.ui.slot({
|
|
232
|
+
append: "sidebar.content",
|
|
233
|
+
render: (props): JSX.Element => <List sessionID={props.sessionID} />,
|
|
234
|
+
})
|
|
235
|
+
const Panel = (props: PanelInput): JSX.Element => {
|
|
236
|
+
ctx.keymap.layer(
|
|
237
|
+
(): KeymapLayer => ({
|
|
238
|
+
mode: "global",
|
|
239
|
+
enabled: props.focused,
|
|
240
|
+
commands: [
|
|
241
|
+
{
|
|
242
|
+
bind: "escape",
|
|
243
|
+
title: "Close session todos",
|
|
244
|
+
run: props.close,
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
return (
|
|
250
|
+
<box flexDirection="column" flexGrow={1} padding={1}>
|
|
251
|
+
<text
|
|
252
|
+
fg={ctx.theme.text.base}
|
|
253
|
+
onMouseDown={(event: MouseEvent): void => {
|
|
254
|
+
event.stopPropagation()
|
|
255
|
+
props.close()
|
|
256
|
+
}}
|
|
257
|
+
>
|
|
258
|
+
Close × · Esc when focused · Ctrl+P to toggle
|
|
259
|
+
</text>
|
|
260
|
+
<scrollbox flexGrow={1}>
|
|
261
|
+
<List sessionID={props.sessionID} full />
|
|
262
|
+
</scrollbox>
|
|
263
|
+
</box>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
const panel: () => void = ctx.ui.slot({
|
|
267
|
+
append: "session.panel",
|
|
268
|
+
render: (props): JSX.Element => (
|
|
269
|
+
<Show when={props.name === "session-todo.list"}>
|
|
270
|
+
<Panel {...props} />
|
|
271
|
+
</Show>
|
|
272
|
+
),
|
|
273
|
+
})
|
|
274
|
+
ctx.keymap.layer(
|
|
275
|
+
(): KeymapLayer => ({
|
|
276
|
+
mode: "global",
|
|
277
|
+
commands: [
|
|
278
|
+
{
|
|
279
|
+
id: "session-todo.list",
|
|
280
|
+
title: "Toggle session todos",
|
|
281
|
+
group: "Session",
|
|
282
|
+
palette: true,
|
|
283
|
+
slash: { name: "todos" },
|
|
284
|
+
enabled: ctx.ui.router.current().type === "session",
|
|
285
|
+
run: (): void => {
|
|
286
|
+
if (ctx.ui.panel.current()?.name === "session-todo.list") ctx.ui.panel.close()
|
|
287
|
+
else ctx.ui.panel.open("session-todo.list")
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
return (): void => {
|
|
294
|
+
sidebar()
|
|
295
|
+
panel()
|
|
296
|
+
notify()
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
})
|