opencode-session-todo 0.0.0-stage → 0.1.1

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/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
+ })