@nguyenquangthai/pi-todo 0.3.3 → 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/package.json +1 -1
- package/src/format.ts +76 -75
- package/src/tools/todoupdate.ts +9 -3
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 { countCompletedTodos, 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,58 +59,58 @@ 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 };
|
|
82
|
+
}
|
|
83
|
+
|
|
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 };
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 };
|
|
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 {
|
|
107
108
|
maxLines?: number;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
/**
|
|
111
|
-
* The overlay is hidden when no work remains open.
|
|
112
|
-
*/
|
|
111
|
+
/**
|
|
112
|
+
* The overlay is hidden when no work remains open.
|
|
113
|
+
*/
|
|
113
114
|
export function renderOverlayLines(
|
|
114
115
|
todos: readonly TodoItem[],
|
|
115
116
|
theme: Theme,
|
|
@@ -118,33 +119,33 @@ export function renderOverlayLines(
|
|
|
118
119
|
): string[] {
|
|
119
120
|
if (!shouldShowOverlay(todos)) return [];
|
|
120
121
|
|
|
121
|
-
const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
|
|
122
|
-
const truncate = (line: string) => truncateToWidth(line, width, "…");
|
|
123
|
-
const open = countOpenTodos(todos);
|
|
124
|
-
const running = countRunningTodos(todos);
|
|
125
|
-
const completed = countCompletedTodos(todos);
|
|
126
|
-
const heading = truncate(
|
|
127
|
-
theme.fg("accent", theme.bold("# Todos")) +
|
|
128
|
-
theme.fg("dim", ` (${open} open, ${running} running, ${completed} completed)`),
|
|
129
|
-
);
|
|
130
|
-
|
|
122
|
+
const maxLines = Math.max(1, options.maxLines ?? MAX_OVERLAY_LINES);
|
|
123
|
+
const truncate = (line: string) => truncateToWidth(line, width, "…");
|
|
124
|
+
const open = countOpenTodos(todos);
|
|
125
|
+
const running = countRunningTodos(todos);
|
|
126
|
+
const completed = countCompletedTodos(todos);
|
|
127
|
+
const heading = truncate(
|
|
128
|
+
theme.fg("accent", theme.bold("# Todos")) +
|
|
129
|
+
theme.fg("dim", ` (${open} open, ${running} running, ${completed} completed)`),
|
|
130
|
+
);
|
|
131
|
+
|
|
131
132
|
// Small gap between heading and first row — budget -1 to account for the blank line
|
|
132
133
|
const layout = selectOverlayLayout(todos, Math.max(3, maxLines - 1));
|
|
133
134
|
const lines: string[] = [heading, ""];
|
|
134
135
|
|
|
135
|
-
for (const todo of layout.visible) {
|
|
136
|
-
lines.push(truncate(formatThemedTodoLine(todo, theme)));
|
|
137
|
-
}
|
|
138
|
-
if (layout.pinnedActive) {
|
|
139
|
-
lines.push(
|
|
140
|
-
truncate(theme.fg("warning", `Active: ${formatPlainTodoLine(layout.pinnedActive)}`)),
|
|
141
|
-
);
|
|
142
|
-
}
|
|
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
|
+
}
|
|
143
144
|
if (layout.hiddenCount > 0) {
|
|
144
145
|
lines.push(truncate(theme.fg("dim", `+${layout.hiddenCount} more`)));
|
|
145
146
|
}
|
|
146
|
-
lines.push("");
|
|
147
|
-
// Layout reserves content rows, but the heading and trailing spacer are
|
|
148
|
-
// rendered here. Enforce the public maxLines contract at the final boundary.
|
|
149
|
-
return lines.slice(0, maxLines);
|
|
150
|
-
}
|
|
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); },
|