opencode-session-todo 0.1.0 → 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/dist/tui.js ADDED
@@ -0,0 +1,433 @@
1
+ // @bun
2
+ // tui.tsx
3
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
4
+ import { insertNode as _$insertNode } from "@opentui/solid";
5
+ import { createComponent as _$createComponent } from "@opentui/solid";
6
+ import { effect as _$effect } from "@opentui/solid";
7
+ import { insert as _$insert } from "@opentui/solid";
8
+ import { memo as _$memo } from "@opentui/solid";
9
+ import { setProp as _$setProp } from "@opentui/solid";
10
+ import { createElement as _$createElement } from "@opentui/solid";
11
+ import { Plugin } from "@opencode/plugin/tui";
12
+ import { Session as Session2 } from "@opencode/schema/session";
13
+ import { Effect as Effect2, Result, Schema as Schema4 } from "effect";
14
+ import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
15
+
16
+ // rpc.ts
17
+ import { Rpc } from "@opencode/schema/rpc";
18
+ import { Session } from "@opencode/schema/session";
19
+ import { Schema as Schema2 } from "effect";
20
+
21
+ // src/model.ts
22
+ import { Schema } from "effect";
23
+ var Content = Schema.String.check(Schema.isMinLength(1), Schema.isTrimmed(), Schema.isPattern(/^[^\r\n\x00]+$/), Schema.makeFilter((value) => !/^(?:\([A-Z]\) |\d{4}-\d{2}-\d{2}(?: |$))/.test(value) && !/(?:^|\s)(?:status|pri):/.test(value), {
24
+ message: "Task text must not start with a priority/date or contain reserved status:/pri: tokens"
25
+ })).pipe(Schema.brand("TodoContent"));
26
+ var Status = Schema.Literals(["pending", "in_progress", "completed", "cancelled"]);
27
+ var Priority = Schema.Literals(["high", "medium", "low"]);
28
+ var Todo = Schema.Struct({ content: Content, status: Status, priority: Priority });
29
+ var Todos = Schema.Array(Todo).check(Schema.makeFilter((todos) => {
30
+ let active = false;
31
+ for (const todo of todos) {
32
+ if (todo.status !== "in_progress")
33
+ continue;
34
+ if (active)
35
+ return false;
36
+ active = true;
37
+ }
38
+ return true;
39
+ }, { message: "At most one todo may be in_progress" })).pipe(Schema.brand("TodoList"));
40
+ var SessionName = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9][A-Za-z0-9_-]*$/)).pipe(Schema.brand("TodoSessionName"));
41
+ var Snapshot = Schema.Struct({ path: Schema.String, todos: Todos });
42
+
43
+ class TodoError extends Schema.TaggedError()("TodoError", {
44
+ message: Schema.String
45
+ }) {
46
+ }
47
+
48
+ // rpc.ts
49
+ var TodoRpc = Rpc.define({
50
+ id: "session-todo",
51
+ methods: {
52
+ read: {
53
+ input: Schema2.toStandardSchemaV1(Schema2.Struct({ sessionID: Session.ID })),
54
+ output: Schema2.toStandardSchemaV1(Snapshot),
55
+ errors: { storage: Schema2.toStandardSchemaV1(Schema2.Struct({ message: Schema2.String })) }
56
+ }
57
+ },
58
+ events: {
59
+ updated: {
60
+ schema: Schema2.toStandardSchemaV1(Schema2.Struct({ sessionID: Session.ID, diff: Schema2.String }))
61
+ }
62
+ }
63
+ });
64
+
65
+ // src/sidebar.ts
66
+ import { Effect, Schema as Schema3 } from "effect";
67
+ var SidebarLimit = Schema3.Int.check(Schema3.isGreaterThan(0)).pipe(Schema3.brand("TodoSidebarLimit"));
68
+ var SidebarOptions = Schema3.Struct({
69
+ sidebarLimit: SidebarLimit.pipe(Schema3.withDecodingDefaultKey(Effect.succeed(5)))
70
+ });
71
+ var incomplete = (todo) => todo.status === "pending" || todo.status === "in_progress";
72
+ var projectTodos = (todos, mode) => {
73
+ if (mode === "full" || todos.length <= mode) {
74
+ return {
75
+ kind: "all",
76
+ items: todos,
77
+ remaining: todos.reduce((count, todo) => count + Number(incomplete(todo)), 0)
78
+ };
79
+ }
80
+ const items = [];
81
+ let remaining = 0;
82
+ for (const todo of todos) {
83
+ if (!incomplete(todo))
84
+ continue;
85
+ remaining += 1;
86
+ if (todo.status === "in_progress") {
87
+ if (items.length === mode)
88
+ items.pop();
89
+ items.unshift(todo);
90
+ } else if (items.length < mode)
91
+ items.push(todo);
92
+ }
93
+ return { kind: "limited", items, remaining, total: todos.length };
94
+ };
95
+
96
+ // tui.tsx
97
+ var symbols = {
98
+ pending: "[ ]",
99
+ in_progress: "[\u2022]",
100
+ completed: "[\u2713]",
101
+ cancelled: "[\u2013]"
102
+ };
103
+ var colorFor = (status, theme) => {
104
+ switch (status) {
105
+ case "pending":
106
+ return theme.text.base;
107
+ case "in_progress":
108
+ return theme.text.feedback.warning.base;
109
+ case "completed":
110
+ return theme.text.muted;
111
+ case "cancelled":
112
+ return theme.text.feedback.error.base;
113
+ }
114
+ };
115
+ var Message = Schema4.Struct({
116
+ message: Schema4.String
117
+ });
118
+ var messageFor = (error) => Schema4.is(Message)(error) ? error.message : String(error);
119
+ var tui_default = Plugin.define({
120
+ id: "session-todo",
121
+ setup(ctx) {
122
+ const options = Schema4.decodeUnknownResult(SidebarOptions)(ctx.options);
123
+ if (Result.isFailure(options)) {
124
+ ctx.ui.toast.show({
125
+ title: "Invalid TODO sidebar options",
126
+ message: options.failure.message,
127
+ variant: "error"
128
+ });
129
+ return () => {};
130
+ }
131
+ const api = ctx.client.rpc(TodoRpc);
132
+ const [sidebarView, updateSidebarView] = ctx.storage.store("sidebar", {
133
+ initial: {
134
+ open: true
135
+ }
136
+ });
137
+ const notify = api.events.on("updated", (event) => {
138
+ if (event.data.diff === "")
139
+ return;
140
+ ctx.ui.toast.show({
141
+ title: "TODO updated",
142
+ message: event.data.diff,
143
+ variant: "info",
144
+ sessionID: event.data.sessionID
145
+ });
146
+ });
147
+ const List = (props) => {
148
+ const [view, setView] = createSignal({
149
+ status: "loading"
150
+ });
151
+ createEffect(() => {
152
+ const sessionID = props.sessionID;
153
+ setView({
154
+ status: "loading"
155
+ });
156
+ const controller = new AbortController;
157
+ let running = false;
158
+ const refresh = () => {
159
+ if (running || controller.signal.aborted)
160
+ return;
161
+ running = true;
162
+ const request = Schema4.decodeUnknownEffect(Session2.ID)(sessionID).pipe(Effect2.flatMap((id) => Effect2.tryPromise({
163
+ try: () => api.read({
164
+ sessionID: id
165
+ }, {
166
+ signal: controller.signal,
167
+ location: ctx.data.session.get(sessionID)?.location ?? ctx.location ?? ctx.data.location.default()
168
+ }),
169
+ catch: (error) => error
170
+ })));
171
+ Effect2.runPromise(request.pipe(Effect2.match({
172
+ onSuccess: (snapshot) => ({
173
+ status: "ready",
174
+ snapshot
175
+ }),
176
+ onFailure: (error) => ({
177
+ status: "error",
178
+ message: messageFor(error)
179
+ })
180
+ }))).then((next) => {
181
+ if (!controller.signal.aborted)
182
+ setView(next);
183
+ }).catch((error) => {
184
+ if (!controller.signal.aborted) {
185
+ setView({
186
+ status: "error",
187
+ message: messageFor(error)
188
+ });
189
+ }
190
+ }).finally(() => {
191
+ running = false;
192
+ });
193
+ };
194
+ refresh();
195
+ const timer = setInterval(refresh, 2000);
196
+ const unsubscribe = api.events.on("updated", (event) => {
197
+ if (event.data.sessionID === sessionID)
198
+ refresh();
199
+ });
200
+ onCleanup(() => {
201
+ controller.abort();
202
+ clearInterval(timer);
203
+ unsubscribe();
204
+ });
205
+ });
206
+ const snapshot = () => {
207
+ const current = view();
208
+ return current.status === "ready" ? current.snapshot : undefined;
209
+ };
210
+ const projection = createMemo(() => {
211
+ const current = snapshot();
212
+ return current === undefined ? undefined : projectTodos(current.todos, props.full ? "full" : options.success.sidebarLimit);
213
+ });
214
+ const todos = () => projection()?.items ?? [];
215
+ const active = () => projection()?.remaining ?? 0;
216
+ const more = () => {
217
+ const current = projection();
218
+ return current?.kind === "limited" ? current.total : undefined;
219
+ };
220
+ const error = () => {
221
+ const current = view();
222
+ return current.status === "error" ? current.message : undefined;
223
+ };
224
+ const visible = () => Boolean(props.full || snapshot()?.todos.length || error());
225
+ return _$createComponent(Show, {
226
+ get when() {
227
+ return visible();
228
+ },
229
+ get children() {
230
+ var _el$ = _$createElement("box"), _el$2 = _$createElement("box"), _el$4 = _$createElement("text"), _el$5 = _$createElement("b"), _el$7 = _$createElement("span"), _el$8 = _$createTextNode(` (`), _el$0 = _$createTextNode(` remaining)`);
231
+ _$insertNode(_el$, _el$2);
232
+ _$setProp(_el$, "flexDirection", "column");
233
+ _$setProp(_el$, "gap", 0);
234
+ _$setProp(_el$, "paddingTop", 1);
235
+ _$insertNode(_el$2, _el$4);
236
+ _$setProp(_el$2, "flexDirection", "row");
237
+ _$setProp(_el$2, "gap", 1);
238
+ _$setProp(_el$2, "onMouseDown", (event) => {
239
+ if (props.full)
240
+ return;
241
+ event.stopPropagation();
242
+ updateSidebarView((draft) => {
243
+ draft.open = !draft.open;
244
+ }).catch((error) => {
245
+ ctx.ui.toast.show({
246
+ title: "Todo sidebar",
247
+ message: `Cannot save expanded state: ${messageFor(error)}`,
248
+ variant: "error"
249
+ });
250
+ });
251
+ });
252
+ _$insert(_el$2, _$createComponent(Show, {
253
+ get when() {
254
+ return !props.full;
255
+ },
256
+ get children() {
257
+ var _el$3 = _$createElement("text");
258
+ _$insert(_el$3, () => sidebarView.open ? "\u25BC" : "\u25B6");
259
+ _$effect((_$p) => _$setProp(_el$3, "fg", ctx.theme.text.base, _$p));
260
+ return _el$3;
261
+ }
262
+ }), _el$4);
263
+ _$insertNode(_el$4, _el$5);
264
+ _$insertNode(_el$4, _el$7);
265
+ _$insertNode(_el$5, _$createTextNode(`TODO`));
266
+ _$insertNode(_el$7, _el$8);
267
+ _$insertNode(_el$7, _el$0);
268
+ _$insert(_el$7, active, _el$0);
269
+ _$insert(_el$, _$createComponent(Show, {
270
+ get when() {
271
+ return props.full || sidebarView.open;
272
+ },
273
+ get children() {
274
+ return [_$createComponent(Show, {
275
+ get when() {
276
+ return props.full;
277
+ },
278
+ get children() {
279
+ var _el$1 = _$createElement("text");
280
+ _$insert(_el$1, () => snapshot()?.path ?? "Loading todo.txt\u2026");
281
+ _$effect((_$p) => _$setProp(_el$1, "fg", ctx.theme.text.muted, _$p));
282
+ return _el$1;
283
+ }
284
+ }), _$createComponent(Show, {
285
+ get when() {
286
+ return error();
287
+ },
288
+ get children() {
289
+ var _el$10 = _$createElement("text"), _el$11 = _$createTextNode(`Cannot read todos: `);
290
+ _$insertNode(_el$10, _el$11);
291
+ _$insert(_el$10, error, null);
292
+ _$effect((_$p) => _$setProp(_el$10, "fg", ctx.theme.text.feedback.error.base, _$p));
293
+ return _el$10;
294
+ }
295
+ }), _$createComponent(Show, {
296
+ get when() {
297
+ return _$memo(() => !!(props.full && view().status === "ready"))() && todos().length === 0;
298
+ },
299
+ get children() {
300
+ var _el$12 = _$createElement("text");
301
+ _$insertNode(_el$12, _$createTextNode(`No todos for this session.`));
302
+ _$effect((_$p) => _$setProp(_el$12, "fg", ctx.theme.text.muted, _$p));
303
+ return _el$12;
304
+ }
305
+ }), _$createComponent(For, {
306
+ get each() {
307
+ return todos();
308
+ },
309
+ children: (todo) => (() => {
310
+ var _el$17 = _$createElement("text"), _el$18 = _$createTextNode(` `), _el$19 = _$createTextNode(` \xB7 `);
311
+ _$insertNode(_el$17, _el$18);
312
+ _$insertNode(_el$17, _el$19);
313
+ _$insert(_el$17, () => symbols[todo.status], _el$18);
314
+ _$insert(_el$17, () => todo.content, _el$19);
315
+ _$insert(_el$17, () => todo.priority, null);
316
+ _$effect((_$p) => _$setProp(_el$17, "fg", colorFor(todo.status, ctx.theme), _$p));
317
+ return _el$17;
318
+ })()
319
+ }), _$createComponent(Show, {
320
+ get when() {
321
+ return more();
322
+ },
323
+ get children() {
324
+ var _el$14 = _$createElement("text"), _el$15 = _$createTextNode(`Show all `), _el$16 = _$createTextNode(` todos \u2192`);
325
+ _$insertNode(_el$14, _el$15);
326
+ _$insertNode(_el$14, _el$16);
327
+ _$setProp(_el$14, "onMouseDown", (event) => {
328
+ event.stopPropagation();
329
+ ctx.ui.panel.open("session-todo.list");
330
+ });
331
+ _$insert(_el$14, more, _el$16);
332
+ _$effect((_$p) => _$setProp(_el$14, "fg", ctx.theme.text.feedback.info.base, _$p));
333
+ return _el$14;
334
+ }
335
+ })];
336
+ }
337
+ }), null);
338
+ _$effect((_p$) => {
339
+ var _v$ = ctx.theme.text.base, _v$2 = {
340
+ fg: ctx.theme.text.muted
341
+ };
342
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$4, "fg", _v$, _p$.e));
343
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$7, "style", _v$2, _p$.t));
344
+ return _p$;
345
+ }, {
346
+ e: undefined,
347
+ t: undefined
348
+ });
349
+ return _el$;
350
+ }
351
+ });
352
+ };
353
+ const sidebar = ctx.ui.slot({
354
+ append: "sidebar.content",
355
+ render: (props) => _$createComponent(List, {
356
+ get sessionID() {
357
+ return props.sessionID;
358
+ }
359
+ })
360
+ });
361
+ const Panel = (props) => {
362
+ ctx.keymap.layer(() => ({
363
+ mode: "global",
364
+ enabled: props.focused,
365
+ commands: [{
366
+ bind: "escape",
367
+ title: "Close session todos",
368
+ run: props.close
369
+ }]
370
+ }));
371
+ return (() => {
372
+ var _el$20 = _$createElement("box"), _el$21 = _$createElement("text"), _el$23 = _$createElement("scrollbox");
373
+ _$insertNode(_el$20, _el$21);
374
+ _$insertNode(_el$20, _el$23);
375
+ _$setProp(_el$20, "flexDirection", "column");
376
+ _$setProp(_el$20, "flexGrow", 1);
377
+ _$setProp(_el$20, "padding", 1);
378
+ _$insertNode(_el$21, _$createTextNode(`Close \xD7 \xB7 Esc when focused \xB7 Ctrl+P to toggle`));
379
+ _$setProp(_el$21, "onMouseDown", (event) => {
380
+ event.stopPropagation();
381
+ props.close();
382
+ });
383
+ _$setProp(_el$23, "flexGrow", 1);
384
+ _$insert(_el$23, _$createComponent(List, {
385
+ get sessionID() {
386
+ return props.sessionID;
387
+ },
388
+ full: true
389
+ }));
390
+ _$effect((_$p) => _$setProp(_el$21, "fg", ctx.theme.text.base, _$p));
391
+ return _el$20;
392
+ })();
393
+ };
394
+ const panel = ctx.ui.slot({
395
+ append: "session.panel",
396
+ render: (props) => _$createComponent(Show, {
397
+ get when() {
398
+ return props.name === "session-todo.list";
399
+ },
400
+ get children() {
401
+ return _$createComponent(Panel, props);
402
+ }
403
+ })
404
+ });
405
+ ctx.keymap.layer(() => ({
406
+ mode: "global",
407
+ commands: [{
408
+ id: "session-todo.list",
409
+ title: "Toggle session todos",
410
+ group: "Session",
411
+ palette: true,
412
+ slash: {
413
+ name: "todos"
414
+ },
415
+ enabled: ctx.ui.router.current().type === "session",
416
+ run: () => {
417
+ if (ctx.ui.panel.current()?.name === "session-todo.list")
418
+ ctx.ui.panel.close();
419
+ else
420
+ ctx.ui.panel.open("session-todo.list");
421
+ }
422
+ }]
423
+ }));
424
+ return () => {
425
+ sidebar();
426
+ panel();
427
+ notify();
428
+ };
429
+ }
430
+ });
431
+ export {
432
+ tui_default as default
433
+ };
@@ -12,7 +12,7 @@ make test
12
12
 
