@gamflaz/opencode-todolist 1.0.12 → 1.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.
@@ -1,5 +1,5 @@
1
1
  import { type TodoStatus } from "../schemas/status.js";
2
- import type { TodoChildern, TodoRootItem } from "../schemas/todo";
2
+ import type { TodoChildren, TodoRootItem } from "../schemas/todo";
3
3
  export interface TodoProgress {
4
4
  completed: number;
5
5
  total: number;
@@ -34,7 +34,7 @@ export declare class TodoTree {
34
34
  private static readonly BAR_UNFILLED;
35
35
  constructor(todos: readonly TodoRootItem[]);
36
36
  static isOpenStatus(status: TodoStatus): boolean;
37
- static calculateProgress(children?: readonly TodoChildern[]): TodoProgress | null;
37
+ static calculateProgress(children?: readonly TodoChildren[]): TodoProgress | null;
38
38
  static renderProgressBar(percent: number, width?: number): {
39
39
  filled: string;
40
40
  unfilled: string;
@@ -56,7 +56,20 @@ export declare class TodoTree {
56
56
  * parent whose children are all completed.
57
57
  */
58
58
  private normalize;
59
- render(): string;
59
+ /**
60
+ * Renders the tree.
61
+ *
62
+ * `options.visibleIds` narrows which rows are *printed* without touching the
63
+ * data, so a root keeps its real `(completed/total)` label even when only one
64
+ * subtask is listed. A scoped read needs exactly that: the root line shows
65
+ * `(2/2)` while a single subtask row is displayed.
66
+ *
67
+ * A root line is printed only when its own id is visible; a child line only
68
+ * when the child's id is visible. Omitting `visibleIds` prints everything.
69
+ */
70
+ render(options?: {
71
+ visibleIds?: readonly number[];
72
+ }): string;
60
73
  private static icon;
61
74
  /** Content with strike-through when completed, prefixed with its id unless it already starts with it. */
62
75
  private static renderLabel;
@@ -159,15 +159,33 @@ export class TodoTree {
159
159
  return { valid: true, data: normalized };
160
160
  }
161
161
  // Rendering
162
- render() {
162
+ /**
163
+ * Renders the tree.
164
+ *
165
+ * `options.visibleIds` narrows which rows are *printed* without touching the
166
+ * data, so a root keeps its real `(completed/total)` label even when only one
167
+ * subtask is listed. A scoped read needs exactly that: the root line shows
168
+ * `(2/2)` while a single subtask row is displayed.
169
+ *
170
+ * A root line is printed only when its own id is visible; a child line only
171
+ * when the child's id is visible. Omitting `visibleIds` prints everything.
172
+ */
173
+ render(options) {
163
174
  const lines = [];
175
+ const visible = options?.visibleIds === undefined
176
+ ? undefined
177
+ : new Set(options.visibleIds);
164
178
  for (const parent of this.todos) {
165
179
  const progress = TodoTree.calculateProgress(parent.children);
166
180
  const progressText = progress ? ` (${progress.label})` : "";
167
- lines.push(`${TodoTree.icon(parent.status)} ${TodoTree.renderLabel(parent, 0)}${progressText}`);
168
- if (parent.note)
169
- lines.push(` ↳ note: ${parent.note}`);
181
+ if (!visible || visible.has(parent.id)) {
182
+ lines.push(`${TodoTree.icon(parent.status)} ${TodoTree.renderLabel(parent, 0)}${progressText}`);
183
+ if (parent.note)
184
+ lines.push(` ↳ note: ${parent.note}`);
185
+ }
170
186
  for (const child of parent.children ?? []) {
187
+ if (visible && !visible.has(child.id))
188
+ continue;
171
189
  lines.push(` ${TodoTree.icon(child.status)} ${TodoTree.renderLabel(child, 1)}`);
172
190
  if (child.note)
173
191
  lines.push(` ↳ note: ${child.note}`);
@@ -74,7 +74,7 @@ export function assertSequentialScope(todos, input) {
74
74
  const targetRootIndex = todos.indexOf(targetRoot);
75
75
  if (targetRootIndex !== firstOpen && !hasError(targetRoot)) {
76
76
  const blocking = todos[firstOpen];
77
- throw new Error(`[manage_todo_list] root ${targetRoot.id} is out of order: finish root ${blocking.id} "${blocking.content}" first (or cancel it). Only a root in 'error' may be fixed early.`);
77
+ throw new Error(`root ${targetRoot.id} is out of order: finish root ${blocking.id} "${blocking.content}" first (or cancel it). Only a root in 'error' may be fixed early.`);
78
78
  }
79
79
  const target = targetRoot.id === input.id
80
80
  ? targetRoot
@@ -89,7 +89,7 @@ export function assertSequentialScope(todos, input) {
89
89
  if (input.status === "in_progress") {
90
90
  const running = activeRoot(todos);
91
91
  if (running && running.id !== targetRoot.id) {
92
- throw new Error(`[manage_todo_list] root ${running.id} is still in_progress. Finish it (or cancel it) before starting ${targetRoot.id}; an error root may be fixed early only when nothing else is running.`);
92
+ throw new Error(`root ${running.id} is still in_progress. Finish it (or cancel it) before starting ${targetRoot.id}; an error root may be fixed early only when nothing else is running.`);
93
93
  }
94
94
  const busyChild = targetRoot.children?.find((child) => child.status === "in_progress" && child.id !== input.id);
95
95
  if (busyChild) {
@@ -11,11 +11,10 @@ import { mergeProps as _$mergeProps } from "@opentui/solid";
11
11
  import { createElement as _$createElement } from "@opentui/solid";
12
12
  import { mkdirSync, watch } from "node:fs";
13
13
  import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
14
- import { supportsImageRendering } from "../icons/image-capabilities.js";
15
- import { SvgIcon } from "../icons/svg-icon.js";
16
- import { arrowDownSvgIcon, arrowRightSvgIcon, continuePromptSvgIcon, removeSvgIcon } from "../icons/svg-icons.js";
14
+ import { Glyph } from "../icons/glyph.js";
15
+ import { ARROW_DOWN, ARROW_RIGHT, CLEAR, CONTINUE } from "../icons/glyphs.js";
17
16
  import { StatusLabel } from "../schemas/status.js";
18
- import { getTodosDirectory, hashSessionID, readTodosSync, writeTodos } from "../storage/todo-store.js";
17
+ import { getDataVersion, getTodosDirectory, readTodosSync, writeTodos } from "../storage/todo-store.js";
19
18
  import { getTodoDisplaySummary, hasOpenTodos } from "../todos/display.js";
20
19
  import { TodoTree } from "../todos/todo-tree.js";
21
20
  import { ERROR_PREFIX, ErrorRowWidth, RowWidth } from "./layout.js";
@@ -31,6 +30,15 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
31
30
  const SPINNER_INTERVAL_MS = 100;
32
31
  const WATCH_DEBOUNCE_MS = 50;
33
32
 
33
+ /**
34
+ * Safety-net interval for cross-process change detection.
35
+ *
36
+ * `fs.watch` on the database usually fires first; this poll only exists for
37
+ * the cases where the filesystem event is missed or arrives late. One PRAGMA
38
+ * per second is negligible next to rendering the panel.
39
+ */
40
+ const DATA_VERSION_POLL_MS = 1000;
41
+
34
42
  /**
35
43
  * Maximum number of todo rows the panel shows before it starts scrolling.
36
44
  * Keeping the panel compact protects the composer; scrolling guarantees no
@@ -337,47 +345,69 @@ function createHover() {
337
345
  }
338
346
 
339
347
  /**
340
- * Watches the todos directory and calls `onChange` (debounced) when a file
341
- * belonging to the current session changes. Cleans up with the owner scope.
348
+ * Notifies when the todo data changes, from either process.
349
+ *
350
+ * All sessions now live in one SQLite file, so there is no longer a per-session
351
+ * folder to watch. Two complementary signals are used:
352
+ *
353
+ * 1. `fs.watch` on the database file, debounced. Low latency, but SQLite writes
354
+ * to the WAL first, so the event can arrive before the reader sees the new
355
+ * rows, and some platforms are inconsistent about reporting it at all.
356
+ * 2. A poll of `PRAGMA data_version`, which changes exactly when *another*
357
+ * connection commits. This is the safety net: if the filesystem event is
358
+ * missed, the poll still notices within one interval.
359
+ *
360
+ * The poll is skipped entirely while the document is idle and nothing moved, so
361
+ * the steady-state cost is one cheap PRAGMA per interval.
342
362
  */
343
- function watchSessionTodos(sessionID, onChange) {
363
+ function watchSessionTodos(onChange, readDataVersion) {
344
364
  let timer;
365
+ let interval;
345
366
  let watcher;
346
- const hashed = createMemo(() => {
347
- const sid = sessionID();
348
- if (!sid) return "";
349
- try {
350
- return hashSessionID(sid);
351
- } catch {
352
- return "";
353
- }
354
- });
355
367
  const notify = () => {
356
368
  clearTimeout(timer);
357
369
  timer = setTimeout(onChange, WATCH_DEBOUNCE_MS);
358
370
  };
359
- const belongsToSession = name => {
360
- const sid = sessionID();
361
- if (!sid) return true;
362
- const hash = hashed();
363
- return hash !== "" && name.startsWith(hash) || name.startsWith(sid) || name.startsWith(`todos-${sid}-`);
371
+
372
+ // Seed with the current value so the first poll compares against reality
373
+ // instead of firing a spurious refresh.
374
+ let lastVersion = Number.NaN;
375
+ const poll = () => {
376
+ try {
377
+ const version = readDataVersion();
378
+ if (Number.isNaN(lastVersion)) {
379
+ lastVersion = version;
380
+ return;
381
+ }
382
+ if (version !== lastVersion) {
383
+ lastVersion = version;
384
+ notify();
385
+ }
386
+ } catch {
387
+ // The database may be mid-migration; the next tick retries.
388
+ }
364
389
  };
365
390
  try {
366
391
  const baseDir = getTodosDirectory();
367
392
  mkdirSync(baseDir, {
368
393
  recursive: true
369
394
  });
395
+ // Watching the directory rather than the file itself: SQLite may replace
396
+ // the file on checkpoint, which would detach a file-level watcher.
370
397
  watcher = watch(baseDir, {
371
- recursive: true,
372
398
  persistent: false
373
399
  }, (_, filename) => {
374
- if (!filename || belongsToSession(String(filename))) notify();
400
+ const name = filename ? String(filename) : "";
401
+ if (!name || name.startsWith("todos.db")) notify();
375
402
  });
376
403
  } catch (err) {
377
404
  console.error("[todos] watcher initialization failed:", err);
378
405
  }
406
+ interval = setInterval(poll, DATA_VERSION_POLL_MS);
407
+ poll();
379
408
  onCleanup(() => {
380
409
  clearTimeout(timer);
410
+ if (interval) clearInterval(interval);
381
411
  watcher?.close();
382
412
  });
383
413
  }
@@ -395,12 +425,6 @@ export function TodoProgress(props) {
395
425
  const clearHover = createHover();
396
426
  const continueHover = createHover();
397
427
 
398
- // Image capability
399
- const [imageSupported, setImageSupported] = createSignal(supportsImageRendering(context.renderer?.capabilities));
400
- const onCapabilities = caps => setImageSupported(supportsImageRendering(caps));
401
- context.renderer.on("capabilities", onCapabilities);
402
- onCleanup(() => context.renderer.off("capabilities", onCapabilities));
403
-
404
428
  // Running state
405
429
  const readRunning = () => {
406
430
  const sid = sessionID();
@@ -443,7 +467,7 @@ export function TodoProgress(props) {
443
467
  });
444
468
 
445
469
  // Todo data
446
- watchSessionTodos(sessionID, () => setRefresh(v => v + 1));
470
+ watchSessionTodos(() => setRefresh(v => v + 1), getDataVersion);
447
471
  const summary = createMemo(() => {
448
472
  refresh();
449
473
  const sid = sessionID();
@@ -570,18 +594,13 @@ export function TodoProgress(props) {
570
594
  _$setProp(_el$18, "width", 2);
571
595
  _$setProp(_el$18, "height", 1);
572
596
  _$setProp(_el$18, "marginRight", 1);
597
+ _$setProp(_el$18, "marginLeft", 1);
573
598
  _$setProp(_el$18, "alignItems", "flex-start");
574
- _$insert(_el$18, _$createComponent(SvgIcon, {
575
- get source() {
576
- return isCollapsed() ? arrowRightSvgIcon : arrowDownSvgIcon;
577
- },
599
+ _$insert(_el$18, _$createComponent(Glyph, {
600
+ glyph: () => isCollapsed() ? ARROW_RIGHT : ARROW_DOWN,
578
601
  get color() {
579
602
  return theme().text.muted;
580
603
  },
581
- get fallback() {
582
- return isCollapsed() ? "▶" : "▼";
583
- },
584
- width: 2,
585
604
  height: 1
586
605
  }));
587
606
  _$insertNode(_el$19, _el$20);
@@ -642,14 +661,11 @@ export function TodoProgress(props) {
642
661
  void continueTodos();
643
662
  }
644
663
  });
645
- _$insert(_el$24, _$createComponent(SvgIcon, {
646
- source: continuePromptSvgIcon,
664
+ _$insert(_el$24, _$createComponent(Glyph, {
665
+ glyph: () => CONTINUE,
647
666
  get color() {
648
667
  return hoverColor(continueHover.hovered(), theme().text.action.primary.base);
649
- },
650
- fallback: "\u21BB",
651
- width: 2,
652
- height: 1
668
+ }
653
669
  }), _el$25);
654
670
  _$insertNode(_el$25, _$createTextNode(`Continue`));
655
671
  _$setProp(_el$25, "wrapMode", "none");
@@ -669,6 +685,7 @@ export function TodoProgress(props) {
669
685
  return _el$24;
670
686
  }
671
687
  }), _el$27);
688
+ _$setProp(_el$27, "marginRight", 1.5);
672
689
  _$setProp(_el$27, "flexDirection", "row");
673
690
  _$setProp(_el$27, "onMouseDown", e => {
674
691
  if (e.button === 0) {
@@ -676,14 +693,11 @@ export function TodoProgress(props) {
676
693
  void clearTodos();
677
694
  }
678
695
  });
679
- _$insert(_el$27, _$createComponent(SvgIcon, {
696
+ _$insert(_el$27, _$createComponent(Glyph, {
697
+ glyph: () => CLEAR,
680
698
  get color() {
681
699
  return hoverColor(clearHover.hovered(), theme().text.feedback.error.base);
682
- },
683
- source: removeSvgIcon,
684
- fallback: "\u2716",
685
- height: 1,
686
- width: 2
700
+ }
687
701
  }));
688
702
  _$insert(_el$15, _$createComponent(Show, {
689
703
  get when() {
@@ -709,32 +723,26 @@ export function TodoProgress(props) {
709
723
  }), null);
710
724
  _$effect(_p$ => {
711
725
  var _v$11 = theme().border.base,
712
- _v$12 = imageSupported() ? 0 : 1,
713
- _v$13 = {
726
+ _v$12 = {
714
727
  fg: theme().text.base
715
728
  },
716
- _v$14 = {
729
+ _v$13 = {
717
730
  fg: theme().text.muted
718
731
  },
719
- _v$15 = !imageSupported() ? 1.5 : 0,
720
- _v$16 = clearHover.onMouseOver,
721
- _v$17 = clearHover.onMouseOut;
732
+ _v$14 = clearHover.onMouseOver,
733
+ _v$15 = clearHover.onMouseOut;
722
734
  _v$11 !== _p$.e && (_p$.e = _$setProp(_el$15, "borderColor", _v$11, _p$.e));
723
- _v$12 !== _p$.t && (_p$.t = _$setProp(_el$18, "marginLeft", _v$12, _p$.t));
724
- _v$13 !== _p$.a && (_p$.a = _$setProp(_el$21, "style", _v$13, _p$.a));
725
- _v$14 !== _p$.o && (_p$.o = _$setProp(_el$23, "style", _v$14, _p$.o));
726
- _v$15 !== _p$.i && (_p$.i = _$setProp(_el$27, "marginRight", _v$15, _p$.i));
727
- _v$16 !== _p$.n && (_p$.n = _$setProp(_el$27, "onMouseOver", _v$16, _p$.n));
728
- _v$17 !== _p$.s && (_p$.s = _$setProp(_el$27, "onMouseOut", _v$17, _p$.s));
735
+ _v$12 !== _p$.t && (_p$.t = _$setProp(_el$21, "style", _v$12, _p$.t));
736
+ _v$13 !== _p$.a && (_p$.a = _$setProp(_el$23, "style", _v$13, _p$.a));
737
+ _v$14 !== _p$.o && (_p$.o = _$setProp(_el$27, "onMouseOver", _v$14, _p$.o));
738
+ _v$15 !== _p$.i && (_p$.i = _$setProp(_el$27, "onMouseOut", _v$15, _p$.i));
729
739
  return _p$;
730
740
  }, {
731
741
  e: undefined,
732
742
  t: undefined,
733
743
  a: undefined,
734
744
  o: undefined,
735
- i: undefined,
736
- n: undefined,
737
- s: undefined
745
+ i: undefined
738
746
  });
739
747
  return _el$15;
740
748
  })()
@@ -0,0 +1,49 @@
1
+ CREATE TABLE `tag` (
2
+ `id` text PRIMARY KEY,
3
+ `name` text NOT NULL UNIQUE
4
+ );
5
+ --> statement-breakpoint
6
+ CREATE TABLE `todo_item` (
7
+ `session_key` text NOT NULL,
8
+ `id` text NOT NULL,
9
+ `parent_id` text,
10
+ `content` text NOT NULL,
11
+ `status` text NOT NULL,
12
+ `priority` text,
13
+ `note` text,
14
+ `position` integer NOT NULL,
15
+ CONSTRAINT `todo_item_pk` PRIMARY KEY(`session_key`, `id`),
16
+ CONSTRAINT `fk_todo_item_session_key_todo_session_session_key_fk` FOREIGN KEY (`session_key`) REFERENCES `todo_session`(`session_key`) ON DELETE CASCADE,
17
+ CONSTRAINT `fk_todo_item_session_key_parent_id_todo_item_session_key_id_fk` FOREIGN KEY (`session_key`,`parent_id`) REFERENCES `todo_item`(`session_key`,`id`) ON DELETE CASCADE
18
+ );
19
+ --> statement-breakpoint
20
+ CREATE TABLE `todo_item_dependency` (
21
+ `session_key` text NOT NULL,
22
+ `todo_id` text NOT NULL,
23
+ `depends_on_id` text NOT NULL,
24
+ CONSTRAINT `todo_item_dependency_pk` PRIMARY KEY(`session_key`, `todo_id`, `depends_on_id`),
25
+ CONSTRAINT `fk_todo_item_dependency_session_key_todo_id_todo_item_session_key_id_fk` FOREIGN KEY (`session_key`,`todo_id`) REFERENCES `todo_item`(`session_key`,`id`) ON DELETE CASCADE,
26
+ CONSTRAINT `fk_todo_item_dependency_session_key_depends_on_id_todo_item_session_key_id_fk` FOREIGN KEY (`session_key`,`depends_on_id`) REFERENCES `todo_item`(`session_key`,`id`) ON DELETE CASCADE
27
+ );
28
+ --> statement-breakpoint
29
+ CREATE TABLE `todo_item_tag` (
30
+ `session_key` text NOT NULL,
31
+ `todo_id` text NOT NULL,
32
+ `tag_id` text NOT NULL,
33
+ CONSTRAINT `todo_item_tag_pk` PRIMARY KEY(`session_key`, `todo_id`, `tag_id`),
34
+ CONSTRAINT `fk_todo_item_tag_session_key_todo_id_todo_item_session_key_id_fk` FOREIGN KEY (`session_key`,`todo_id`) REFERENCES `todo_item`(`session_key`,`id`) ON DELETE CASCADE,
35
+ CONSTRAINT `fk_todo_item_tag_tag_id_tag_id_fk` FOREIGN KEY (`tag_id`) REFERENCES `tag`(`id`) ON DELETE CASCADE
36
+ );
37
+ --> statement-breakpoint
38
+ CREATE TABLE `todo_session` (
39
+ `session_key` text PRIMARY KEY,
40
+ `revision` integer DEFAULT 0 NOT NULL,
41
+ `hint_revision` integer DEFAULT 0 NOT NULL,
42
+ `created_at` text NOT NULL,
43
+ `updated_at` text NOT NULL
44
+ );
45
+ --> statement-breakpoint
46
+ CREATE INDEX `todo_item_session_position_idx` ON `todo_item` (`session_key`,`position`);--> statement-breakpoint
47
+ CREATE UNIQUE INDEX `todo_item_dependency_pair_idx` ON `todo_item_dependency` (`session_key`,`todo_id`,`depends_on_id`);--> statement-breakpoint
48
+ CREATE INDEX `todo_item_dependency_depends_on_idx` ON `todo_item_dependency` (`session_key`,`depends_on_id`);--> statement-breakpoint
49
+ CREATE INDEX `todo_item_tag_tag_idx` ON `todo_item_tag` (`tag_id`);