@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 +22 -0
- package/README.md +6 -6
- package/media/screenshot.png +0 -0
- package/package.json +1 -1
- package/src/format.ts +68 -83
- package/src/tools/todoupdate.ts +9 -3
- package/src/validate.ts +5 -0
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
|
|
85
|
-
|
|
86
|
-
- **open** = `pending` + `in_progress`
|
|
87
|
-
- **running** = `in_progress` only (0 or 1 after a valid write)
|
|
88
|
-
- **
|
|
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.
|
package/media/screenshot.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
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
|
+
}
|
package/src/tools/todoupdate.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|