@agnishc/edb-todo 0.1.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/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/package.json +28 -0
- package/src/component.ts +156 -0
- package/src/index.ts +148 -0
- package/src/prompt.ts +49 -0
- package/src/schemas.ts +35 -0
- package/src/state.ts +95 -0
- package/src/types.ts +29 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Initial release: `todo_write` and `todo_read` tools
|
|
7
|
+
- Live widget above editor showing up to 4 active tasks
|
|
8
|
+
- System-prompt injection before every agent turn to prevent goal drift
|
|
9
|
+
- `/todos` command with full-screen interactive viewer and progress bar
|
|
10
|
+
- Session branch reconstruction so task state survives `/tree` navigation and forking
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agnish Chakraborty
|
|
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,41 @@
|
|
|
1
|
+
# @agnishc/edb-todo
|
|
2
|
+
|
|
3
|
+
A Pi CLI extension that gives the agent a structured task list to prevent **goal drift** — the tendency for agents to lose track of the original plan as context grows and tool calls accumulate.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
1. The agent calls `todo_write` to plan multi-step work as an explicit task list
|
|
8
|
+
2. Before every agent turn, active tasks are injected into the system prompt so the model always knows what remains and what it's currently doing
|
|
9
|
+
3. A live widget above the editor shows the current task list at all times
|
|
10
|
+
4. State is reconstructed from the session branch, so `/tree` navigation and forking work correctly
|
|
11
|
+
|
|
12
|
+
## Tools
|
|
13
|
+
|
|
14
|
+
| Tool | Description |
|
|
15
|
+
|------|-------------|
|
|
16
|
+
| `todo_write` | Replace the entire task list (atomic update) — always pass all tasks |
|
|
17
|
+
| `todo_read` | Read the current task list and statuses |
|
|
18
|
+
|
|
19
|
+
## Task statuses
|
|
20
|
+
|
|
21
|
+
| Status | Icon | Meaning |
|
|
22
|
+
|--------|------|---------|
|
|
23
|
+
| `pending` | `○` | Not started |
|
|
24
|
+
| `in_progress` | `→` | Actively working — only one at a time |
|
|
25
|
+
| `completed` | `✓` | Done |
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install npm:@agnishc/edb-todo
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
/todos — open the interactive full-screen task viewer
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
[MIT](LICENSE) © Agnish Chakraborty
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agnishc/edb-todo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: structured task list with live widget and system-prompt injection to prevent goal drift",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "edb"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Agnish Chakraborty",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/agnishcc/pi-extention-monorepo.git",
|
|
12
|
+
"directory": "packages/edb-todo"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-todo#readme",
|
|
15
|
+
"bugs": { "url": "https://github.com/agnishcc/pi-extention-monorepo/issues" },
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"scripts": { "test": "vitest run" },
|
|
18
|
+
"files": ["src", "README.md", "LICENSE", "CHANGELOG.md"],
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": ["./src/index.ts"]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@mariozechner/pi-ai": "*",
|
|
24
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
25
|
+
"@mariozechner/pi-tui": "*",
|
|
26
|
+
"typebox": "*"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/component.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
2
|
+
import type { Task, TaskDetails } from "./types";
|
|
3
|
+
import { PRIORITY_ORDER, STATUS_ICON } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── /todos interactive viewer ──────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export class TodoViewComponent {
|
|
8
|
+
private cachedWidth?: number;
|
|
9
|
+
private cachedLines?: string[];
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly tasks: Task[],
|
|
13
|
+
private readonly theme: any,
|
|
14
|
+
private readonly onClose: () => void,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
handleInput(data: string): void {
|
|
18
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
19
|
+
this.onClose();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
render(width: number): string[] {
|
|
24
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
25
|
+
|
|
26
|
+
const lines: string[] = [];
|
|
27
|
+
const th = this.theme;
|
|
28
|
+
|
|
29
|
+
// ── Header ──
|
|
30
|
+
lines.push("");
|
|
31
|
+
const titleText = " Tasks ";
|
|
32
|
+
const sideLen = Math.max(0, width - titleText.length - 3);
|
|
33
|
+
const headerLine =
|
|
34
|
+
th.fg("borderMuted", "─".repeat(3)) + th.fg("accent", titleText) + th.fg("borderMuted", "─".repeat(sideLen));
|
|
35
|
+
lines.push(truncateToWidth(headerLine, width));
|
|
36
|
+
lines.push("");
|
|
37
|
+
|
|
38
|
+
if (this.tasks.length === 0) {
|
|
39
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to plan the work.")}`, width));
|
|
40
|
+
} else {
|
|
41
|
+
// ── Progress bar ──
|
|
42
|
+
const completedCount = this.tasks.filter((t) => t.status === "completed").length;
|
|
43
|
+
const total = this.tasks.length;
|
|
44
|
+
const barWidth = Math.min(24, width - 20);
|
|
45
|
+
const filled = total > 0 ? Math.round((completedCount / total) * barWidth) : 0;
|
|
46
|
+
const empty = barWidth - filled;
|
|
47
|
+
const bar = `[${th.fg("success", "█".repeat(filled))}${th.fg("dim", "░".repeat(empty))}]`;
|
|
48
|
+
lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${completedCount}/${total} done`)}`, width));
|
|
49
|
+
lines.push("");
|
|
50
|
+
|
|
51
|
+
// ── In Progress ──
|
|
52
|
+
const inProgress = this.tasks.filter((t) => t.status === "in_progress");
|
|
53
|
+
if (inProgress.length > 0) {
|
|
54
|
+
lines.push(truncateToWidth(` ${th.fg("accent", "In Progress")}`, width));
|
|
55
|
+
for (const t of inProgress) lines.push(...this.renderTask(t, width));
|
|
56
|
+
lines.push("");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Pending (sorted by priority) ──
|
|
60
|
+
const pending = this.tasks
|
|
61
|
+
.filter((t) => t.status === "pending")
|
|
62
|
+
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
63
|
+
if (pending.length > 0) {
|
|
64
|
+
lines.push(truncateToWidth(` ${th.fg("muted", "Pending")}`, width));
|
|
65
|
+
for (const t of pending) lines.push(...this.renderTask(t, width));
|
|
66
|
+
lines.push("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Completed ──
|
|
70
|
+
const done = this.tasks.filter((t) => t.status === "completed");
|
|
71
|
+
if (done.length > 0) {
|
|
72
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Completed")}`, width));
|
|
73
|
+
for (const t of done) lines.push(...this.renderTask(t, width));
|
|
74
|
+
lines.push("");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Footer ──
|
|
79
|
+
lines.push(truncateToWidth(th.fg("borderMuted", "─".repeat(width)), width));
|
|
80
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
|
81
|
+
lines.push("");
|
|
82
|
+
|
|
83
|
+
this.cachedWidth = width;
|
|
84
|
+
this.cachedLines = lines;
|
|
85
|
+
return lines;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private renderTask(task: Task, width: number): string[] {
|
|
89
|
+
const th = this.theme;
|
|
90
|
+
|
|
91
|
+
const icon =
|
|
92
|
+
task.status === "completed"
|
|
93
|
+
? th.fg("success", STATUS_ICON.completed)
|
|
94
|
+
: task.status === "in_progress"
|
|
95
|
+
? th.fg("accent", STATUS_ICON.in_progress)
|
|
96
|
+
: th.fg("dim", STATUS_ICON.pending);
|
|
97
|
+
|
|
98
|
+
const priorityColor = task.priority === "high" ? "error" : task.priority === "medium" ? "warning" : "dim";
|
|
99
|
+
const pLabel = th.fg(priorityColor, task.priority.toUpperCase().slice(0, 3));
|
|
100
|
+
|
|
101
|
+
const contentText =
|
|
102
|
+
task.status === "completed"
|
|
103
|
+
? th.fg("dim", th.strikethrough(task.content))
|
|
104
|
+
: task.status === "in_progress"
|
|
105
|
+
? th.fg("text", th.bold(task.content))
|
|
106
|
+
: th.fg("muted", task.content);
|
|
107
|
+
|
|
108
|
+
const idHint = th.fg("dim", ` [${task.id}]`);
|
|
109
|
+
|
|
110
|
+
return [truncateToWidth(` ${icon} ${pLabel} ${contentText}${idHint}`, width)];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Tool result rendering ──────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
static renderTaskResult(details: TaskDetails | undefined, expanded: boolean, theme: any): any {
|
|
116
|
+
if (!details?.tasks?.length) {
|
|
117
|
+
return new Text(theme.fg("dim", "Task list cleared"), 0, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const list = details.tasks;
|
|
121
|
+
const doneCount = list.filter((t) => t.status === "completed").length;
|
|
122
|
+
let output = theme.fg("muted", `${doneCount}/${list.length} completed`);
|
|
123
|
+
|
|
124
|
+
const display = expanded ? list : list.slice(0, 5);
|
|
125
|
+
for (const t of display) {
|
|
126
|
+
const icon =
|
|
127
|
+
t.status === "completed"
|
|
128
|
+
? theme.fg("success", STATUS_ICON.completed)
|
|
129
|
+
: t.status === "in_progress"
|
|
130
|
+
? theme.fg("accent", STATUS_ICON.in_progress)
|
|
131
|
+
: theme.fg("dim", STATUS_ICON.pending);
|
|
132
|
+
|
|
133
|
+
const pColor = t.priority === "high" ? "error" : t.priority === "medium" ? "warning" : "dim";
|
|
134
|
+
const pLabel = theme.fg(pColor, t.priority.toUpperCase().slice(0, 3));
|
|
135
|
+
const content =
|
|
136
|
+
t.status === "completed"
|
|
137
|
+
? theme.fg("dim", theme.strikethrough(t.content))
|
|
138
|
+
: t.status === "in_progress"
|
|
139
|
+
? theme.fg("text", theme.bold(t.content))
|
|
140
|
+
: theme.fg("muted", t.content);
|
|
141
|
+
|
|
142
|
+
output += `\n${icon} ${pLabel} ${content}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!expanded && list.length > 5) {
|
|
146
|
+
output += `\n${theme.fg("dim", `... ${list.length - 5} more`)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return new Text(output, 0, 0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
invalidate(): void {
|
|
153
|
+
this.cachedWidth = undefined;
|
|
154
|
+
this.cachedLines = undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-todo
|
|
3
|
+
*
|
|
4
|
+
* Task management extension that prevents "goal drift" — the tendency for
|
|
5
|
+
* agents to lose track of the original plan as context grows.
|
|
6
|
+
*
|
|
7
|
+
* How it works:
|
|
8
|
+
* 1. The agent uses `todo_write` to plan multi-step work as structured tasks
|
|
9
|
+
* 2. Before every agent turn, active tasks are injected into the system prompt
|
|
10
|
+
* 3. A live widget above the editor shows the task list to the user
|
|
11
|
+
* 4. State is stored in tool-result details and reconstructed from the session
|
|
12
|
+
* branch, so /tree navigation and forking work correctly
|
|
13
|
+
*
|
|
14
|
+
* Tools: todo_write — replace the entire task list (atomic update)
|
|
15
|
+
* todo_read — read the current task list
|
|
16
|
+
* Command: /todos — open interactive full-screen task viewer
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
20
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
21
|
+
import { Type } from "typebox";
|
|
22
|
+
import { TodoViewComponent } from "./component";
|
|
23
|
+
import { buildSystemPromptBlock, formatListForLLM } from "./prompt";
|
|
24
|
+
import { TodoWriteParams } from "./schemas";
|
|
25
|
+
import { generateId, reconstructState, setTasks, syncIdCounter, tasks, updateWidget } from "./state";
|
|
26
|
+
import type { TaskDetails, TaskPriority, TaskStatus } from "./types";
|
|
27
|
+
|
|
28
|
+
// ── Extension ──────────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export default function todoExtension(pi: ExtensionAPI): void {
|
|
31
|
+
// ── Session lifecycle ──────────────────────────────────────────────────
|
|
32
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
33
|
+
reconstructState(ctx);
|
|
34
|
+
updateWidget(ctx);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
38
|
+
reconstructState(ctx);
|
|
39
|
+
updateWidget(ctx);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── System-prompt injection ────────────────────────────────────────────
|
|
43
|
+
// Active tasks are injected at the start of every agent turn so the model
|
|
44
|
+
// always knows its current plan — the core mechanism that prevents goal drift.
|
|
45
|
+
pi.on("before_agent_start", async (event, _ctx) => {
|
|
46
|
+
const block = buildSystemPromptBlock();
|
|
47
|
+
if (!block) return;
|
|
48
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${block}` };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── Post-turn widget refresh ───────────────────────────────────────────
|
|
52
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
53
|
+
updateWidget(ctx);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Tool: todo_write ───────────────────────────────────────────────────
|
|
57
|
+
pi.registerTool({
|
|
58
|
+
name: "todo_write",
|
|
59
|
+
label: "Tasks",
|
|
60
|
+
description:
|
|
61
|
+
"Write and manage your task list for complex, multi-step work. " +
|
|
62
|
+
"Provide the COMPLETE updated list — this REPLACES the current list entirely.",
|
|
63
|
+
promptSnippet:
|
|
64
|
+
"Create and update a structured task list with statuses (pending / in_progress / completed) and priorities",
|
|
65
|
+
promptGuidelines: [
|
|
66
|
+
"Use todo_write at the start of any complex, multi-step task to break the work into a clear plan. " +
|
|
67
|
+
"Tasks should be specific and atomic (one concrete action each).",
|
|
68
|
+
"Before starting a task, call todo_write to set it to 'in_progress'. " +
|
|
69
|
+
"Only one task should be 'in_progress' at a time unless tasks are genuinely parallel.",
|
|
70
|
+
"Immediately after completing a task, call todo_write to mark it 'completed'. " +
|
|
71
|
+
"Do not batch completions — mark each task done as soon as it is finished.",
|
|
72
|
+
"todo_write REPLACES the entire list. Always include ALL tasks (both changed and unchanged) in every call.",
|
|
73
|
+
],
|
|
74
|
+
parameters: TodoWriteParams,
|
|
75
|
+
|
|
76
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
77
|
+
setTasks(
|
|
78
|
+
params.tasks.map((t) => ({
|
|
79
|
+
id: t.id ?? generateId(),
|
|
80
|
+
content: t.content,
|
|
81
|
+
status: t.status as TaskStatus,
|
|
82
|
+
priority: t.priority as TaskPriority,
|
|
83
|
+
})),
|
|
84
|
+
);
|
|
85
|
+
syncIdCounter();
|
|
86
|
+
updateWidget(ctx);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: formatListForLLM() }],
|
|
90
|
+
details: { tasks: [...tasks] } satisfies TaskDetails,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
renderCall(args, theme) {
|
|
95
|
+
const list = (args.tasks as any[]) ?? [];
|
|
96
|
+
const inProg = list.filter((t: any) => t.status === "in_progress").length;
|
|
97
|
+
const done = list.filter((t: any) => t.status === "completed").length;
|
|
98
|
+
const total = list.length;
|
|
99
|
+
let text = theme.fg("toolTitle", theme.bold("todo_write "));
|
|
100
|
+
text += theme.fg("muted", `${total} task${total !== 1 ? "s" : ""}`);
|
|
101
|
+
if (inProg > 0) text += ` ${theme.fg("accent", `→ ${inProg} active`)}`;
|
|
102
|
+
if (done > 0) text += ` ${theme.fg("success", `✓ ${done} done`)}`;
|
|
103
|
+
return new Text(text, 0, 0);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
renderResult(result, { expanded }, theme) {
|
|
107
|
+
return TodoViewComponent.renderTaskResult(result.details as TaskDetails | undefined, expanded, theme);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── Tool: todo_read ────────────────────────────────────────────────────
|
|
112
|
+
pi.registerTool({
|
|
113
|
+
name: "todo_read",
|
|
114
|
+
label: "Tasks",
|
|
115
|
+
description: "Read the current task list. Use this to check your tasks and their statuses.",
|
|
116
|
+
promptSnippet: "Read the current task list and statuses",
|
|
117
|
+
parameters: Type.Object({}),
|
|
118
|
+
|
|
119
|
+
async execute() {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: formatListForLLM() }],
|
|
122
|
+
details: { tasks: [...tasks] } satisfies TaskDetails,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
renderCall(_args, theme) {
|
|
127
|
+
return new Text(theme.fg("toolTitle", theme.bold("todo_read")), 0, 0);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
renderResult(result, { expanded }, theme) {
|
|
131
|
+
return TodoViewComponent.renderTaskResult(result.details as TaskDetails | undefined, expanded, theme);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── Command: /todos ────────────────────────────────────────────────────
|
|
136
|
+
pi.registerCommand("todos", {
|
|
137
|
+
description: "Open the interactive task viewer",
|
|
138
|
+
handler: async (_args, ctx) => {
|
|
139
|
+
if (!ctx.hasUI) {
|
|
140
|
+
ctx.ui.notify(tasks.length === 0 ? "No tasks yet." : formatListForLLM(), "info");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
144
|
+
return new TodoViewComponent(tasks, theme, () => done());
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { activeTasks, tasks } from "./state";
|
|
2
|
+
import { PRIORITY_ORDER, STATUS_ICON } from "./types";
|
|
3
|
+
|
|
4
|
+
// ── System prompt injection ────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a plain-text task block for system-prompt injection.
|
|
8
|
+
* Only injected when there are active (non-completed) tasks.
|
|
9
|
+
*/
|
|
10
|
+
export function buildSystemPromptBlock(): string {
|
|
11
|
+
const active = activeTasks();
|
|
12
|
+
if (active.length === 0) return "";
|
|
13
|
+
|
|
14
|
+
const lines: string[] = [
|
|
15
|
+
"## Current Task List",
|
|
16
|
+
"",
|
|
17
|
+
"You have the following tasks. Update them with `todo_write` as you work:",
|
|
18
|
+
"",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const inProg = active.filter((t) => t.status === "in_progress");
|
|
22
|
+
const pending = active
|
|
23
|
+
.filter((t) => t.status === "pending")
|
|
24
|
+
.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]);
|
|
25
|
+
|
|
26
|
+
for (const t of [...inProg, ...pending]) {
|
|
27
|
+
const icon = STATUS_ICON[t.status];
|
|
28
|
+
const pLabel = `[${t.priority.toUpperCase().slice(0, 3)}]`;
|
|
29
|
+
const suffix = t.status === "in_progress" ? " ← in progress" : "";
|
|
30
|
+
lines.push(`${icon} ${pLabel} ${t.content}${suffix}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const doneCount = tasks.filter((t) => t.status === "completed").length;
|
|
34
|
+
if (doneCount > 0) {
|
|
35
|
+
lines.push("", `${doneCount}/${tasks.length} tasks completed.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── LLM text formatter ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Plain text list returned inside tool results (visible to the LLM). */
|
|
44
|
+
export function formatListForLLM(): string {
|
|
45
|
+
if (tasks.length === 0) return "Task list is empty.";
|
|
46
|
+
return tasks
|
|
47
|
+
.map((t) => `${STATUS_ICON[t.status]} [${t.priority.toUpperCase().slice(0, 3)}] [${t.id}] ${t.content}`)
|
|
48
|
+
.join("\n");
|
|
49
|
+
}
|
package/src/schemas.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
// ── Tool schemas ───────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const TaskSchema = Type.Object({
|
|
7
|
+
id: Type.Optional(
|
|
8
|
+
Type.String({
|
|
9
|
+
description:
|
|
10
|
+
"Unique task ID. Omit to auto-generate. " +
|
|
11
|
+
"When updating existing tasks use their current ID to preserve identity.",
|
|
12
|
+
}),
|
|
13
|
+
),
|
|
14
|
+
content: Type.String({
|
|
15
|
+
description: "Clear, actionable task description.",
|
|
16
|
+
}),
|
|
17
|
+
status: StringEnum(["pending", "in_progress", "completed"] as const, {
|
|
18
|
+
description:
|
|
19
|
+
"Task status. " +
|
|
20
|
+
"Set to 'in_progress' for the task you are actively working on right now. " +
|
|
21
|
+
"Only one task should be 'in_progress' at a time unless tasks are genuinely parallel.",
|
|
22
|
+
}),
|
|
23
|
+
priority: StringEnum(["high", "medium", "low"] as const, {
|
|
24
|
+
description: "Task priority.",
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const TodoWriteParams = Type.Object({
|
|
29
|
+
tasks: Type.Array(TaskSchema, {
|
|
30
|
+
description:
|
|
31
|
+
"The COMPLETE, updated task list. " +
|
|
32
|
+
"This REPLACES the current list entirely — always include ALL tasks, " +
|
|
33
|
+
"both updated ones and unchanged ones.",
|
|
34
|
+
}),
|
|
35
|
+
});
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { Task, TaskDetails } from "./types";
|
|
3
|
+
import { PRIORITY_ORDER } from "./types";
|
|
4
|
+
|
|
5
|
+
// ── Module state ───────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export let tasks: Task[] = [];
|
|
8
|
+
export let idCounter: number = 0;
|
|
9
|
+
|
|
10
|
+
// ── State helpers ──────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export function generateId(): string {
|
|
13
|
+
return `t${++idCounter}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function activeTasks(): Task[] {
|
|
17
|
+
return tasks.filter((t) => t.status !== "completed");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setTasks(next: Task[]): void {
|
|
21
|
+
tasks = next;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function syncIdCounter(): void {
|
|
25
|
+
for (const t of tasks) {
|
|
26
|
+
const m = t.id.match(/^t(\d+)$/);
|
|
27
|
+
if (m) idCounter = Math.max(idCounter, parseInt(m[1]!, 10));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Session reconstruction ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Reconstruct in-memory state by replaying the last todo_write on the branch.
|
|
35
|
+
* Ensures /tree navigation and forking work correctly.
|
|
36
|
+
*/
|
|
37
|
+
export function reconstructState(ctx: ExtensionContext): void {
|
|
38
|
+
tasks = [];
|
|
39
|
+
idCounter = 0;
|
|
40
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
41
|
+
if (entry.type !== "message") continue;
|
|
42
|
+
const msg = entry.message;
|
|
43
|
+
if (msg.role !== "toolResult" || msg.toolName !== "todo_write") continue;
|
|
44
|
+
const details = msg.details as TaskDetails | undefined;
|
|
45
|
+
if (details?.tasks) {
|
|
46
|
+
tasks = details.tasks;
|
|
47
|
+
syncIdCounter();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Widget & status bar ────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function updateWidget(ctx: ExtensionContext): void {
|
|
55
|
+
const active = activeTasks();
|
|
56
|
+
|
|
57
|
+
if (active.length === 0) {
|
|
58
|
+
ctx.ui.setWidget("pi-todo", undefined);
|
|
59
|
+
ctx.ui.setStatus("pi-todo", undefined);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const th = ctx.ui.theme;
|
|
64
|
+
const inProg = active.filter((t) => t.status === "in_progress");
|
|
65
|
+
const pending = active.filter((t) => t.status === "pending");
|
|
66
|
+
const doneCount = tasks.filter((t) => t.status === "completed").length;
|
|
67
|
+
|
|
68
|
+
// ── Footer status ──
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
if (inProg.length > 0) parts.push(th.fg("accent", `→ ${inProg.length} active`));
|
|
71
|
+
if (pending.length > 0) parts.push(th.fg("muted", `○ ${pending.length} pending`));
|
|
72
|
+
if (doneCount > 0) parts.push(th.fg("success", `✓ ${doneCount} done`));
|
|
73
|
+
ctx.ui.setStatus("pi-todo", parts.join(" "));
|
|
74
|
+
|
|
75
|
+
// ── Widget: show up to 4 active tasks ─────────────────────────────────
|
|
76
|
+
const displayTasks = [
|
|
77
|
+
...inProg,
|
|
78
|
+
...pending.sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]),
|
|
79
|
+
].slice(0, 4);
|
|
80
|
+
|
|
81
|
+
const widgetLines: string[] = [""];
|
|
82
|
+
for (const t of displayTasks) {
|
|
83
|
+
const icon = t.status === "in_progress" ? th.fg("accent", "→") : th.fg("dim", "○");
|
|
84
|
+
const pColor = t.priority === "high" ? "error" : t.priority === "medium" ? "warning" : "dim";
|
|
85
|
+
const pLabel = th.fg(pColor, t.priority.toUpperCase().slice(0, 3));
|
|
86
|
+
const content = t.status === "in_progress" ? th.fg("text", th.bold(t.content)) : th.fg("muted", t.content);
|
|
87
|
+
widgetLines.push(` ${icon} ${pLabel} ${content}`);
|
|
88
|
+
}
|
|
89
|
+
if (active.length > 4) {
|
|
90
|
+
widgetLines.push(` ${th.fg("dim", `... ${active.length - 4} more (/todos for full list)`)}`);
|
|
91
|
+
}
|
|
92
|
+
widgetLines.push("");
|
|
93
|
+
|
|
94
|
+
ctx.ui.setWidget("pi-todo", widgetLines);
|
|
95
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type TaskStatus = "pending" | "in_progress" | "completed";
|
|
4
|
+
export type TaskPriority = "high" | "medium" | "low";
|
|
5
|
+
|
|
6
|
+
export interface Task {
|
|
7
|
+
id: string;
|
|
8
|
+
content: string;
|
|
9
|
+
status: TaskStatus;
|
|
10
|
+
priority: TaskPriority;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TaskDetails {
|
|
14
|
+
tasks: Task[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Visual constants ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export const STATUS_ICON: Record<TaskStatus, string> = {
|
|
20
|
+
pending: "○",
|
|
21
|
+
in_progress: "→",
|
|
22
|
+
completed: "✓",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const PRIORITY_ORDER: Record<TaskPriority, number> = {
|
|
26
|
+
high: 0,
|
|
27
|
+
medium: 1,
|
|
28
|
+
low: 2,
|
|
29
|
+
};
|