@maxiaochao/pi-toolkit 0.9.0 → 0.9.2
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 +8 -0
- package/README.md +2 -0
- package/extensions/tests/todo-state.mjs +98 -0
- package/extensions/todo-state.ts +160 -0
- package/extensions/todo.ts +65 -156
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2026-09-30 - v0.9.2
|
|
2
|
+
|
|
3
|
+
- show contiguous queue positions in the todo widget while retaining stable internal task IDs
|
|
4
|
+
|
|
5
|
+
## 2026-09-30 - v0.9.1
|
|
6
|
+
|
|
7
|
+
- redesign the todo lifecycle with plan replacement, cancellation, automatic advancement, active-only widget rendering, concise model guidance, and deterministic state tests
|
|
8
|
+
|
|
1
9
|
## 2026-09-30 - v0.9.0
|
|
2
10
|
|
|
3
11
|
- merge the Codex-style apply_patch extension into the toolkit (from the standalone @maxiaochao/pi-codex-edit v0.1.6): it now ships as extensions/codex-edit/ registered in pi.extensions, its parser and model-routing tests join npm test, and the separate package, release script and publish workflow are removed
|
package/README.md
CHANGED
|
@@ -38,6 +38,8 @@ To synchronize the file directly from a checkout instead:
|
|
|
38
38
|
bash scripts/install.sh
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
The `todo` tool maintains a session checklist for explicit plans and multi-step work. It automatically keeps one task active and advances the next queued task; `replace` starts a clean plan after a change of direction, while `cancelled` records obsolete work honestly. The persistent widget numbers active work by queue position; `/todos` shows active and closed tasks with their stable internal IDs.
|
|
42
|
+
|
|
41
43
|
The `/btw` side chat is a separate agent session. It can inspect the main conversation and repository, and its thread stays out of the main context until you close it and choose to inject a summary. Use `/btw` to open it or `/btw <question>` to ask directly. While the BTW overlay has focus, use `Up` / `Down` to scroll its transcript one line at a time and `PageUp` / `PageDown` to move by one transcript page.
|
|
42
44
|
|
|
43
45
|
Use `/cache_export [path]` to write an interactive per-request cache dashboard. It defaults to the latest Context segment for current-state diagnosis, and the View selector can show any earlier segment or all session history. Compaction and model changes both break context charts into segments; cache-hit accumulation also resets at model changes, while TTL and other inferred interruptions remain diagnostic events rather than segment boundaries.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
activeTodoRows,
|
|
4
|
+
activeTodos,
|
|
5
|
+
applyTodoAction,
|
|
6
|
+
restoreTodoState,
|
|
7
|
+
} from "../todo-state.ts";
|
|
8
|
+
|
|
9
|
+
const empty = { todos: [], nextId: 1 };
|
|
10
|
+
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
activeTodoRows([
|
|
13
|
+
{ id: 8, text: "closed", status: "done" },
|
|
14
|
+
{ id: 9, text: "current", status: "doing" },
|
|
15
|
+
{ id: 11, text: "next", status: "todo" },
|
|
16
|
+
]).map(({ position, todo }) => ({ position, id: todo.id })),
|
|
17
|
+
[
|
|
18
|
+
{ position: 1, id: 9 },
|
|
19
|
+
{ position: 2, id: 11 },
|
|
20
|
+
],
|
|
21
|
+
"active rows expose contiguous queue positions independently of internal IDs",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const replaced = applyTodoAction(empty, {
|
|
25
|
+
action: "replace",
|
|
26
|
+
items: ["Inspect the failure", "Implement the fix", "Run verification"],
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
replaced.todos.map(({ id, text, status }) => ({ id, text, status })),
|
|
30
|
+
[
|
|
31
|
+
{ id: 1, text: "Inspect the failure", status: "doing" },
|
|
32
|
+
{ id: 2, text: "Implement the fix", status: "todo" },
|
|
33
|
+
{ id: 3, text: "Run verification", status: "todo" },
|
|
34
|
+
],
|
|
35
|
+
"replace installs a fresh ordered plan and starts its first task",
|
|
36
|
+
);
|
|
37
|
+
assert.equal(replaced.nextId, 4, "replace advances the ID sequence past the new plan");
|
|
38
|
+
|
|
39
|
+
const completed = applyTodoAction(replaced, { action: "update", id: 1, status: "done" });
|
|
40
|
+
assert.deepEqual(
|
|
41
|
+
completed.todos.map(({ id, status }) => ({ id, status })),
|
|
42
|
+
[
|
|
43
|
+
{ id: 1, status: "done" },
|
|
44
|
+
{ id: 2, status: "doing" },
|
|
45
|
+
{ id: 3, status: "todo" },
|
|
46
|
+
],
|
|
47
|
+
"finishing active work advances the next queued task",
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const cancelled = applyTodoAction(completed, { action: "update", id: 2, status: "cancelled" });
|
|
51
|
+
assert.equal(cancelled.todos[2].status, "doing", "cancelling active work advances the next queued task");
|
|
52
|
+
assert.deepEqual(activeTodos(cancelled.todos).map((todo) => todo.id), [3], "closed work is absent from the active view");
|
|
53
|
+
|
|
54
|
+
const restarted = applyTodoAction(cancelled, { action: "add", text: "Review the redirected implementation", status: "doing" });
|
|
55
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 3).status, "todo", "starting another task demotes the previous active task");
|
|
56
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 4).status, "doing", "the requested task becomes active");
|
|
57
|
+
|
|
58
|
+
const removed = applyTodoAction(restarted, { action: "remove", id: 4 });
|
|
59
|
+
assert.equal(removed.todos.find((todo) => todo.id === 3).status, "doing", "removing active work advances the queue");
|
|
60
|
+
assert.equal(removed.nextId, 5, "removing a task does not reuse its ID");
|
|
61
|
+
|
|
62
|
+
const duplicate = applyTodoAction(removed, { action: "add", text: "Run verification" });
|
|
63
|
+
assert.match(duplicate.error ?? "", /already exists/, "active duplicates are rejected");
|
|
64
|
+
assert.deepEqual(duplicate.todos, removed.todos, "a rejected mutation preserves the previous state");
|
|
65
|
+
|
|
66
|
+
const replacedAgain = applyTodoAction(removed, { action: "replace", items: ["New direction"] });
|
|
67
|
+
assert.deepEqual(
|
|
68
|
+
replacedAgain.todos,
|
|
69
|
+
[{ id: 5, text: "New direction", status: "doing" }],
|
|
70
|
+
"replace discards the obsolete plan without reusing an old task ID",
|
|
71
|
+
);
|
|
72
|
+
assert.equal(replacedAgain.nextId, 6);
|
|
73
|
+
|
|
74
|
+
const invalidReplace = applyTodoAction(replacedAgain, { action: "replace", items: [" "] });
|
|
75
|
+
assert.ok(invalidReplace.error, "replace rejects empty task text");
|
|
76
|
+
assert.deepEqual(invalidReplace.todos, replacedAgain.todos, "invalid replacement is atomic");
|
|
77
|
+
|
|
78
|
+
const restored = restoreTodoState([
|
|
79
|
+
{ id: 4, text: "legacy complete", done: true },
|
|
80
|
+
{ id: 2, text: "first active", status: "doing" },
|
|
81
|
+
{ id: 3, text: "second active", status: "doing" },
|
|
82
|
+
], 2);
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
restored.todos.map(({ id, status }) => ({ id, status })),
|
|
85
|
+
[
|
|
86
|
+
{ id: 2, status: "doing" },
|
|
87
|
+
{ id: 3, status: "todo" },
|
|
88
|
+
{ id: 4, status: "done" },
|
|
89
|
+
],
|
|
90
|
+
"restoration migrates legacy entries and enforces one active task",
|
|
91
|
+
);
|
|
92
|
+
assert.equal(restored.nextId, 5, "restoration repairs stale next IDs");
|
|
93
|
+
|
|
94
|
+
const cleared = applyTodoAction(restored, { action: "clear" });
|
|
95
|
+
assert.deepEqual(cleared.todos, []);
|
|
96
|
+
assert.equal(cleared.nextId, 1, "clear resets the ID sequence");
|
|
97
|
+
|
|
98
|
+
console.log("Todo state tests passed");
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export type TodoStatus = "todo" | "doing" | "done" | "cancelled";
|
|
2
|
+
export type TodoAction = "list" | "replace" | "add" | "update" | "remove" | "clear";
|
|
3
|
+
|
|
4
|
+
export interface Todo {
|
|
5
|
+
id: number;
|
|
6
|
+
text: string;
|
|
7
|
+
status: TodoStatus;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface StoredTodo {
|
|
11
|
+
id: number;
|
|
12
|
+
text: string;
|
|
13
|
+
status?: TodoStatus;
|
|
14
|
+
done?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TodoState {
|
|
18
|
+
todos: Todo[];
|
|
19
|
+
nextId: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ActiveTodoRow {
|
|
23
|
+
position: number;
|
|
24
|
+
todo: Todo;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TodoActionInput {
|
|
28
|
+
action: TodoAction;
|
|
29
|
+
text?: string;
|
|
30
|
+
items?: string[];
|
|
31
|
+
id?: number;
|
|
32
|
+
status?: TodoStatus;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TodoActionResult extends TodoState {
|
|
36
|
+
message: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type TodoCounts = Record<TodoStatus, number>;
|
|
41
|
+
|
|
42
|
+
export function normalizeTodo(todo: StoredTodo): Todo {
|
|
43
|
+
const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done" || todo.status === "cancelled"
|
|
44
|
+
? todo.status
|
|
45
|
+
: todo.done
|
|
46
|
+
? "done"
|
|
47
|
+
: "todo";
|
|
48
|
+
return { id: todo.id, text: todo.text, status };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeTodoFlow(todos: Todo[]): void {
|
|
52
|
+
const active = todos.filter((todo) => todo.status === "doing");
|
|
53
|
+
for (const todo of active.slice(1)) todo.status = "todo";
|
|
54
|
+
if (active.length > 0) return;
|
|
55
|
+
|
|
56
|
+
const next = todos.find((todo) => todo.status === "todo");
|
|
57
|
+
if (next) next.status = "doing";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function restoreTodoState(stored: StoredTodo[], nextId?: number): TodoState {
|
|
61
|
+
const todos = stored.map(normalizeTodo).sort((a, b) => a.id - b.id);
|
|
62
|
+
normalizeTodoFlow(todos);
|
|
63
|
+
const minimumNextId = todos.reduce((max, todo) => Math.max(max, todo.id + 1), 1);
|
|
64
|
+
return { todos, nextId: Math.max(nextId ?? 1, minimumNextId) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function countTodoStatuses(todos: Todo[]): TodoCounts {
|
|
68
|
+
return todos.reduce<TodoCounts>(
|
|
69
|
+
(counts, todo) => {
|
|
70
|
+
counts[todo.status] += 1;
|
|
71
|
+
return counts;
|
|
72
|
+
},
|
|
73
|
+
{ todo: 0, doing: 0, done: 0, cancelled: 0 },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function activeTodos(todos: Todo[]): Todo[] {
|
|
78
|
+
return todos.filter((todo) => todo.status === "doing" || todo.status === "todo");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function activeTodoRows(todos: Todo[]): ActiveTodoRow[] {
|
|
82
|
+
return activeTodos(todos).map((todo, index) => ({ position: index + 1, todo }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cloneState(state: TodoState): TodoState {
|
|
86
|
+
return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function fail(state: TodoState, error: string): TodoActionResult {
|
|
90
|
+
return { ...cloneState(state), message: `Error: ${error}`, error };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanText(text: string | undefined): string | undefined {
|
|
94
|
+
const cleaned = text?.trim();
|
|
95
|
+
return cleaned ? cleaned : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasActiveDuplicate(todos: Todo[], text: string): boolean {
|
|
99
|
+
return todos.some((todo) => (todo.status === "todo" || todo.status === "doing") && todo.text === text);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
|
|
103
|
+
const next = cloneState(state);
|
|
104
|
+
|
|
105
|
+
switch (input.action) {
|
|
106
|
+
case "list":
|
|
107
|
+
return { ...next, message: next.todos.length ? "Listed todos" : "No todos" };
|
|
108
|
+
|
|
109
|
+
case "replace": {
|
|
110
|
+
if (!input.items?.length) return fail(state, "items required for replace");
|
|
111
|
+
const items = input.items.map(cleanText);
|
|
112
|
+
if (items.some((item) => item === undefined)) return fail(state, "replace items must not be empty");
|
|
113
|
+
const texts = items as string[];
|
|
114
|
+
if (new Set(texts).size !== texts.length) return fail(state, "replace items must be unique");
|
|
115
|
+
const firstId = next.nextId;
|
|
116
|
+
next.todos = texts.map((text, index) => ({ id: firstId + index, text, status: index === 0 ? "doing" : "todo" }));
|
|
117
|
+
next.nextId += next.todos.length;
|
|
118
|
+
return { ...next, message: `Replaced plan with ${next.todos.length} tasks` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case "add": {
|
|
122
|
+
const text = cleanText(input.text);
|
|
123
|
+
if (!text) return fail(state, "text required for add");
|
|
124
|
+
if (hasActiveDuplicate(next.todos, text)) return fail(state, `active task already exists: ${text}`);
|
|
125
|
+
if (input.status === "doing") {
|
|
126
|
+
for (const todo of next.todos) if (todo.status === "doing") todo.status = "todo";
|
|
127
|
+
}
|
|
128
|
+
const todo: Todo = { id: next.nextId++, text, status: input.status ?? "todo" };
|
|
129
|
+
next.todos.push(todo);
|
|
130
|
+
normalizeTodoFlow(next.todos);
|
|
131
|
+
return { ...next, message: `Added todo #${todo.id} [${todo.status}]: ${todo.text}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
case "update": {
|
|
135
|
+
if (input.id === undefined || input.status === undefined) return fail(state, "id and status required for update");
|
|
136
|
+
const todo = next.todos.find((item) => item.id === input.id);
|
|
137
|
+
if (!todo) return fail(state, `#${input.id} not found`);
|
|
138
|
+
if (input.status === "doing") {
|
|
139
|
+
for (const item of next.todos) if (item.status === "doing") item.status = "todo";
|
|
140
|
+
}
|
|
141
|
+
todo.status = input.status;
|
|
142
|
+
normalizeTodoFlow(next.todos);
|
|
143
|
+
return { ...next, message: `Todo #${todo.id} is now ${todo.status}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case "remove": {
|
|
147
|
+
if (input.id === undefined) return fail(state, "id required for remove");
|
|
148
|
+
const index = next.todos.findIndex((item) => item.id === input.id);
|
|
149
|
+
if (index < 0) return fail(state, `#${input.id} not found`);
|
|
150
|
+
const [removed] = next.todos.splice(index, 1);
|
|
151
|
+
normalizeTodoFlow(next.todos);
|
|
152
|
+
return { ...next, message: `Removed todo #${removed.id}: ${removed.text}` };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case "clear": {
|
|
156
|
+
const count = next.todos.length;
|
|
157
|
+
return { todos: [], nextId: 1, message: `Cleared ${count} todos` };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/extensions/todo.ts
CHANGED
|
@@ -1,30 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Session-scoped todo extension with branch-aware persistence.
|
|
3
3
|
*
|
|
4
|
-
* Provides
|
|
5
|
-
* widget, and footer counts. It has no subagent integration or prompt hooks.
|
|
4
|
+
* Provides the `todo` tool, a compact active-work widget, and `/todos` current-plan view.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
8
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
10
9
|
import { matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
10
|
import { Type } from "typebox";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
text: string;
|
|
25
|
-
status?: TodoStatus;
|
|
26
|
-
done?: boolean;
|
|
27
|
-
}
|
|
11
|
+
import {
|
|
12
|
+
activeTodoRows,
|
|
13
|
+
applyTodoAction,
|
|
14
|
+
countTodoStatuses,
|
|
15
|
+
normalizeTodo,
|
|
16
|
+
restoreTodoState,
|
|
17
|
+
type StoredTodo,
|
|
18
|
+
type Todo,
|
|
19
|
+
type TodoAction,
|
|
20
|
+
type TodoState,
|
|
21
|
+
type TodoStatus,
|
|
22
|
+
} from "./todo-state.ts";
|
|
28
23
|
|
|
29
24
|
interface TodoDetails {
|
|
30
25
|
action: TodoAction;
|
|
@@ -34,37 +29,22 @@ interface TodoDetails {
|
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
const TodoParams = Type.Object({
|
|
37
|
-
action: StringEnum(["list", "add", "update", "
|
|
38
|
-
description: "
|
|
32
|
+
action: StringEnum(["list", "replace", "add", "update", "remove", "clear"] as const, {
|
|
33
|
+
description: "list current tasks; replace the plan; add one; update status; remove one; or clear all",
|
|
39
34
|
}),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
items: Type.Optional(Type.Array(Type.String(), {
|
|
36
|
+
minItems: 1,
|
|
37
|
+
description: "Complete ordered task texts for replace",
|
|
38
|
+
})),
|
|
39
|
+
text: Type.Optional(Type.String({ description: "Task text for add" })),
|
|
40
|
+
id: Type.Optional(Type.Integer({ minimum: 1, description: "Task ID for update or remove" })),
|
|
41
|
+
status: Type.Optional(StringEnum(["todo", "doing", "done", "cancelled"] as const, {
|
|
42
|
+
description: "New status for update; optional initial status for add",
|
|
44
43
|
})),
|
|
45
44
|
});
|
|
46
45
|
|
|
47
46
|
const WIDGET_KEY = "todo-list";
|
|
48
47
|
|
|
49
|
-
function normalizeTodo(todo: StoredTodo): Todo {
|
|
50
|
-
const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done"
|
|
51
|
-
? todo.status
|
|
52
|
-
: todo.done
|
|
53
|
-
? "done"
|
|
54
|
-
: "todo";
|
|
55
|
-
return { id: todo.id, text: todo.text, status };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function countStatuses(todos: Todo[]): Record<TodoStatus, number> {
|
|
59
|
-
return todos.reduce<Record<TodoStatus, number>>(
|
|
60
|
-
(counts, todo) => {
|
|
61
|
-
counts[todo.status] += 1;
|
|
62
|
-
return counts;
|
|
63
|
-
},
|
|
64
|
-
{ todo: 0, doing: 0, done: 0 },
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
48
|
function sortedTodos(todos: Todo[]): Todo[] {
|
|
69
49
|
return [...todos].sort((a, b) => a.id - b.id);
|
|
70
50
|
}
|
|
@@ -72,29 +52,39 @@ function sortedTodos(todos: Todo[]): Todo[] {
|
|
|
72
52
|
function statusMarker(status: TodoStatus): string {
|
|
73
53
|
if (status === "doing") return "◼";
|
|
74
54
|
if (status === "done") return "✔";
|
|
55
|
+
if (status === "cancelled") return "✖";
|
|
75
56
|
return "◻";
|
|
76
57
|
}
|
|
77
58
|
|
|
78
59
|
function themedStatus(theme: Theme, status: TodoStatus, text: string): string {
|
|
79
60
|
if (status === "doing") return theme.fg("warning", text);
|
|
80
61
|
if (status === "done") return theme.fg("success", text);
|
|
62
|
+
if (status === "cancelled") return theme.fg("error", text);
|
|
81
63
|
return theme.fg("dim", text);
|
|
82
64
|
}
|
|
83
65
|
|
|
84
66
|
function taskSummary(todos: Todo[]): string {
|
|
85
|
-
const counts =
|
|
86
|
-
const
|
|
87
|
-
|
|
67
|
+
const counts = countTodoStatuses(todos);
|
|
68
|
+
const active = counts.todo + counts.doing;
|
|
69
|
+
const parts = [`${active} active (${counts.doing} doing, ${counts.todo} queued)`];
|
|
70
|
+
if (counts.done > 0) parts.push(`${counts.done} done`);
|
|
71
|
+
if (counts.cancelled > 0) parts.push(`${counts.cancelled} cancelled`);
|
|
72
|
+
return parts.join(" · ");
|
|
88
73
|
}
|
|
89
74
|
|
|
90
75
|
function renderTodoLine(theme: Theme, todo: Todo): string {
|
|
91
76
|
const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
|
|
92
|
-
if (todo.status === "done") {
|
|
77
|
+
if (todo.status === "done" || todo.status === "cancelled") {
|
|
93
78
|
return `${icon} \x1b[9m${theme.fg("dim", `#${todo.id} ${todo.text}`)}\x1b[29m`;
|
|
94
79
|
}
|
|
95
80
|
return `${icon} ${theme.fg("accent", `#${todo.id}`)} ${theme.fg("text", todo.text)}`;
|
|
96
81
|
}
|
|
97
82
|
|
|
83
|
+
function renderActiveTodoLine(theme: Theme, todo: Todo, position: number): string {
|
|
84
|
+
const icon = themedStatus(theme, todo.status, statusMarker(todo.status));
|
|
85
|
+
return `${icon} ${theme.fg("accent", `${position}.`)} ${theme.fg("text", todo.text)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
98
88
|
class TodoListComponent {
|
|
99
89
|
private cachedWidth?: number;
|
|
100
90
|
private cachedLines?: string[];
|
|
@@ -143,14 +133,14 @@ class TodoListComponent {
|
|
|
143
133
|
export default function (pi: ExtensionAPI) {
|
|
144
134
|
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
145
135
|
|
|
146
|
-
let todos:
|
|
147
|
-
let nextId = 1;
|
|
136
|
+
let state: TodoState = { todos: [], nextId: 1 };
|
|
148
137
|
|
|
149
|
-
const snapshot = (): StoredTodo[] => todos.map((todo) => ({ ...todo }));
|
|
138
|
+
const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
|
|
150
139
|
|
|
151
140
|
const refreshUI = (ctx: ExtensionContext) => {
|
|
152
141
|
if (!ctx.hasUI) return;
|
|
153
|
-
|
|
142
|
+
const visible = activeTodoRows(state.todos);
|
|
143
|
+
if (visible.length === 0) {
|
|
154
144
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
155
145
|
return;
|
|
156
146
|
}
|
|
@@ -160,8 +150,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
160
150
|
(_tui, theme) => ({
|
|
161
151
|
render(width: number): string[] {
|
|
162
152
|
return [
|
|
163
|
-
truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(todos))}`, width),
|
|
164
|
-
...
|
|
153
|
+
truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(state.todos))}`, width),
|
|
154
|
+
...visible.map(({ position, todo }) => truncateToWidth(` ${renderActiveTodoLine(theme, todo, position)}`, width)),
|
|
165
155
|
];
|
|
166
156
|
},
|
|
167
157
|
invalidate() {},
|
|
@@ -170,16 +160,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
160
|
};
|
|
171
161
|
|
|
172
162
|
const reconstructState = (ctx: ExtensionContext) => {
|
|
173
|
-
|
|
174
|
-
nextId = 1;
|
|
163
|
+
state = { todos: [], nextId: 1 };
|
|
175
164
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
176
165
|
if (entry.type !== "message") continue;
|
|
177
166
|
const message = entry.message;
|
|
178
167
|
if (message.role !== "toolResult" || message.toolName !== "todo") continue;
|
|
179
168
|
const details = message.details as TodoDetails | undefined;
|
|
180
169
|
if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
|
|
181
|
-
|
|
182
|
-
nextId = details.nextId;
|
|
170
|
+
state = restoreTodoState(details.todos, details.nextId);
|
|
183
171
|
}
|
|
184
172
|
refreshUI(ctx);
|
|
185
173
|
};
|
|
@@ -187,116 +175,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
187
175
|
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
188
176
|
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
189
177
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
190
|
-
if (
|
|
191
|
-
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
178
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
192
179
|
});
|
|
193
180
|
|
|
194
181
|
pi.registerTool({
|
|
195
182
|
name: "todo",
|
|
196
183
|
label: "Todo",
|
|
197
|
-
description:
|
|
198
|
-
|
|
199
|
-
Use this tool when:
|
|
200
|
-
- The user asks for a checklist, plan, or progress tracking.
|
|
201
|
-
- Work has multiple concrete steps whose progress should remain visible.
|
|
202
|
-
- A task starts, finishes, becomes pending again, or the current state must be inspected.
|
|
203
|
-
|
|
204
|
-
Do not create a list for a single trivial action unless the user explicitly requests one. Keep task text specific and outcome-oriented. Avoid duplicate tasks.
|
|
205
|
-
|
|
206
|
-
States:
|
|
207
|
-
- todo: Planned but not started, or intentionally returned to the queue.
|
|
208
|
-
- doing: Actively being worked on now. Set this immediately before starting the task.
|
|
209
|
-
- done: Fully completed and, when applicable, verified. Never mark partial or failed work done.
|
|
210
|
-
|
|
211
|
-
Actions:
|
|
212
|
-
- list: Read the complete current list. Use before updating when IDs or current states are uncertain, and when reporting progress.
|
|
213
|
-
- add: Create one task using text. It defaults to todo; pass status only when the task is already active or completed.
|
|
214
|
-
- update: Set an exact state using id and status. This is the preferred way to move tasks between todo, doing, and done.
|
|
215
|
-
- toggle: Cycle one task todo -> doing -> done -> todo. Use only for quick manual-style transitions when the current state is already known; otherwise use update.
|
|
216
|
-
- clear: Remove the entire list and reset IDs. Use only when the user requests a reset or the whole list is obsolete.
|
|
217
|
-
|
|
218
|
-
Execution discipline:
|
|
219
|
-
- Move a task to doing before performing it.
|
|
220
|
-
- Move it to done immediately after successful completion and verification.
|
|
221
|
-
- Leave failed, blocked, or incomplete work as doing, or move it back to todo when it is no longer active.
|
|
222
|
-
- Keep only genuinely active work in doing; sequential workflows normally have one doing task at a time.`,
|
|
184
|
+
description: "Track explicit checklists and non-trivial multi-step work. Replace the plan when instructions change. The tool keeps one task doing and advances automatically; mark obsolete work cancelled and verified work done.",
|
|
223
185
|
parameters: TodoParams,
|
|
224
186
|
|
|
225
187
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
: "No todos";
|
|
234
|
-
break;
|
|
235
|
-
|
|
236
|
-
case "add": {
|
|
237
|
-
if (!params.text) {
|
|
238
|
-
error = "text required for add";
|
|
239
|
-
text = `Error: ${error}`;
|
|
240
|
-
break;
|
|
241
|
-
}
|
|
242
|
-
const todo: Todo = { id: nextId++, text: params.text, status: params.status ?? "todo" };
|
|
243
|
-
todos.push(todo);
|
|
244
|
-
text = `Added todo #${todo.id} [${todo.status}]: ${todo.text}`;
|
|
245
|
-
break;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
case "update": {
|
|
249
|
-
if (params.id === undefined || params.status === undefined) {
|
|
250
|
-
error = "id and status required for update";
|
|
251
|
-
text = `Error: ${error}`;
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
const todo = todos.find((item) => item.id === params.id);
|
|
255
|
-
if (!todo) {
|
|
256
|
-
error = `#${params.id} not found`;
|
|
257
|
-
text = `Todo ${error}`;
|
|
258
|
-
break;
|
|
259
|
-
}
|
|
260
|
-
todo.status = params.status;
|
|
261
|
-
text = `Todo #${todo.id} is now ${todo.status}`;
|
|
262
|
-
break;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
case "toggle": {
|
|
266
|
-
if (params.id === undefined) {
|
|
267
|
-
error = "id required for toggle";
|
|
268
|
-
text = `Error: ${error}`;
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
const todo = todos.find((item) => item.id === params.id);
|
|
272
|
-
if (!todo) {
|
|
273
|
-
error = `#${params.id} not found`;
|
|
274
|
-
text = `Todo ${error}`;
|
|
275
|
-
break;
|
|
276
|
-
}
|
|
277
|
-
todo.status = todo.status === "todo" ? "doing" : todo.status === "doing" ? "done" : "todo";
|
|
278
|
-
text = `Todo #${todo.id} is now ${todo.status}`;
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
case "clear": {
|
|
283
|
-
const count = todos.length;
|
|
284
|
-
todos = [];
|
|
285
|
-
nextId = 1;
|
|
286
|
-
text = `Cleared ${count} todos`;
|
|
287
|
-
break;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
188
|
+
const result = applyTodoAction(state, params);
|
|
189
|
+
state = { todos: result.todos, nextId: result.nextId };
|
|
190
|
+
const text = params.action === "list"
|
|
191
|
+
? state.todos.length
|
|
192
|
+
? sortedTodos(state.todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
|
|
193
|
+
: "No todos"
|
|
194
|
+
: result.message;
|
|
290
195
|
|
|
291
196
|
refreshUI(ctx);
|
|
292
197
|
return {
|
|
293
198
|
content: [{ type: "text" as const, text }],
|
|
294
|
-
details: {
|
|
199
|
+
details: {
|
|
200
|
+
action: params.action,
|
|
201
|
+
todos: snapshot(),
|
|
202
|
+
nextId: state.nextId,
|
|
203
|
+
...(result.error ? { error: result.error } : {}),
|
|
204
|
+
} as TodoDetails,
|
|
295
205
|
};
|
|
296
206
|
},
|
|
297
207
|
|
|
298
208
|
renderCall(args, theme) {
|
|
299
209
|
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
210
|
+
if (args.items) text += ` ${theme.fg("dim", `${args.items.length} tasks`)}`;
|
|
300
211
|
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
301
212
|
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
302
213
|
if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
|
|
@@ -314,9 +225,7 @@ Execution discipline:
|
|
|
314
225
|
if (details.action === "list") {
|
|
315
226
|
const list = details.todos.map(normalizeTodo);
|
|
316
227
|
if (list.length === 0) return new Text(theme.fg("dim", "No todos"), 0, 0);
|
|
317
|
-
|
|
318
|
-
const output = display.map((todo) => renderTodoLine(theme, todo)).join("\n");
|
|
319
|
-
return new Text(output, 0, 0);
|
|
228
|
+
return new Text(sortedTodos(list).map((todo) => renderTodoLine(theme, todo)).join("\n"), 0, 0);
|
|
320
229
|
}
|
|
321
230
|
|
|
322
231
|
const item = result.content[0];
|
|
@@ -326,14 +235,14 @@ Execution discipline:
|
|
|
326
235
|
});
|
|
327
236
|
|
|
328
237
|
pi.registerCommand("todos", {
|
|
329
|
-
description: "Show
|
|
238
|
+
description: "Show active and closed tasks in the current plan",
|
|
330
239
|
handler: async (_args, ctx) => {
|
|
331
240
|
if (ctx.mode !== "tui") {
|
|
332
241
|
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
333
242
|
return;
|
|
334
243
|
}
|
|
335
244
|
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
|
|
336
|
-
new TodoListComponent(todos, theme, () => done()),
|
|
245
|
+
new TodoListComponent(state.todos, theme, () => done()),
|
|
337
246
|
);
|
|
338
247
|
},
|
|
339
248
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: /todos, /btw, /cache_export and apply_patch extensions, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"ws": "^8.19.0"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node tests/e2e-browser.mjs && cd ../codex-edit && node --experimental-strip-types --test tests/package.test.mjs tests/parser.test.mjs",
|
|
51
|
+
"test": "cd extensions/cache-export && node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types ../tests/btw-history.mjs && node --experimental-strip-types ../tests/todo-state.mjs && node tests/e2e-browser.mjs && cd ../codex-edit && node --experimental-strip-types --test tests/package.test.mjs tests/parser.test.mjs",
|
|
52
52
|
"load-test": "pi -ne -e ./extensions/todo.ts && pi -ne -e ./extensions/cache-export/index.ts && pi -ne -e ./extensions/btw.ts && pi -ne -e ./extensions/codex-edit/index.ts",
|
|
53
53
|
"eval:agents": "node evaluations/agents-md/run.mjs",
|
|
54
54
|
"eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
|