@nguyenquangthai/pi-todo 0.3.2 → 0.3.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 (2026-07-16)
4
+
5
+ ### Changed
6
+
7
+ - **Overlay heading**: Shows open, running, and completed counts. Completed
8
+ counts include only `completed` todos; `cancelled` todos remain excluded.
9
+ - **Simplified status display**: Removed the ANSI progress bar and `done/total`
10
+ counter from the overlay.
11
+ - **Documentation and screenshot**: Updated the README, screenshot PNG, and
12
+ screenshot HTML source to match the new heading.
13
+
14
+ ## 0.3.2 (2026-07-16)
15
+
16
+ ### Fixed
17
+
18
+ - **Todo ID integrity**: Prevented id-less todos from taking IDs explicitly
19
+ retained by another todo during a full `todo_write` replacement.
20
+ - **Diagnostics**: Added ID-integrity reporting and `repair_needed` status to
21
+ `todo_diagnose`.
22
+ - **Mutation bounds**: Capped todo mutations and persisted snapshots at 200
23
+ items.
24
+
3
25
  ## 0.3.1 (2026-07-15)
4
26
 
5
27
  ### Fixed
package/README.md CHANGED
@@ -81,12 +81,12 @@ Shown above the editor while any **open** todo remains (`pending` / `in_progress
81
81
 
82
82
  Hidden when the list is empty or every item is `completed` / `cancelled`.
83
83
 
84
- Heading shows open + running counts + background-color progress bar, e.g. `# Todos (3 open, 1 running) ▓▓▓▓░░░░ 1/4`:
85
-
86
- - **open** = `pending` + `in_progress`
87
- - **running** = `in_progress` only (0 or 1 after a valid write)
88
- - **progress bar** = ANSI background color via reverse video, using theme `accent` (filled) and `muted` (empty)
89
-
84
+ Heading shows open, running, and completed counts, e.g. `# Todos (3 open, 1 running, 1 completed)`:
85
+
86
+ - **open** = `pending` + `in_progress`
87
+ - **running** = `in_progress` only (0 or 1 after a valid write)
88
+ - **completed** = `completed` only; `cancelled` todos are not counted
89
+
90
90
  Items always stay in the array's workflow order; status changes only their marker/color.
91
91
  When space is tight, the overlay shows the earliest checklist items and `+N more`. If the active item is outside that prefix, it is repeated as `Active: [•] …` rather than moved ahead of earlier work.
92
92
  A blank line separates the heading from the first todo row for visual breathing room.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nguyenquangthai/pi-todo",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "OpenCode-like session todo checklist for the pi coding agent — todowrite/todoread with a live TUI overlay",
5
5
  "type": "module",
6
6
  "author": "QuangThai",
package/src/format.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { TodoItem, TodoStatus } from "./types.js";
4
- import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
5
- import { countOpenTodos, countRunningTodos, hasOpenTodos } from "./validate.js";
3
+ import type { TodoItem, TodoStatus } from "./types.js";
4
+ import { MAX_OVERLAY_LINES, MAX_RESULT_LINES } from "./types.js";
5
+ import { countCompletedTodos, countOpenTodos, countRunningTodos, hasOpenTodos } from "./validate.js";
6
6
 
7
7
 
8
8
  export function getTodoMarker(status: TodoStatus): string {
@@ -19,22 +19,23 @@ export function getTodoMarker(status: TodoStatus): string {
19
19
  }
20
20
 
21
21
  export function formatPlainTodoLine(todo: TodoItem): string {
22
- return `${getTodoMarker(todo.status)} ${todo.content}`;
22
+ const prefix = todo.id ? `${todo.id} ` : "";
23
+ return `${getTodoMarker(todo.status)} ${prefix}${todo.content}`;
23
24
  }
24
25
 
25
26
  /** Compact checklist for tool responses; caps lines to keep LLM context small. */
26
27
  export function formatTodoListText(todos: readonly TodoItem[], summary: string): string {
27
28
  if (todos.length === 0) return summary;
28
29
 
29
- // The list is a workflow timeline. Never reshuffle completed work after the
30
- // next task just because its status changed.
31
- const ordered = [...todos];
30
+ // The list is a workflow timeline. Never reshuffle completed work after the
31
+ // next task just because its status changed.
32
+ const ordered = [...todos];
32
33
 
33
34
  if (todos.length <= MAX_RESULT_LINES) {
34
- return [summary, ...ordered.map(formatPlainTodoLine)].join("\n");
35
+ return [summary, ...ordered.map(formatPlainTodoLine)].join("\n");
35
36
  }
36
- const shown = ordered.slice(0, MAX_RESULT_LINES);
37
- const hidden = ordered.length - MAX_RESULT_LINES;
37
+ const shown = ordered.slice(0, MAX_RESULT_LINES);
38
+ const hidden = ordered.length - MAX_RESULT_LINES;
38
39
  return [
39
40
  summary,
40
41
  ...shown.map(formatPlainTodoLine),
@@ -58,49 +59,49 @@ export function shouldShowOverlay(todos: readonly TodoItem[]): boolean {
58
59
  return hasOpenTodos(todos);
59
60
  }
60
61
 
61
- export interface OverlayLayout {
62
- visible: TodoItem[];
63
- /** Active item repeated below the timeline when it is outside the visible prefix. */
64
- pinnedActive?: TodoItem;
65
- hiddenCount: number;
66
- /** Retained for consumers of the layout API; terminal items are no longer regrouped. */
67
- terminalCount: number;
68
- }
69
-
70
- /**
71
- * Fit the checklist timeline into the overlay without sorting by status or
72
- * priority. When the active task falls outside the visible prefix, repeat it
73
- * as a pinned "Active:" row rather than moving it ahead of earlier work.
74
- */
62
+ export interface OverlayLayout {
63
+ visible: TodoItem[];
64
+ /** Active item repeated below the timeline when it is outside the visible prefix. */
65
+ pinnedActive?: TodoItem;
66
+ hiddenCount: number;
67
+ /** Retained for consumers of the layout API; terminal items are no longer regrouped. */
68
+ terminalCount: number;
69
+ }
70
+
71
+ /**
72
+ * Fit the checklist timeline into the overlay without sorting by status or
73
+ * priority. When the active task falls outside the visible prefix, repeat it
74
+ * as a pinned "Active:" row rather than moving it ahead of earlier work.
75
+ */
75
76
  export function selectOverlayLayout(
76
77
  todos: readonly TodoItem[],
77
- maxLines: number = MAX_OVERLAY_LINES,
78
- ): OverlayLayout {
79
- if (!shouldShowOverlay(todos)) {
80
- return { visible: [], hiddenCount: 0, terminalCount: 0 };
78
+ maxLines: number = MAX_OVERLAY_LINES,
79
+ ): OverlayLayout {
80
+ if (!shouldShowOverlay(todos)) {
81
+ return { visible: [], hiddenCount: 0, terminalCount: 0 };
81
82
  }
82
83
 
83
- const bodyBudget = Math.max(1, maxLines - 1);
84
- if (todos.length <= bodyBudget) {
85
- // All fit — show the canonical checklist sequence unchanged.
86
- return { visible: [...todos], hiddenCount: 0, terminalCount: 0 };
87
- }
88
-
89
- const active = todos.find((todo) => todo.status === "in_progress");
90
- // Reserve one row for the overflow summary. If the active task lies outside
91
- // the timeline prefix, reserve another row to pin it without reordering.
92
- let visibleCapacity = Math.max(0, bodyBudget - 1);
93
- let visible = todos.slice(0, visibleCapacity);
94
- let pinnedActive = active && !visible.includes(active) ? active : undefined;
95
-
96
- if (pinnedActive) {
97
- visibleCapacity = Math.max(0, bodyBudget - 2);
98
- visible = todos.slice(0, visibleCapacity);
99
- pinnedActive = active;
100
- }
101
-
102
- const hiddenCount = todos.length - visible.length - (pinnedActive ? 1 : 0);
103
- return { visible, pinnedActive, hiddenCount, terminalCount: 0 };
84
+ const bodyBudget = Math.max(1, maxLines - 1);
85
+ if (todos.length <= bodyBudget) {
86
+ // All fit — show the canonical checklist sequence unchanged.
87
+ return { visible: [...todos], hiddenCount: 0, terminalCount: 0 };
88
+ }
89
+
90
+ const active = todos.find((todo) => todo.status === "in_progress");
91
+ // Reserve one row for the overflow summary. If the active task lies outside
92
+ // the timeline prefix, reserve another row to pin it without reordering.
93
+ let visibleCapacity = Math.max(0, bodyBudget - 1);
94
+ let visible = todos.slice(0, visibleCapacity);
95
+ let pinnedActive = active && !visible.includes(active) ? active : undefined;
96
+
97
+ if (pinnedActive) {
98
+ visibleCapacity = Math.max(0, bodyBudget - 2);
99
+ visible = todos.slice(0, visibleCapacity);
100
+ pinnedActive = active;
101
+ }
102
+
103
+ const hiddenCount = todos.length - visible.length - (pinnedActive ? 1 : 0);
104
+ return { visible, pinnedActive, hiddenCount, terminalCount: 0 };
104
105
  }
105
106
 
106
107
  export interface RenderOverlayOptions {
@@ -108,11 +109,7 @@ export interface RenderOverlayOptions {
108
109
  }
109
110
 
110
111
  /**
111
- * Heading counts (when overlay is visible):
112
- * - open = pending + in_progress
113
- * - running = in_progress only (0 or 1 after a valid todo_write)
114
- * Overlay is hidden when open === 0, so "(0 open, 0 running)" is never shown.
115
- * Includes a background-color progress bar using theme colors.
112
+ * The overlay is hidden when no work remains open.
116
113
  */
117
114
  export function renderOverlayLines(
118
115
  todos: readonly TodoItem[],
@@ -122,45 +119,33 @@ export function renderOverlayLines(
122
119
  ): string[] {
123
120
  if (!shouldShowOverlay(todos)) return [];
124
121
 
125
- const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
122
+ const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
126
123
  const truncate = (line: string) => truncateToWidth(line, width, "…");
127
124
  const open = countOpenTodos(todos);
128
125
  const running = countRunningTodos(todos);
129
-
130
- // Background-color progress bar via reverse video — uses theme accent/dim
131
- const total = todos.length;
132
- const done = total - open;
133
- const barWidth = Math.max(4, Math.min(10, Math.floor(width / 8)));
134
- const filled = Math.round((barWidth * done) / total);
135
- const REV = "\x1b[7m";
136
- const filledBar = REV + theme.fg("accent", " ".repeat(filled));
137
- const emptyBar =
138
- filled < barWidth ? REV + theme.fg("muted", " ".repeat(barWidth - filled)) : "";
139
- const bar = filledBar + emptyBar + "\x1b[0m";
140
-
126
+ const completed = countCompletedTodos(todos);
141
127
  const heading = truncate(
142
- theme.fg("accent", theme.bold(`# Todos`)) +
143
- theme.fg("dim", ` (${open} open, ${running} running)`) +
144
- ` ${bar}` + theme.fg("dim", ` ${done}/${total}`),
128
+ theme.fg("accent", theme.bold("# Todos")) +
129
+ theme.fg("dim", ` (${open} open, ${running} running, ${completed} completed)`),
145
130
  );
146
131
 
147
132
  // Small gap between heading and first row — budget -1 to account for the blank line
148
133
  const layout = selectOverlayLayout(todos, Math.max(3, maxLines - 1));
149
134
  const lines: string[] = [heading, ""];
150
135
 
151
- for (const todo of layout.visible) {
152
- lines.push(truncate(formatThemedTodoLine(todo, theme)));
153
- }
154
- if (layout.pinnedActive) {
155
- lines.push(
156
- truncate(theme.fg("warning", `Active: ${formatPlainTodoLine(layout.pinnedActive)}`)),
157
- );
158
- }
136
+ for (const todo of layout.visible) {
137
+ lines.push(truncate(formatThemedTodoLine(todo, theme)));
138
+ }
139
+ if (layout.pinnedActive) {
140
+ lines.push(
141
+ truncate(theme.fg("warning", `Active: ${formatPlainTodoLine(layout.pinnedActive)}`)),
142
+ );
143
+ }
159
144
  if (layout.hiddenCount > 0) {
160
145
  lines.push(truncate(theme.fg("dim", `+${layout.hiddenCount} more`)));
161
146
  }
162
- lines.push("");
163
- // Layout reserves content rows, but the heading and trailing spacer are
164
- // rendered here. Enforce the public maxLines contract at the final boundary.
165
- return lines.slice(0, maxLines);
166
- }
147
+ lines.push("");
148
+ // Layout reserves content rows, but the heading and trailing spacer are
149
+ // rendered here. Enforce the public maxLines contract at the final boundary.
150
+ return lines.slice(0, maxLines);
151
+ }
@@ -1,16 +1,18 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
+ import { formatTodoListText } from "../format.js";
3
4
  import { TodoUpdateParams } from "../schema.js";
4
5
  import { getTodos, setTodos, withStoreLock } from "../store.js";
5
6
  import type { TodoWriteDetails } from "../types.js";
6
7
  import { TODO_STATE_ENTRY_TYPE, TOOL_UPDATE } from "../types.js";
7
- import { ensureTodoIds, validateTodoUpdate } from "../validate.js";
8
+ import { countOpenTodos, ensureTodoIds, validateTodoUpdate } from "../validate.js";
8
9
 
9
10
  export function registerTodoUpdateTool(pi: ExtensionAPI, options: { onCommit?: () => void }): void {
10
11
  pi.registerTool({
11
12
  name: TOOL_UPDATE,
12
13
  label: "Todo Update",
13
- description: "Patch existing todos by stable ID. Use todo_read first to obtain IDs. This never deletes items.",
14
+ description: "Patch existing todos by stable ID without replacing the full list or changing its order. id is required, must be a non-empty string, and must match a current todo; use todo_read first to obtain it. This tool never deletes items.\n\nNote: todo_write can reassign IDs for items whose content/status/priority changed. Always call todo_read before todo_update to get the current IDs.",
15
+ promptGuidelines: ["Always call todo_read before todo_update to obtain current IDs; IDs can change after todo_write if the item\u2019s content, status, or priority was modified."],
14
16
  promptSnippet: "Patch one or more existing todos by ID without replacing the full list",
15
17
  parameters: TodoUpdateParams,
16
18
  async execute(_toolCallId, params) {
@@ -30,7 +32,11 @@ export function registerTodoUpdateTool(pi: ExtensionAPI, options: { onCommit?: (
30
32
  }
31
33
  setTodos(todos);
32
34
  options.onCommit?.();
33
- return { content: [{ type: "text", text: result.unchanged ? "No change" : `Updated ${params.updates.length} todo(s)` }], details: { todos, ...(result.unchanged ? { unchanged: true } : {}) } as TodoWriteDetails };
35
+ const open = countOpenTodos(todos);
36
+ const text = result.unchanged
37
+ ? "No change"
38
+ : `Updated ${params.updates.length} todo(s)\n\n${formatTodoListText(todos, `${open} open / ${todos.length} total`)}`;
39
+ return { content: [{ type: "text", text }], details: { todos, ...(result.unchanged ? { unchanged: true } : {}) } as TodoWriteDetails };
34
40
  });
35
41
  },
36
42
  renderCall(args, theme) { return new Text(theme.fg("toolTitle", theme.bold("todo_update ")) + theme.fg("accent", `${Array.isArray(args.updates) ? args.updates.length : 0} patch(es)`), 0, 0); },
package/src/validate.ts CHANGED
@@ -228,3 +228,8 @@ export function countOpenTodos(todos: readonly TodoItem[]): number {
228
228
  export function countRunningTodos(todos: readonly TodoItem[]): number {
229
229
  return todos.filter((t) => t.status === "in_progress").length;
230
230
  }
231
+
232
+ /** Count successfully finished items; cancelled items are intentionally excluded. */
233
+ export function countCompletedTodos(todos: readonly TodoItem[]): number {
234
+ return todos.filter((todo) => todo.status === "completed").length;
235
+ }