@maxiaochao/pi-toolkit 0.9.0 → 0.9.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.
- package/CHANGELOG.md +4 -0
- package/README.md +2 -0
- package/extensions/tests/todo-state.mjs +84 -0
- package/extensions/todo-state.ts +151 -0
- package/extensions/todo.ts +60 -156
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2026-09-30 - v0.9.1
|
|
2
|
+
|
|
3
|
+
- redesign the todo lifecycle with plan replacement, cancellation, automatic advancement, active-only widget rendering, concise model guidance, and deterministic state tests
|
|
4
|
+
|
|
1
5
|
## 2026-09-30 - v0.9.0
|
|
2
6
|
|
|
3
7
|
- 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 shows active work only, and `/todos` shows active and closed tasks in the current plan.
|
|
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,84 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {
|
|
3
|
+
activeTodos,
|
|
4
|
+
applyTodoAction,
|
|
5
|
+
restoreTodoState,
|
|
6
|
+
} from "../todo-state.ts";
|
|
7
|
+
|
|
8
|
+
const empty = { todos: [], nextId: 1 };
|
|
9
|
+
|
|
10
|
+
const replaced = applyTodoAction(empty, {
|
|
11
|
+
action: "replace",
|
|
12
|
+
items: ["Inspect the failure", "Implement the fix", "Run verification"],
|
|
13
|
+
});
|
|
14
|
+
assert.deepEqual(
|
|
15
|
+
replaced.todos.map(({ id, text, status }) => ({ id, text, status })),
|
|
16
|
+
[
|
|
17
|
+
{ id: 1, text: "Inspect the failure", status: "doing" },
|
|
18
|
+
{ id: 2, text: "Implement the fix", status: "todo" },
|
|
19
|
+
{ id: 3, text: "Run verification", status: "todo" },
|
|
20
|
+
],
|
|
21
|
+
"replace installs a fresh ordered plan and starts its first task",
|
|
22
|
+
);
|
|
23
|
+
assert.equal(replaced.nextId, 4, "replace advances the ID sequence past the new plan");
|
|
24
|
+
|
|
25
|
+
const completed = applyTodoAction(replaced, { action: "update", id: 1, status: "done" });
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
completed.todos.map(({ id, status }) => ({ id, status })),
|
|
28
|
+
[
|
|
29
|
+
{ id: 1, status: "done" },
|
|
30
|
+
{ id: 2, status: "doing" },
|
|
31
|
+
{ id: 3, status: "todo" },
|
|
32
|
+
],
|
|
33
|
+
"finishing active work advances the next queued task",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const cancelled = applyTodoAction(completed, { action: "update", id: 2, status: "cancelled" });
|
|
37
|
+
assert.equal(cancelled.todos[2].status, "doing", "cancelling active work advances the next queued task");
|
|
38
|
+
assert.deepEqual(activeTodos(cancelled.todos).map((todo) => todo.id), [3], "closed work is absent from the active view");
|
|
39
|
+
|
|
40
|
+
const restarted = applyTodoAction(cancelled, { action: "add", text: "Review the redirected implementation", status: "doing" });
|
|
41
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 3).status, "todo", "starting another task demotes the previous active task");
|
|
42
|
+
assert.equal(restarted.todos.find((todo) => todo.id === 4).status, "doing", "the requested task becomes active");
|
|
43
|
+
|
|
44
|
+
const removed = applyTodoAction(restarted, { action: "remove", id: 4 });
|
|
45
|
+
assert.equal(removed.todos.find((todo) => todo.id === 3).status, "doing", "removing active work advances the queue");
|
|
46
|
+
assert.equal(removed.nextId, 5, "removing a task does not reuse its ID");
|
|
47
|
+
|
|
48
|
+
const duplicate = applyTodoAction(removed, { action: "add", text: "Run verification" });
|
|
49
|
+
assert.match(duplicate.error ?? "", /already exists/, "active duplicates are rejected");
|
|
50
|
+
assert.deepEqual(duplicate.todos, removed.todos, "a rejected mutation preserves the previous state");
|
|
51
|
+
|
|
52
|
+
const replacedAgain = applyTodoAction(removed, { action: "replace", items: ["New direction"] });
|
|
53
|
+
assert.deepEqual(
|
|
54
|
+
replacedAgain.todos,
|
|
55
|
+
[{ id: 5, text: "New direction", status: "doing" }],
|
|
56
|
+
"replace discards the obsolete plan without reusing an old task ID",
|
|
57
|
+
);
|
|
58
|
+
assert.equal(replacedAgain.nextId, 6);
|
|
59
|
+
|
|
60
|
+
const invalidReplace = applyTodoAction(replacedAgain, { action: "replace", items: [" "] });
|
|
61
|
+
assert.ok(invalidReplace.error, "replace rejects empty task text");
|
|
62
|
+
assert.deepEqual(invalidReplace.todos, replacedAgain.todos, "invalid replacement is atomic");
|
|
63
|
+
|
|
64
|
+
const restored = restoreTodoState([
|
|
65
|
+
{ id: 4, text: "legacy complete", done: true },
|
|
66
|
+
{ id: 2, text: "first active", status: "doing" },
|
|
67
|
+
{ id: 3, text: "second active", status: "doing" },
|
|
68
|
+
], 2);
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
restored.todos.map(({ id, status }) => ({ id, status })),
|
|
71
|
+
[
|
|
72
|
+
{ id: 2, status: "doing" },
|
|
73
|
+
{ id: 3, status: "todo" },
|
|
74
|
+
{ id: 4, status: "done" },
|
|
75
|
+
],
|
|
76
|
+
"restoration migrates legacy entries and enforces one active task",
|
|
77
|
+
);
|
|
78
|
+
assert.equal(restored.nextId, 5, "restoration repairs stale next IDs");
|
|
79
|
+
|
|
80
|
+
const cleared = applyTodoAction(restored, { action: "clear" });
|
|
81
|
+
assert.deepEqual(cleared.todos, []);
|
|
82
|
+
assert.equal(cleared.nextId, 1, "clear resets the ID sequence");
|
|
83
|
+
|
|
84
|
+
console.log("Todo state tests passed");
|
|
@@ -0,0 +1,151 @@
|
|
|
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 TodoActionInput {
|
|
23
|
+
action: TodoAction;
|
|
24
|
+
text?: string;
|
|
25
|
+
items?: string[];
|
|
26
|
+
id?: number;
|
|
27
|
+
status?: TodoStatus;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TodoActionResult extends TodoState {
|
|
31
|
+
message: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TodoCounts = Record<TodoStatus, number>;
|
|
36
|
+
|
|
37
|
+
export function normalizeTodo(todo: StoredTodo): Todo {
|
|
38
|
+
const status = todo.status === "todo" || todo.status === "doing" || todo.status === "done" || todo.status === "cancelled"
|
|
39
|
+
? todo.status
|
|
40
|
+
: todo.done
|
|
41
|
+
? "done"
|
|
42
|
+
: "todo";
|
|
43
|
+
return { id: todo.id, text: todo.text, status };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeTodoFlow(todos: Todo[]): void {
|
|
47
|
+
const active = todos.filter((todo) => todo.status === "doing");
|
|
48
|
+
for (const todo of active.slice(1)) todo.status = "todo";
|
|
49
|
+
if (active.length > 0) return;
|
|
50
|
+
|
|
51
|
+
const next = todos.find((todo) => todo.status === "todo");
|
|
52
|
+
if (next) next.status = "doing";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function restoreTodoState(stored: StoredTodo[], nextId?: number): TodoState {
|
|
56
|
+
const todos = stored.map(normalizeTodo).sort((a, b) => a.id - b.id);
|
|
57
|
+
normalizeTodoFlow(todos);
|
|
58
|
+
const minimumNextId = todos.reduce((max, todo) => Math.max(max, todo.id + 1), 1);
|
|
59
|
+
return { todos, nextId: Math.max(nextId ?? 1, minimumNextId) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function countTodoStatuses(todos: Todo[]): TodoCounts {
|
|
63
|
+
return todos.reduce<TodoCounts>(
|
|
64
|
+
(counts, todo) => {
|
|
65
|
+
counts[todo.status] += 1;
|
|
66
|
+
return counts;
|
|
67
|
+
},
|
|
68
|
+
{ todo: 0, doing: 0, done: 0, cancelled: 0 },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function activeTodos(todos: Todo[]): Todo[] {
|
|
73
|
+
return todos.filter((todo) => todo.status === "doing" || todo.status === "todo");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cloneState(state: TodoState): TodoState {
|
|
77
|
+
return { todos: state.todos.map((todo) => ({ ...todo })), nextId: state.nextId };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function fail(state: TodoState, error: string): TodoActionResult {
|
|
81
|
+
return { ...cloneState(state), message: `Error: ${error}`, error };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cleanText(text: string | undefined): string | undefined {
|
|
85
|
+
const cleaned = text?.trim();
|
|
86
|
+
return cleaned ? cleaned : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasActiveDuplicate(todos: Todo[], text: string): boolean {
|
|
90
|
+
return todos.some((todo) => (todo.status === "todo" || todo.status === "doing") && todo.text === text);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function applyTodoAction(state: TodoState, input: TodoActionInput): TodoActionResult {
|
|
94
|
+
const next = cloneState(state);
|
|
95
|
+
|
|
96
|
+
switch (input.action) {
|
|
97
|
+
case "list":
|
|
98
|
+
return { ...next, message: next.todos.length ? "Listed todos" : "No todos" };
|
|
99
|
+
|
|
100
|
+
case "replace": {
|
|
101
|
+
if (!input.items?.length) return fail(state, "items required for replace");
|
|
102
|
+
const items = input.items.map(cleanText);
|
|
103
|
+
if (items.some((item) => item === undefined)) return fail(state, "replace items must not be empty");
|
|
104
|
+
const texts = items as string[];
|
|
105
|
+
if (new Set(texts).size !== texts.length) return fail(state, "replace items must be unique");
|
|
106
|
+
const firstId = next.nextId;
|
|
107
|
+
next.todos = texts.map((text, index) => ({ id: firstId + index, text, status: index === 0 ? "doing" : "todo" }));
|
|
108
|
+
next.nextId += next.todos.length;
|
|
109
|
+
return { ...next, message: `Replaced plan with ${next.todos.length} tasks` };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case "add": {
|
|
113
|
+
const text = cleanText(input.text);
|
|
114
|
+
if (!text) return fail(state, "text required for add");
|
|
115
|
+
if (hasActiveDuplicate(next.todos, text)) return fail(state, `active task already exists: ${text}`);
|
|
116
|
+
if (input.status === "doing") {
|
|
117
|
+
for (const todo of next.todos) if (todo.status === "doing") todo.status = "todo";
|
|
118
|
+
}
|
|
119
|
+
const todo: Todo = { id: next.nextId++, text, status: input.status ?? "todo" };
|
|
120
|
+
next.todos.push(todo);
|
|
121
|
+
normalizeTodoFlow(next.todos);
|
|
122
|
+
return { ...next, message: `Added todo #${todo.id} [${todo.status}]: ${todo.text}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case "update": {
|
|
126
|
+
if (input.id === undefined || input.status === undefined) return fail(state, "id and status required for update");
|
|
127
|
+
const todo = next.todos.find((item) => item.id === input.id);
|
|
128
|
+
if (!todo) return fail(state, `#${input.id} not found`);
|
|
129
|
+
if (input.status === "doing") {
|
|
130
|
+
for (const item of next.todos) if (item.status === "doing") item.status = "todo";
|
|
131
|
+
}
|
|
132
|
+
todo.status = input.status;
|
|
133
|
+
normalizeTodoFlow(next.todos);
|
|
134
|
+
return { ...next, message: `Todo #${todo.id} is now ${todo.status}` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
case "remove": {
|
|
138
|
+
if (input.id === undefined) return fail(state, "id required for remove");
|
|
139
|
+
const index = next.todos.findIndex((item) => item.id === input.id);
|
|
140
|
+
if (index < 0) return fail(state, `#${input.id} not found`);
|
|
141
|
+
const [removed] = next.todos.splice(index, 1);
|
|
142
|
+
normalizeTodoFlow(next.todos);
|
|
143
|
+
return { ...next, message: `Removed todo #${removed.id}: ${removed.text}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case "clear": {
|
|
147
|
+
const count = next.todos.length;
|
|
148
|
+
return { todos: [], nextId: 1, message: `Cleared ${count} todos` };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
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
|
+
activeTodos,
|
|
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,24 +52,29 @@ 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)}`;
|
|
@@ -143,14 +128,14 @@ class TodoListComponent {
|
|
|
143
128
|
export default function (pi: ExtensionAPI) {
|
|
144
129
|
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
145
130
|
|
|
146
|
-
let todos:
|
|
147
|
-
let nextId = 1;
|
|
131
|
+
let state: TodoState = { todos: [], nextId: 1 };
|
|
148
132
|
|
|
149
|
-
const snapshot = (): StoredTodo[] => todos.map((todo) => ({ ...todo }));
|
|
133
|
+
const snapshot = (): StoredTodo[] => state.todos.map((todo) => ({ ...todo }));
|
|
150
134
|
|
|
151
135
|
const refreshUI = (ctx: ExtensionContext) => {
|
|
152
136
|
if (!ctx.hasUI) return;
|
|
153
|
-
|
|
137
|
+
const visible = activeTodos(state.todos);
|
|
138
|
+
if (visible.length === 0) {
|
|
154
139
|
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
155
140
|
return;
|
|
156
141
|
}
|
|
@@ -160,8 +145,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
160
145
|
(_tui, theme) => ({
|
|
161
146
|
render(width: number): string[] {
|
|
162
147
|
return [
|
|
163
|
-
truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(todos))}`, width),
|
|
164
|
-
...sortedTodos(
|
|
148
|
+
truncateToWidth(`${theme.fg("accent", "●")} ${theme.fg("muted", taskSummary(state.todos))}`, width),
|
|
149
|
+
...sortedTodos(visible).map((todo) => truncateToWidth(` ${renderTodoLine(theme, todo)}`, width)),
|
|
165
150
|
];
|
|
166
151
|
},
|
|
167
152
|
invalidate() {},
|
|
@@ -170,16 +155,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
155
|
};
|
|
171
156
|
|
|
172
157
|
const reconstructState = (ctx: ExtensionContext) => {
|
|
173
|
-
|
|
174
|
-
nextId = 1;
|
|
158
|
+
state = { todos: [], nextId: 1 };
|
|
175
159
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
176
160
|
if (entry.type !== "message") continue;
|
|
177
161
|
const message = entry.message;
|
|
178
162
|
if (message.role !== "toolResult" || message.toolName !== "todo") continue;
|
|
179
163
|
const details = message.details as TodoDetails | undefined;
|
|
180
164
|
if (!details || !Array.isArray(details.todos) || typeof details.nextId !== "number") continue;
|
|
181
|
-
|
|
182
|
-
nextId = details.nextId;
|
|
165
|
+
state = restoreTodoState(details.todos, details.nextId);
|
|
183
166
|
}
|
|
184
167
|
refreshUI(ctx);
|
|
185
168
|
};
|
|
@@ -187,116 +170,39 @@ export default function (pi: ExtensionAPI) {
|
|
|
187
170
|
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
188
171
|
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
189
172
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
190
|
-
if (
|
|
191
|
-
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
173
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
192
174
|
});
|
|
193
175
|
|
|
194
176
|
pi.registerTool({
|
|
195
177
|
name: "todo",
|
|
196
178
|
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.`,
|
|
179
|
+
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
180
|
parameters: TodoParams,
|
|
224
181
|
|
|
225
182
|
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
|
-
}
|
|
183
|
+
const result = applyTodoAction(state, params);
|
|
184
|
+
state = { todos: result.todos, nextId: result.nextId };
|
|
185
|
+
const text = params.action === "list"
|
|
186
|
+
? state.todos.length
|
|
187
|
+
? sortedTodos(state.todos).map((todo) => `${statusMarker(todo.status)} #${todo.id} [${todo.status}] ${todo.text}`).join("\n")
|
|
188
|
+
: "No todos"
|
|
189
|
+
: result.message;
|
|
290
190
|
|
|
291
191
|
refreshUI(ctx);
|
|
292
192
|
return {
|
|
293
193
|
content: [{ type: "text" as const, text }],
|
|
294
|
-
details: {
|
|
194
|
+
details: {
|
|
195
|
+
action: params.action,
|
|
196
|
+
todos: snapshot(),
|
|
197
|
+
nextId: state.nextId,
|
|
198
|
+
...(result.error ? { error: result.error } : {}),
|
|
199
|
+
} as TodoDetails,
|
|
295
200
|
};
|
|
296
201
|
},
|
|
297
202
|
|
|
298
203
|
renderCall(args, theme) {
|
|
299
204
|
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
205
|
+
if (args.items) text += ` ${theme.fg("dim", `${args.items.length} tasks`)}`;
|
|
300
206
|
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
301
207
|
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
302
208
|
if (args.status) text += ` ${themedStatus(theme, args.status, args.status)}`;
|
|
@@ -314,9 +220,7 @@ Execution discipline:
|
|
|
314
220
|
if (details.action === "list") {
|
|
315
221
|
const list = details.todos.map(normalizeTodo);
|
|
316
222
|
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);
|
|
223
|
+
return new Text(sortedTodos(list).map((todo) => renderTodoLine(theme, todo)).join("\n"), 0, 0);
|
|
320
224
|
}
|
|
321
225
|
|
|
322
226
|
const item = result.content[0];
|
|
@@ -326,14 +230,14 @@ Execution discipline:
|
|
|
326
230
|
});
|
|
327
231
|
|
|
328
232
|
pi.registerCommand("todos", {
|
|
329
|
-
description: "Show
|
|
233
|
+
description: "Show active and closed tasks in the current plan",
|
|
330
234
|
handler: async (_args, ctx) => {
|
|
331
235
|
if (ctx.mode !== "tui") {
|
|
332
236
|
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
333
237
|
return;
|
|
334
238
|
}
|
|
335
239
|
await ctx.ui.custom<void>((_tui, theme, _keybindings, done) =>
|
|
336
|
-
new TodoListComponent(todos, theme, () => done()),
|
|
240
|
+
new TodoListComponent(state.todos, theme, () => done()),
|
|
337
241
|
);
|
|
338
242
|
},
|
|
339
243
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
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",
|