13
13
  ## Package entry points
14
14
 
15
- The package ships TypeScript sources for OpenCode's Bun runtime; no build step is required.
15
+ The package ships TypeScript sources for OpenCode's Bun runtime and precompiled Solid ESM for the TUI. Run `bun run build` before loading the local package; packing builds the TUI automatically.
16
16
 
17
17
  - `opencode-session-todo`: tools and RPC handlers.
18
18
  - `opencode-session-todo/tui`: sidebar and full panel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-session-todo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Session-local todo.txt tools and TUI for OpenCode V2",
5
5
  "homepage": "https://github.com/alexandru/opencode-todo#readme",
6
6
  "bugs": {
@@ -14,12 +14,13 @@
14
14
  "type": "module",
15
15
  "exports": {
16
16
  ".": "./index.ts",
17
- "./tui": "./tui.tsx",
17
+ "./tui": "./dist/tui.js",
18
18
  "./rpc": "./rpc.ts"
19
19
  },
20
20
  "files": [
21
21
  "index.ts",
22
22
  "tui.tsx",
23
+ "dist",
23
24
  "rpc.ts",
24
25
  "src",
25
26
  "USAGE.md",
@@ -28,10 +29,12 @@
28
29
  "docs/development.md"
29
30
  ],
30
31
  "scripts": {
32
+ "build": "bun run build.ts",
31
33
  "check": "tsc --noEmit",
32
34
  "test": "bun test",
33
35
  "format": "dprint fmt",
34
36
  "format:check": "dprint check",
37
+ "prepack": "bun run build",
35
38
  "prepublishOnly": "bun run check && bun run test && bun run format:check"
36
39
  },
37
40
  "dependencies": {