@leo-alvarenga/pi-todo-list 0.2.0
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/LICENSE +21 -0
- package/README.md +64 -0
- package/package.json +42 -0
- package/src/command.ts +51 -0
- package/src/constants.ts +34 -0
- package/src/core.ts +257 -0
- package/src/index.ts +25 -0
- package/src/state.ts +82 -0
- package/src/tool.ts +66 -0
- package/src/types.ts +35 -0
- package/src/utils.ts +115 -0
- package/src/widget.ts +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leonardo A. Alvarenga
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# pi-todo-list
|
|
2
|
+
|
|
3
|
+
Session-aware todo list overlay for the [Pi coding agent](https://github.com/earendil-works/pi-mono).
|
|
4
|
+
The agent manages tasks with a `todo` tool while you watch them live in a panel above the input editor.
|
|
5
|
+
|
|
6
|
+
## Features
|
|
7
|
+
|
|
8
|
+
- **`todo` tool** — the agent can add tasks, update their status
|
|
9
|
+
(`pending` → `in-progress` → `completed`), declare dependencies
|
|
10
|
+
(`blockedBy`), remove, list, and clear. Invalid operations (unknown ids,
|
|
11
|
+
self-blocks, dependency cycles) are rejected before any state change.
|
|
12
|
+
- **Live TUI panel** above the input editor — `Todos (done/total)` header,
|
|
13
|
+
status glyphs (`○` pending, `◐` in-progress, `✓` completed), blocked-task
|
|
14
|
+
hints, and a hardcoded row budget with a `+N more` summary line.
|
|
15
|
+
- **Toggle with `Ctrl+Shift+T`** — collapsed shows just the header.
|
|
16
|
+
- **`/todos` command** — prints the full list grouped by status straight to
|
|
17
|
+
the terminal transcript.
|
|
18
|
+
- **Session-isolated state** — each session has its own list; it survives
|
|
19
|
+
`/reload` and context compaction with no external files, because state is
|
|
20
|
+
replayed from the session branch (tool results + custom entries), never
|
|
21
|
+
written by the extension itself.
|
|
22
|
+
- **No configuration, no localization** — all limits are hardcoded constants
|
|
23
|
+
and all text is English.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pi install ./packages/pi-todo-list
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
or add the local path / npm spec to your project `.pi/settings.json`, then
|
|
32
|
+
run `/reload`.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Tell the agent: *"track these tasks as todos: …"*, or use the tool directly:
|
|
37
|
+
|
|
38
|
+
- `todo add` with `text`
|
|
39
|
+
- `todo update` with `id` and optional `status` / `text` / `blockedBy`
|
|
40
|
+
- `todo remove` / `todo list` / `todo clear`
|
|
41
|
+
|
|
42
|
+
Run `/todos` yourself at any time to see the full grouped list.
|
|
43
|
+
|
|
44
|
+
## Keybinding
|
|
45
|
+
|
|
46
|
+
`Ctrl+Shift+T` toggles the panel. If your terminal intercepts that chord
|
|
47
|
+
(e.g. GNOME Terminal opens a new tab), pick a free one and change
|
|
48
|
+
`PANEL_TOGGLE_CHORD` in `src/constants.ts`.
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pnpm --filter @leo-alvarenga/pi-todo-list typecheck
|
|
54
|
+
pnpm --filter @leo-alvarenga/pi-todo-list test
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
To release a new version, bump `version` in `package.json`, then push a tag
|
|
58
|
+
(`git tag pi-todo-list@0.1.0 && git push origin pi-todo-list@0.1.0`). The
|
|
59
|
+
`.github/workflows/publish-pi-todo-list.yml` workflow publishes to npm
|
|
60
|
+
(requires the `NPM_TOKEN` repository secret).
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT — see [LICENSE](LICENSE). Copyright (c) 2026 Leonardo A. Alvarenga.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@leo-alvarenga/pi-todo-list",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Session-aware todo list overlay for Pi: todo tool, /todos command, and a live TUI panel",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": "github.com/leo-alvarenga/pi-mono",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-extension",
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi",
|
|
14
|
+
"todo"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"!src/*.test.ts"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-ai": "*",
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
25
|
+
"@earendil-works/pi-tui": "*",
|
|
26
|
+
"typebox": "*"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./src/index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"tsx": "^4.19.0",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc --noEmit",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { groupByStatus } from "./core";
|
|
5
|
+
import { REPORT_ENTRY } from "./constants";
|
|
6
|
+
import type { TodoStore } from "./state";
|
|
7
|
+
import type { Todo } from "./types";
|
|
8
|
+
import { getStyledTodo } from "./utils";
|
|
9
|
+
|
|
10
|
+
const renderList = (todos: Todo[], theme: Theme): string => {
|
|
11
|
+
if (todos.length === 0) return ` ${theme.fg("dim", "No todos.")}`;
|
|
12
|
+
|
|
13
|
+
const { completed, inProgress, pending } = groupByStatus(todos);
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
|
|
16
|
+
const section = (label: string, items: Todo[]): void => {
|
|
17
|
+
if (items.length === 0) return;
|
|
18
|
+
|
|
19
|
+
lines.push(` ${theme.fg("muted", `${label} (${items.length})`)}`);
|
|
20
|
+
for (const t of items) lines.push(getStyledTodo(t, todos, theme));
|
|
21
|
+
|
|
22
|
+
lines.push("");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
section("Completed", completed);
|
|
26
|
+
section("In progress", inProgress);
|
|
27
|
+
section("Pending", pending);
|
|
28
|
+
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function registerTodosCommand(pi: ExtensionAPI, store: TodoStore): void {
|
|
33
|
+
pi.registerCommand("todos", {
|
|
34
|
+
description: "Show all todos grouped by status",
|
|
35
|
+
handler: async (_args, ctx) => {
|
|
36
|
+
if (!ctx.hasUI) {
|
|
37
|
+
ctx.ui.notify("/todos requires interactive mode", "error");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
pi.appendEntry(REPORT_ENTRY, { todos: [...store.getState(ctx).todos] });
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
pi.registerEntryRenderer<{ todos: Todo[] }>(
|
|
46
|
+
REPORT_ENTRY,
|
|
47
|
+
(entry, _options, theme) => {
|
|
48
|
+
return new Text(renderList(entry.data?.todos ?? [], theme), 0, 0);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
2
|
+
import { TodoStatus, TodoStatusUi } from "./types";
|
|
3
|
+
|
|
4
|
+
/** Task statuses, in lifecycle order. All limits are hardcoded — no config files. */
|
|
5
|
+
export const STATUSES = ["pending", "in-progress", "completed"] as const;
|
|
6
|
+
|
|
7
|
+
export const STATUS_STYLES: Record<TodoStatus, TodoStatusUi> = {
|
|
8
|
+
pending: { icon: " ", fg: "text", color: "text" },
|
|
9
|
+
"in-progress": { icon: " ", fg: "accent", color: "accent", bold: true },
|
|
10
|
+
completed: { icon: " ", fg: "dim", color: "success", strikethrough: true },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Max task rows rendered in the expanded TUI widget. */
|
|
14
|
+
export const MAX_PANEL_ROWS = 8;
|
|
15
|
+
|
|
16
|
+
/** Max characters per task text. */
|
|
17
|
+
export const MAX_TEXT_LENGTH = 120;
|
|
18
|
+
|
|
19
|
+
/** Widget key for the panel above the editor. */
|
|
20
|
+
export const WIDGET_KEY = "todos";
|
|
21
|
+
|
|
22
|
+
/** Custom entry type carrying the durable state snapshot. */
|
|
23
|
+
export const STATE_ENTRY = "todos.state";
|
|
24
|
+
|
|
25
|
+
/** Custom entry type rendered by the /todos command. */
|
|
26
|
+
export const REPORT_ENTRY = "todos.report";
|
|
27
|
+
|
|
28
|
+
/** Chord that toggles the panel. Chosen in setup: free in pi's default keybindings. */
|
|
29
|
+
export const PANEL_TOGGLE_CHORD: KeyId = "alt+t";
|
|
30
|
+
|
|
31
|
+
export const PANEL_STATE_ICON = {
|
|
32
|
+
collapsed: "",
|
|
33
|
+
expanded: "",
|
|
34
|
+
};
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { MAX_TEXT_LENGTH } from "./constants";
|
|
2
|
+
import type { Todo, TodoAction, TodoState, TodoStatus } from "./types";
|
|
3
|
+
|
|
4
|
+
export type Patch = {
|
|
5
|
+
text?: string;
|
|
6
|
+
status?: TodoStatus;
|
|
7
|
+
blockedBy?: number[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ActionResult =
|
|
11
|
+
{ ok: true; state: TodoState; text: string } | { ok: false; error: string };
|
|
12
|
+
|
|
13
|
+
const clone = (todos: Todo[]): Todo[] =>
|
|
14
|
+
todos.map((t) => ({ ...t, blockedBy: [...t.blockedBy] }));
|
|
15
|
+
|
|
16
|
+
const cleanText = (text: string): string | undefined => {
|
|
17
|
+
const t = text.trim();
|
|
18
|
+
return t.length === 0 ? undefined : t;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const textError = (): { ok: false; error: string } => ({
|
|
22
|
+
ok: false,
|
|
23
|
+
error: `text is limited to ${MAX_TEXT_LENGTH} characters`,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function add(state: TodoState, text: string): ActionResult {
|
|
27
|
+
const t = cleanText(text);
|
|
28
|
+
|
|
29
|
+
if (!t) return { ok: false, error: "text required for add" };
|
|
30
|
+
if (t.length > MAX_TEXT_LENGTH) return textError();
|
|
31
|
+
|
|
32
|
+
const todo: Todo = {
|
|
33
|
+
text: t,
|
|
34
|
+
blockedBy: [],
|
|
35
|
+
id: state.nextId,
|
|
36
|
+
status: "pending",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
text: `Added todo #${todo.id}: ${todo.text}`,
|
|
42
|
+
state: { todos: [...state.todos, todo], nextId: state.nextId + 1 },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function validateBlockedBy(
|
|
47
|
+
todos: Todo[],
|
|
48
|
+
id: number,
|
|
49
|
+
blockedBy: number[],
|
|
50
|
+
): string | undefined {
|
|
51
|
+
const seen = new Set<number>();
|
|
52
|
+
|
|
53
|
+
for (const b of blockedBy) {
|
|
54
|
+
if (b === id) return "task cannot block itself";
|
|
55
|
+
|
|
56
|
+
if (!todos.some((t) => t.id === b)) {
|
|
57
|
+
return `blockedBy references unknown todo #${b}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
seen.add(b);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function update(state: TodoState, id: number, patch: Patch): ActionResult {
|
|
67
|
+
if (
|
|
68
|
+
patch.text === undefined &&
|
|
69
|
+
patch.status === undefined &&
|
|
70
|
+
patch.blockedBy === undefined
|
|
71
|
+
) {
|
|
72
|
+
return { ok: false, error: "nothing to update" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const target = state.todos.find((t) => t.id === id);
|
|
76
|
+
if (!target) return { ok: false, error: `todo #${id} not found` };
|
|
77
|
+
|
|
78
|
+
const next = clone(state.todos);
|
|
79
|
+
const idx = next.findIndex((t) => t.id === id);
|
|
80
|
+
|
|
81
|
+
if (patch.text !== undefined) {
|
|
82
|
+
const t = cleanText(patch.text);
|
|
83
|
+
|
|
84
|
+
if (!t) return { ok: false, error: "text cannot be empty" };
|
|
85
|
+
if (t.length > MAX_TEXT_LENGTH) return textError();
|
|
86
|
+
|
|
87
|
+
next[idx] = { ...next[idx], text: t };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (patch.blockedBy !== undefined) {
|
|
91
|
+
const err = validateBlockedBy(next, id, patch.blockedBy);
|
|
92
|
+
if (err) return { ok: false, error: err };
|
|
93
|
+
|
|
94
|
+
next[idx] = { ...next[idx], blockedBy: [...new Set(patch.blockedBy)] };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (patch.status !== undefined) {
|
|
98
|
+
next[idx] = { ...next[idx], status: patch.status };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (wouldCreateCycle(next, id)) {
|
|
102
|
+
return { ok: false, error: "would create a dependency cycle" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
text: `Updated todo #${id}`,
|
|
108
|
+
state: { todos: next, nextId: state.nextId },
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function remove(state: TodoState, id: number): ActionResult {
|
|
113
|
+
if (!state.todos.some((t) => t.id === id)) {
|
|
114
|
+
return { ok: false, error: `todo #${id} not found` };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const todos = state.todos
|
|
118
|
+
.filter((t) => t.id !== id)
|
|
119
|
+
.map((t) =>
|
|
120
|
+
t.blockedBy.includes(id)
|
|
121
|
+
? { ...t, blockedBy: t.blockedBy.filter((b) => b !== id) }
|
|
122
|
+
: t,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
text: `Removed todo #${id}`,
|
|
128
|
+
state: { todos, nextId: state.nextId },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const blockedSuffix = (t: Todo, todos: Todo[]): string => {
|
|
133
|
+
const waiting = t.blockedBy.filter((b) => {
|
|
134
|
+
const bt = todos.find((x) => x.id === b);
|
|
135
|
+
|
|
136
|
+
return !bt || bt.status !== "completed";
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (waiting.length > 0) {
|
|
140
|
+
return ` (blocked by ${waiting.map((b) => `#${b}`).join(", ")})`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return "";
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function list(state: TodoState): ActionResult {
|
|
147
|
+
if (state.todos.length === 0) {
|
|
148
|
+
return { ok: true, state, text: "No todos" };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
state,
|
|
153
|
+
ok: true,
|
|
154
|
+
text: state.todos
|
|
155
|
+
.map(
|
|
156
|
+
(t) =>
|
|
157
|
+
`[${t.status === "completed" ? "x" : " "}] #${t.id}: ${t.text}${blockedSuffix(t, state.todos)}`,
|
|
158
|
+
)
|
|
159
|
+
.join("\n"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function clear(): ActionResult {
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
text: "Cleared all todos",
|
|
167
|
+
state: { todos: [], nextId: 1 },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Apply a tool action against a copy of the state.
|
|
173
|
+
* Returns the new state only when validation passes; the caller commits it.
|
|
174
|
+
*/
|
|
175
|
+
export function applyAction(
|
|
176
|
+
state: TodoState,
|
|
177
|
+
params: {
|
|
178
|
+
id?: number;
|
|
179
|
+
text?: string;
|
|
180
|
+
action: TodoAction;
|
|
181
|
+
status?: TodoStatus;
|
|
182
|
+
blockedBy?: number[];
|
|
183
|
+
},
|
|
184
|
+
): ActionResult {
|
|
185
|
+
switch (params.action) {
|
|
186
|
+
case "add":
|
|
187
|
+
return add(state, params.text ?? "");
|
|
188
|
+
|
|
189
|
+
case "update":
|
|
190
|
+
if (params.id === undefined) {
|
|
191
|
+
return { ok: false, error: "id required for update" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return update(state, params.id, params);
|
|
195
|
+
|
|
196
|
+
case "remove":
|
|
197
|
+
if (params.id === undefined) {
|
|
198
|
+
return { ok: false, error: "id required for remove" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return remove(state, params.id);
|
|
202
|
+
|
|
203
|
+
case "list":
|
|
204
|
+
return list(state);
|
|
205
|
+
|
|
206
|
+
case "clear":
|
|
207
|
+
return clear();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True if `changedId` can reach itself by following blockedBy edges
|
|
213
|
+
* (direct, or through a chain) — i.e. the update would create a cycle.
|
|
214
|
+
*/
|
|
215
|
+
export function wouldCreateCycle(todos: Todo[], changedId: number): boolean {
|
|
216
|
+
const adj = new Map<number, number[]>();
|
|
217
|
+
for (const t of todos) adj.set(t.id, [...t.blockedBy]);
|
|
218
|
+
|
|
219
|
+
const done = new Set<number>();
|
|
220
|
+
const visiting = new Set<number>();
|
|
221
|
+
|
|
222
|
+
const dfs = (id: number): boolean => {
|
|
223
|
+
if (visiting.has(id)) return true;
|
|
224
|
+
if (done.has(id)) return false;
|
|
225
|
+
|
|
226
|
+
visiting.add(id);
|
|
227
|
+
|
|
228
|
+
for (const dep of adj.get(id) ?? []) {
|
|
229
|
+
if (dfs(dep)) return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
visiting.delete(id);
|
|
233
|
+
done.add(id);
|
|
234
|
+
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return dfs(changedId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function groupByStatus(todos: Todo[]): {
|
|
242
|
+
completed: Todo[];
|
|
243
|
+
inProgress: Todo[];
|
|
244
|
+
pending: Todo[];
|
|
245
|
+
} {
|
|
246
|
+
const completed: Todo[] = [];
|
|
247
|
+
const inProgress: Todo[] = [];
|
|
248
|
+
const pending: Todo[] = [];
|
|
249
|
+
|
|
250
|
+
for (const t of todos) {
|
|
251
|
+
if (t.status === "completed") completed.push(t);
|
|
252
|
+
else if (t.status === "in-progress") inProgress.push(t);
|
|
253
|
+
else pending.push(t);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { completed, inProgress, pending };
|
|
257
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { registerTodosCommand } from "./command";
|
|
4
|
+
import { STATE_ENTRY, WIDGET_KEY } from "./constants";
|
|
5
|
+
import { TodoStore } from "./state";
|
|
6
|
+
import { registerTodoTool } from "./tool";
|
|
7
|
+
import { registerTodoWidget, refreshWidget } from "./widget";
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI): void {
|
|
10
|
+
const store = new TodoStore(
|
|
11
|
+
(snapshot) => pi.appendEntry(STATE_ENTRY, snapshot),
|
|
12
|
+
(ctx) => refreshWidget(ctx, store),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
registerTodoTool(pi, store);
|
|
16
|
+
registerTodosCommand(pi, store);
|
|
17
|
+
registerTodoWidget(pi, store);
|
|
18
|
+
|
|
19
|
+
pi.on("session_start", (_event, ctx) => store.replay(ctx));
|
|
20
|
+
pi.on("session_tree", (_event, ctx) => store.replay(ctx));
|
|
21
|
+
pi.on("session_before_compact", (_event, ctx) => store.persistSnapshot(ctx));
|
|
22
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
23
|
+
if (ctx.hasUI) ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { STATE_ENTRY } from "./constants";
|
|
4
|
+
import type { TodoDetails, TodoState } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Session-scoped todo state.
|
|
8
|
+
*
|
|
9
|
+
* State is keyed by the active session id so forked/parallel sessions never
|
|
10
|
+
* overwrite each other, and it is replayed from the session branch on every
|
|
11
|
+
* session event, so it survives /reload and compaction without the extension
|
|
12
|
+
* writing any of its own files.
|
|
13
|
+
*/
|
|
14
|
+
export class TodoStore {
|
|
15
|
+
private readonly stateBySession = new Map<string, TodoState>();
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly persist: (snapshot: TodoState) => void,
|
|
19
|
+
private readonly emitChange: (ctx: ExtensionContext) => void,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
private sid(ctx: ExtensionContext): string {
|
|
23
|
+
return ctx.sessionManager.getSessionId();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getState(ctx: ExtensionContext): TodoState {
|
|
27
|
+
const sid = this.sid(ctx);
|
|
28
|
+
|
|
29
|
+
let s = this.stateBySession.get(sid);
|
|
30
|
+
if (!s) this.stateBySession.set(sid, (s = { todos: [], nextId: 1 }));
|
|
31
|
+
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Commit a validated snapshot: update memory, persist, refresh the panel. */
|
|
36
|
+
commit(ctx: ExtensionContext, next: TodoState): void {
|
|
37
|
+
this.stateBySession.set(this.sid(ctx), next);
|
|
38
|
+
this.persist(next);
|
|
39
|
+
this.emitChange(ctx);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rebuild the current session's state from the session branch (no disk writes). */
|
|
43
|
+
replay(ctx: ExtensionContext): void {
|
|
44
|
+
const s = this.getState(ctx);
|
|
45
|
+
s.todos = [];
|
|
46
|
+
s.nextId = 1;
|
|
47
|
+
|
|
48
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
49
|
+
if (entry.type === "custom" && entry.customType === STATE_ENTRY) {
|
|
50
|
+
const d = entry.data as TodoState | undefined;
|
|
51
|
+
|
|
52
|
+
if (d) {
|
|
53
|
+
s.todos = d.todos;
|
|
54
|
+
s.nextId = d.nextId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (
|
|
61
|
+
entry.type === "message" &&
|
|
62
|
+
entry.message.role === "toolResult" &&
|
|
63
|
+
entry.message.toolName === "todo"
|
|
64
|
+
) {
|
|
65
|
+
const d = entry.message.details as TodoDetails | undefined;
|
|
66
|
+
|
|
67
|
+
if (d && !d.error) {
|
|
68
|
+
s.todos = d.todos;
|
|
69
|
+
s.nextId = d.nextId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this.emitChange(ctx);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Persist the latest snapshot right before compaction so it lands after the cut point. */
|
|
78
|
+
persistSnapshot(ctx: ExtensionContext): void {
|
|
79
|
+
const s = this.getState(ctx);
|
|
80
|
+
if (s.todos.length > 0) this.persist(s);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Type } from "typebox";
|
|
5
|
+
|
|
6
|
+
import { STATUSES } from "./constants";
|
|
7
|
+
import { applyAction } from "./core";
|
|
8
|
+
import type { TodoStore } from "./state";
|
|
9
|
+
import type { TodoDetails } from "./types";
|
|
10
|
+
|
|
11
|
+
const TodoParams = Type.Object({
|
|
12
|
+
action: StringEnum(["add", "update", "remove", "list", "clear"] as const),
|
|
13
|
+
text: Type.Optional(Type.String({ description: "Task text (required for add; optional for update)" })),
|
|
14
|
+
id: Type.Optional(Type.Number({ description: "Task id (required for update/remove)" })),
|
|
15
|
+
status: Type.Optional(StringEnum([...STATUSES] as const)),
|
|
16
|
+
blockedBy: Type.Optional(Type.Array(Type.Number(), { description: "Task ids this task depends on" })),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export function registerTodoTool(pi: ExtensionAPI, store: TodoStore): void {
|
|
20
|
+
pi.registerTool({
|
|
21
|
+
name: "todo",
|
|
22
|
+
label: "Todo",
|
|
23
|
+
parameters: TodoParams,
|
|
24
|
+
description:
|
|
25
|
+
"Manage the session todo list. Actions: add (text), update (id, optional status/text/blockedBy), remove (id), list, clear",
|
|
26
|
+
|
|
27
|
+
async execute(toolCallId, params, _signal, _onUpdate, ctx) {
|
|
28
|
+
const current = store.getState(ctx);
|
|
29
|
+
const result = applyAction(current, params);
|
|
30
|
+
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: "text", text: result.error }],
|
|
34
|
+
details: {
|
|
35
|
+
action: params.action,
|
|
36
|
+
todos: [...current.todos],
|
|
37
|
+
nextId: current.nextId,
|
|
38
|
+
error: result.error,
|
|
39
|
+
} satisfies TodoDetails,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
store.commit(ctx, result.state);
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: result.text }],
|
|
46
|
+
details: { action: params.action, todos: [...result.state.todos], nextId: result.state.nextId } satisfies TodoDetails,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
renderCall(args, theme, _context) {
|
|
51
|
+
let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action);
|
|
52
|
+
if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
53
|
+
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
54
|
+
return new Text(text, 0, 0);
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
renderResult(result, _options, theme, _context) {
|
|
58
|
+
const details = result.details as TodoDetails | undefined;
|
|
59
|
+
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
60
|
+
const text = result.content[0];
|
|
61
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
62
|
+
if (details?.action === "list") return new Text(theme.fg("muted", msg), 0, 0);
|
|
63
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { STATUSES } from "./constants";
|
|
3
|
+
|
|
4
|
+
export type TodoStatus = (typeof STATUSES)[number];
|
|
5
|
+
|
|
6
|
+
export type TodoStatusUi = {
|
|
7
|
+
icon: string;
|
|
8
|
+
fg: ThemeColor;
|
|
9
|
+
bold?: boolean;
|
|
10
|
+
color: ThemeColor;
|
|
11
|
+
strikethrough?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export interface Todo {
|
|
15
|
+
id: number;
|
|
16
|
+
text: string;
|
|
17
|
+
status: TodoStatus;
|
|
18
|
+
/** Ids of tasks this task depends on. */
|
|
19
|
+
blockedBy: number[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TodoState {
|
|
23
|
+
todos: Todo[];
|
|
24
|
+
nextId: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TodoAction = "add" | "update" | "remove" | "list" | "clear";
|
|
28
|
+
|
|
29
|
+
/** Shape stored in tool-result details and the todos.state custom entry. */
|
|
30
|
+
export interface TodoDetails {
|
|
31
|
+
action: TodoAction;
|
|
32
|
+
todos: Todo[];
|
|
33
|
+
nextId: number;
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
import { Todo, TodoStatus } from "./types";
|
|
5
|
+
import { MAX_PANEL_ROWS, PANEL_STATE_ICON, STATUS_STYLES } from "./constants";
|
|
6
|
+
|
|
7
|
+
export function getStyledTodoList(
|
|
8
|
+
todos: Todo[],
|
|
9
|
+
th: Theme,
|
|
10
|
+
width: number,
|
|
11
|
+
isCollapsed?: boolean,
|
|
12
|
+
): string[] {
|
|
13
|
+
const indent = (str: string, level = 1) => " ".repeat(Math.abs(level)) + str;
|
|
14
|
+
|
|
15
|
+
const lines: string[] = [
|
|
16
|
+
truncateToWidth(
|
|
17
|
+
indent(getStyledTodoListHeader(todos, th, isCollapsed)),
|
|
18
|
+
width,
|
|
19
|
+
),
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
if (!isCollapsed) {
|
|
23
|
+
lines.push("");
|
|
24
|
+
|
|
25
|
+
if (todos.length === 0) {
|
|
26
|
+
lines.push(
|
|
27
|
+
truncateToWidth(
|
|
28
|
+
indent(th.fg("dim", "No todos yet. Ask the agent to add some!"), 2),
|
|
29
|
+
width,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
} else {
|
|
33
|
+
const visible = todos.slice(0, MAX_PANEL_ROWS);
|
|
34
|
+
|
|
35
|
+
for (const t of visible) {
|
|
36
|
+
lines.push(
|
|
37
|
+
truncateToWidth(indent(getStyledTodo(t, todos, th), 2), width),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (todos.length > visible.length) {
|
|
42
|
+
lines.push(
|
|
43
|
+
truncateToWidth(
|
|
44
|
+
indent(th.fg("dim", `… +${todos.length - visible.length} more`), 3),
|
|
45
|
+
width,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lines.push("");
|
|
53
|
+
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getStyledTodoListHeader(
|
|
58
|
+
todos: Todo[],
|
|
59
|
+
th: Theme,
|
|
60
|
+
isCollapsed?: boolean,
|
|
61
|
+
): string {
|
|
62
|
+
const count = todos.reduce<Record<TodoStatus, number>>(
|
|
63
|
+
(acc, t) => {
|
|
64
|
+
acc[t.status] = (acc[t.status] ?? 0) + 1;
|
|
65
|
+
return acc;
|
|
66
|
+
},
|
|
67
|
+
{ pending: 0, "in-progress": 0, completed: 0 },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const collapseState = isCollapsed ? "collapsed" : "expanded";
|
|
71
|
+
|
|
72
|
+
const statuses: TodoStatus[] = ["pending", "in-progress", "completed"];
|
|
73
|
+
|
|
74
|
+
const counter = statuses
|
|
75
|
+
.map((s) => {
|
|
76
|
+
const { icon, color } = STATUS_STYLES[s] ?? STATUS_STYLES.pending;
|
|
77
|
+
|
|
78
|
+
return th.fg(color, `${icon}${count[s] ?? 0}`);
|
|
79
|
+
})
|
|
80
|
+
.join(th.fg("dim", " / "));
|
|
81
|
+
|
|
82
|
+
return th.fg(
|
|
83
|
+
"accent",
|
|
84
|
+
`${PANEL_STATE_ICON[collapseState]} Todos - ${counter}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getStyledTodo(t: Todo, todos: Todo[], th: Theme): string {
|
|
89
|
+
const { icon, fg, bold, strikethrough, color } =
|
|
90
|
+
STATUS_STYLES[t.status] ?? STATUS_STYLES.pending;
|
|
91
|
+
|
|
92
|
+
const check = th.fg(color, icon);
|
|
93
|
+
const id = th.fg("accent", `#${t.id}`);
|
|
94
|
+
|
|
95
|
+
let text = th.fg(fg, t.text);
|
|
96
|
+
|
|
97
|
+
if (bold) text = th.bold(text);
|
|
98
|
+
if (strikethrough) text = th.strikethrough(text);
|
|
99
|
+
|
|
100
|
+
const waiting = t.blockedBy.filter((b) => {
|
|
101
|
+
const bt = todos.find((x) => x.id === b);
|
|
102
|
+
|
|
103
|
+
return !bt || bt.status !== "completed";
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let line = ` ${check} ${id} ${text}`;
|
|
107
|
+
if (waiting.length > 0) {
|
|
108
|
+
line += th.fg(
|
|
109
|
+
"dim",
|
|
110
|
+
` (blocked by ${waiting.map((b) => `#${b}`).join(", ")})`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return line;
|
|
115
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { PANEL_TOGGLE_CHORD, WIDGET_KEY } from "./constants";
|
|
8
|
+
import type { TodoStore } from "./state";
|
|
9
|
+
import type { TodoState } from "./types";
|
|
10
|
+
import { getStyledTodoList } from "./utils";
|
|
11
|
+
|
|
12
|
+
let collapsed = true;
|
|
13
|
+
const hideIfEmpty = true;
|
|
14
|
+
|
|
15
|
+
class TodoWidget {
|
|
16
|
+
private cachedWidth: number | undefined;
|
|
17
|
+
private cachedLines: string[] | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly theme: Theme,
|
|
21
|
+
private readonly snapshot: () => TodoState,
|
|
22
|
+
private readonly isCollapsed: () => boolean,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
render(width: number): string[] {
|
|
26
|
+
if (hideIfEmpty && this.snapshot().todos.length === 0) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (this.cachedLines !== undefined && this.cachedWidth === width) {
|
|
31
|
+
return this.cachedLines;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lines = getStyledTodoList(
|
|
35
|
+
this.snapshot().todos,
|
|
36
|
+
this.theme,
|
|
37
|
+
width,
|
|
38
|
+
this.isCollapsed(),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
this.cachedWidth = width;
|
|
42
|
+
this.cachedLines = lines;
|
|
43
|
+
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
invalidate(): void {
|
|
48
|
+
this.cachedWidth = undefined;
|
|
49
|
+
this.cachedLines = undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function refreshWidget(ctx: ExtensionContext, store: TodoStore): void {
|
|
54
|
+
if (!ctx.hasUI) return;
|
|
55
|
+
|
|
56
|
+
ctx.ui.setWidget(
|
|
57
|
+
WIDGET_KEY,
|
|
58
|
+
(_tui, theme) =>
|
|
59
|
+
new TodoWidget(
|
|
60
|
+
theme,
|
|
61
|
+
() => store.getState(ctx),
|
|
62
|
+
() => collapsed,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerTodoWidget(pi: ExtensionAPI, store: TodoStore): void {
|
|
68
|
+
pi.registerShortcut(PANEL_TOGGLE_CHORD, {
|
|
69
|
+
description: "Toggle todos panel",
|
|
70
|
+
handler: (ctx) => {
|
|
71
|
+
collapsed = !collapsed;
|
|
72
|
+
|
|
73
|
+
refreshWidget(ctx, store);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|