@pi-archimedes/todo 1.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/README.md +86 -0
- package/package.json +27 -0
- package/src/index.ts +99 -0
- package/src/state-manager.ts +93 -0
- package/src/tool.ts +196 -0
- package/src/types.ts +46 -0
- package/src/ui/todo-widget.ts +154 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @pi-archimedes/todo
|
|
2
|
+
|
|
3
|
+
Todo list management with auto-clear and live subagent visibility for the [Pi coding agent](https://github.com/earendil-works/pi).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **`manage_todo_list` tool** — structured todo tracking with `read` and `write` operations
|
|
8
|
+
- **Auto-clear** — when all todos are completed, the list clears itself after a brief 2-second delay
|
|
9
|
+
- **Multi-column widget** — main agent todos on the left, each subagent's todos in their own column to the right
|
|
10
|
+
- **Live subagent visibility** — subagent todos stream through the core bus so you can see what they're working on
|
|
11
|
+
- **`/todos` command** — toggle the widget or clear todos (`/todos clear`)
|
|
12
|
+
- **Session persistence** — todos survive `/reload` via session branch reconstruction
|
|
13
|
+
|
|
14
|
+
## Screenshots
|
|
15
|
+
|
|
16
|
+
### Multiple todos with progress tracking
|
|
17
|
+
|
|
18
|
+
Widget showing three todos with completion status — completed items dimmed with strikethrough, in-progress highlighted:
|
|
19
|
+
|
|
20
|
+

|
|
21
|
+
|
|
22
|
+
### Main agent + subagent side by side
|
|
23
|
+
|
|
24
|
+
Main agent todos (left) alongside a subagent's todos (right), separated by a divider. Subagent column auto-removes when the subagent finishes:
|
|
25
|
+
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi install @pi-archimedes/todo
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or install the full [pi-archimedes](../..) meta package for the integrated experience:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install pi-archimedes
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### As a tool
|
|
43
|
+
|
|
44
|
+
The `manage_todo_list` tool accepts two operations:
|
|
45
|
+
|
|
46
|
+
**Read current todos:**
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
{
|
|
50
|
+
"operation": "read"
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Write (replace) the todo list:**
|
|
55
|
+
|
|
56
|
+
```jsonc
|
|
57
|
+
{
|
|
58
|
+
"operation": "write",
|
|
59
|
+
"todoList": [
|
|
60
|
+
{ "id": 1, "title": "Parse config files", "description": "Read and validate all config files", "status": "in-progress" },
|
|
61
|
+
{ "id": 2, "title": "Build state manager", "description": "Implement TodoStateManager class", "status": "not-started" },
|
|
62
|
+
{ "id": 3, "title": "Wire up widget", "description": "Connect widget to bus events", "status": "not-started" }
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### As a command
|
|
68
|
+
|
|
69
|
+
- `/todos` — toggle the todo widget visibility
|
|
70
|
+
- `/todos clear` — clear all todos immediately
|
|
71
|
+
|
|
72
|
+
## Todo statuses
|
|
73
|
+
|
|
74
|
+
| Status | Icon | Description |
|
|
75
|
+
|--------|------|-------------|
|
|
76
|
+
| `not-started` | ○ | Not yet begun |
|
|
77
|
+
| `in-progress` | ◉ | Currently being worked on |
|
|
78
|
+
| `completed` | ✓ | Fully finished |
|
|
79
|
+
|
|
80
|
+
## Auto-clear
|
|
81
|
+
|
|
82
|
+
When all todos in the list are marked `completed`, the widget shows the all-done state for 2 seconds, then auto-clears. No need to manually run `/todos clear`.
|
|
83
|
+
|
|
84
|
+
## Subagent integration
|
|
85
|
+
|
|
86
|
+
When installed via `pi-archimedes` (the meta package), subagent todo events flow through `@pi-archimedes/core/bus` and appear as separate columns in the widget. Each subagent gets its own column labeled with its agent name. The column auto-removes when the subagent finishes.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-archimedes/todo",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"description": "Todo list tool with auto-clear and subagent visibility",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@pi-archimedes/core": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-ai": ">=0.1.0",
|
|
18
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
19
|
+
"@earendil-works/pi-tui": ">=0.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^6.0.0"
|
|
23
|
+
},
|
|
24
|
+
"pi": {
|
|
25
|
+
"image": "https://raw.githubusercontent.com/danielcherubini/pi-archimedes/main/docs/images/todos-and-subagent.png"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
3
|
+
import { TodoStateManager } from "./state-manager.js";
|
|
4
|
+
import { createManageTodoListTool } from "./tool.js";
|
|
5
|
+
import { updateWidget, clearWidget } from "./ui/todo-widget.js";
|
|
6
|
+
import type { TodoItem } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export default function (pi: ExtensionAPI): void {
|
|
9
|
+
registerTodo(pi);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerTodo(pi: ExtensionAPI): void {
|
|
13
|
+
const state = new TodoStateManager();
|
|
14
|
+
const subagentTodos = new Map<string, TodoItem[]>();
|
|
15
|
+
const unsubscribes: Array<() => void> = [];
|
|
16
|
+
let currentCtx: ExtensionContext | undefined;
|
|
17
|
+
|
|
18
|
+
const refreshWidget = () => {
|
|
19
|
+
if (currentCtx) {
|
|
20
|
+
updateWidget(state, currentCtx, subagentTodos);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Subscribe to bus events for subagent todos
|
|
25
|
+
const unsubTodosUpdate = getBus().on(Events.TODOS_UPDATE, (payload: unknown) => {
|
|
26
|
+
const data = payload as { source: string; todos: TodoItem[] };
|
|
27
|
+
if (data.source === "main") return; // main handled locally
|
|
28
|
+
subagentTodos.set(data.source, data.todos);
|
|
29
|
+
refreshWidget();
|
|
30
|
+
});
|
|
31
|
+
unsubscribes.push(unsubTodosUpdate);
|
|
32
|
+
|
|
33
|
+
const unsubTodosClear = getBus().on(Events.TODOS_CLEAR, (payload: unknown) => {
|
|
34
|
+
const data = payload as { source: string };
|
|
35
|
+
subagentTodos.delete(data.source);
|
|
36
|
+
refreshWidget();
|
|
37
|
+
});
|
|
38
|
+
unsubscribes.push(unsubTodosClear);
|
|
39
|
+
|
|
40
|
+
// Reconstruct state from session on load/resume/fork/tree
|
|
41
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
42
|
+
currentCtx = ctx;
|
|
43
|
+
state.loadFromSession(ctx);
|
|
44
|
+
updateWidget(state, ctx, subagentTodos);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
48
|
+
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
49
|
+
|
|
50
|
+
// Keep ctx reference fresh on every turn
|
|
51
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
52
|
+
currentCtx = ctx;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Update widget after each turn (in case tool was called)
|
|
56
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
57
|
+
currentCtx = ctx;
|
|
58
|
+
updateWidget(state, ctx, subagentTodos);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// session_shutdown handler (top-level to prevent accumulation on /reload)
|
|
62
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
63
|
+
unsubscribes.forEach((unsub) => unsub());
|
|
64
|
+
unsubscribes.length = 0;
|
|
65
|
+
subagentTodos.clear();
|
|
66
|
+
state.cancelAutoClear();
|
|
67
|
+
if (currentCtx) {
|
|
68
|
+
clearWidget(currentCtx);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Register the manage_todo_list tool
|
|
73
|
+
const tool = createManageTodoListTool(state, refreshWidget);
|
|
74
|
+
pi.registerTool(tool);
|
|
75
|
+
|
|
76
|
+
// Register /todos command
|
|
77
|
+
pi.registerCommand("todos", {
|
|
78
|
+
description: "Toggle todo list widget or clear todos (/todos clear)",
|
|
79
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
80
|
+
currentCtx = ctx;
|
|
81
|
+
|
|
82
|
+
if (args?.trim().toLowerCase() === "clear") {
|
|
83
|
+
state.clear();
|
|
84
|
+
clearWidget(ctx);
|
|
85
|
+
ctx.ui.notify("Todo list cleared.", "info");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const todos = state.read();
|
|
90
|
+
if (todos.length === 0) {
|
|
91
|
+
ctx.ui.notify("No todos. The LLM will create them when working on complex tasks.", "info");
|
|
92
|
+
} else {
|
|
93
|
+
updateWidget(state, ctx, subagentTodos);
|
|
94
|
+
const stats = state.getStats();
|
|
95
|
+
ctx.ui.notify(`${stats.completed}/${stats.total} todos completed.`, "info");
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TodoItem, TodoStats, ValidationResult } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/** Manages the in-memory todo list state. */
|
|
5
|
+
export class TodoStateManager {
|
|
6
|
+
private todos: TodoItem[] = [];
|
|
7
|
+
private autoClearTimer: ReturnType<typeof setTimeout> | undefined;
|
|
8
|
+
|
|
9
|
+
read(): TodoItem[] {
|
|
10
|
+
return [...this.todos];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
write(todos: TodoItem[]): void {
|
|
14
|
+
this.cancelAutoClear();
|
|
15
|
+
this.todos = todos.map((t) => ({ ...t }));
|
|
16
|
+
// If all completed and non-empty, schedule auto-clear
|
|
17
|
+
if (todos.length > 0 && todos.every((t) => t.status === "completed")) {
|
|
18
|
+
this.scheduleAutoClear(() => this.clear());
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
clear(): void {
|
|
23
|
+
this.cancelAutoClear();
|
|
24
|
+
this.todos = [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getStats(): TodoStats {
|
|
28
|
+
const total = this.todos.length;
|
|
29
|
+
const completed = this.todos.filter((t) => t.status === "completed").length;
|
|
30
|
+
const inProgress = this.todos.filter((t) => t.status === "in-progress").length;
|
|
31
|
+
const notStarted = this.todos.filter((t) => t.status === "not-started").length;
|
|
32
|
+
return { total, completed, inProgress, notStarted };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
validate(todos: TodoItem[]): ValidationResult {
|
|
36
|
+
const errors: string[] = [];
|
|
37
|
+
if (!Array.isArray(todos)) {
|
|
38
|
+
return { valid: false, errors: ["todoList must be an array"] };
|
|
39
|
+
}
|
|
40
|
+
const validStatuses = new Set(["not-started", "in-progress", "completed"]);
|
|
41
|
+
for (let i = 0; i < todos.length; i++) {
|
|
42
|
+
const item = todos[i];
|
|
43
|
+
const prefix = `Item ${i + 1}`;
|
|
44
|
+
if (!item) {
|
|
45
|
+
errors.push(`${prefix}: undefined item`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (item.id == null) {
|
|
49
|
+
errors.push(`${prefix}: missing 'id'`);
|
|
50
|
+
} else if (typeof item.id !== "number") {
|
|
51
|
+
errors.push(`${prefix}: 'id' must be a number`);
|
|
52
|
+
}
|
|
53
|
+
if (!item.title || typeof item.title !== "string") {
|
|
54
|
+
errors.push(`${prefix}: missing or invalid 'title'`);
|
|
55
|
+
}
|
|
56
|
+
if (!item.description || typeof item.description !== "string") {
|
|
57
|
+
errors.push(`${prefix}: missing or invalid 'description'`);
|
|
58
|
+
}
|
|
59
|
+
if (!item.status || !validStatuses.has(item.status)) {
|
|
60
|
+
errors.push(`${prefix}: 'status' must be one of: not-started, in-progress, completed`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { valid: errors.length === 0, errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
loadFromSession(ctx: ExtensionContext): void {
|
|
67
|
+
this.todos = [];
|
|
68
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
69
|
+
if (entry.type !== "message") continue;
|
|
70
|
+
const msg = entry.message;
|
|
71
|
+
if (msg.role !== "toolResult" || msg.toolName !== "manage_todo_list") continue;
|
|
72
|
+
const details = msg.details as { todos?: TodoItem[] } | undefined;
|
|
73
|
+
if (details?.todos) {
|
|
74
|
+
this.todos = details.todos.map((t) => ({ ...t }));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
scheduleAutoClear(callback: () => void): void {
|
|
80
|
+
this.cancelAutoClear();
|
|
81
|
+
this.autoClearTimer = setTimeout(() => {
|
|
82
|
+
this.autoClearTimer = undefined;
|
|
83
|
+
callback();
|
|
84
|
+
}, 2000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
cancelAutoClear(): void {
|
|
88
|
+
if (this.autoClearTimer) {
|
|
89
|
+
clearTimeout(this.autoClearTimer);
|
|
90
|
+
this.autoClearTimer = undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/tool.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { type Static, StringEnum, Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { AgentToolResult, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
5
|
+
import type { TodoStateManager } from "./state-manager.js";
|
|
6
|
+
import { STATUS_ICONS } from "./types.js";
|
|
7
|
+
import type { TodoDetails } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const TodoItemSchema = Type.Object({
|
|
10
|
+
id: Type.Number({ description: "Unique identifier for the todo. Use sequential numbers starting from 1." }),
|
|
11
|
+
title: Type.String({ description: "Concise action-oriented todo label (3-7 words). Displayed in UI." }),
|
|
12
|
+
description: Type.String({
|
|
13
|
+
description: "Detailed context, requirements, or implementation notes. Include file paths, specific methods, or acceptance criteria.",
|
|
14
|
+
}),
|
|
15
|
+
status: StringEnum(["not-started", "in-progress", "completed"] as const, {
|
|
16
|
+
description: "not-started: Not begun | in-progress: Currently working (multiple allowed for parallel work) | completed: Fully finished with no blockers",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const ManageTodoListParams = Type.Object({
|
|
21
|
+
operation: StringEnum(["write", "read"] as const, {
|
|
22
|
+
description: "write: Replace entire todo list with new content. read: Retrieve current todo list. ALWAYS provide complete list when writing - partial updates not supported.",
|
|
23
|
+
}),
|
|
24
|
+
todoList: Type.Optional(
|
|
25
|
+
Type.Array(TodoItemSchema, {
|
|
26
|
+
description: "Complete array of all todo items (required for write operation, ignored for read). Must include ALL items - both existing and new.",
|
|
27
|
+
})
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export type ManageTodoListInput = Static<typeof ManageTodoListParams>;
|
|
32
|
+
|
|
33
|
+
export const TOOL_DESCRIPTION = `Manage a structured todo list to track progress and plan tasks throughout your coding session. Use this tool VERY frequently to ensure task visibility and proper planning.
|
|
34
|
+
|
|
35
|
+
When to use this tool:
|
|
36
|
+
- Complex multi-step work requiring planning and tracking
|
|
37
|
+
- When user provides multiple tasks or requests (numbered, comma-separated)
|
|
38
|
+
- After receiving new instructions that require multiple steps
|
|
39
|
+
- BEFORE starting work on any todo (mark as in-progress)
|
|
40
|
+
- IMMEDIATELY after completing each todo (mark completed individually)
|
|
41
|
+
- When breaking down larger tasks into smaller actionable steps
|
|
42
|
+
- To give users visibility into your progress and planning
|
|
43
|
+
|
|
44
|
+
When NOT to use:
|
|
45
|
+
- Single, trivial tasks that can be completed in one step
|
|
46
|
+
- Purely conversational/informational requests
|
|
47
|
+
- When just reading files or performing simple searches
|
|
48
|
+
|
|
49
|
+
CRITICAL workflow:
|
|
50
|
+
1. Plan tasks by writing todo list with specific, actionable items
|
|
51
|
+
2. Mark todo(s) as in-progress before starting work
|
|
52
|
+
3. Complete the work for that specific todo
|
|
53
|
+
4. Mark that todo as completed IMMEDIATELY
|
|
54
|
+
5. Move to next todo and repeat
|
|
55
|
+
|
|
56
|
+
Todo states:
|
|
57
|
+
- not-started: Todo not yet begun
|
|
58
|
+
- in-progress: Currently working (multiple allowed for parallel work/subagents)
|
|
59
|
+
- completed: Finished successfully
|
|
60
|
+
|
|
61
|
+
IMPORTANT: Mark todos completed as soon as they are done. Do not batch completions.
|
|
62
|
+
When all todos are completed, the list auto-clears after a brief delay.`;
|
|
63
|
+
|
|
64
|
+
export function createManageTodoListTool(state: TodoStateManager, onUpdate: () => void) {
|
|
65
|
+
return {
|
|
66
|
+
name: "manage_todo_list",
|
|
67
|
+
label: "Todo List",
|
|
68
|
+
description: TOOL_DESCRIPTION,
|
|
69
|
+
parameters: ManageTodoListParams,
|
|
70
|
+
|
|
71
|
+
async execute(
|
|
72
|
+
_toolCallId: string,
|
|
73
|
+
params: ManageTodoListInput,
|
|
74
|
+
_signal: AbortSignal | undefined,
|
|
75
|
+
_onStreamUpdate: undefined,
|
|
76
|
+
_ctx: ExtensionContext
|
|
77
|
+
) {
|
|
78
|
+
if (params.operation === "read") {
|
|
79
|
+
const todos = state.read();
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: "text" as const,
|
|
84
|
+
text: todos.length
|
|
85
|
+
? JSON.stringify(todos, null, 2)
|
|
86
|
+
: "No todos. Use write operation to create a todo list.",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
details: { operation: "read", todos } as TodoDetails,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// write
|
|
94
|
+
const todoList = params.todoList;
|
|
95
|
+
if (!todoList || !Array.isArray(todoList)) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: "text" as const, text: "Error: todoList is required for write operation." }],
|
|
98
|
+
details: { operation: "write", todos: state.read(), error: "todoList required" } as TodoDetails,
|
|
99
|
+
isError: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const validation = state.validate(todoList);
|
|
104
|
+
if (!validation.valid) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: "text" as const, text: `Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join("\n")}` }],
|
|
107
|
+
details: { operation: "write", todos: state.read(), error: validation.errors.join("; ") } as TodoDetails,
|
|
108
|
+
isError: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
state.write(todoList);
|
|
113
|
+
onUpdate();
|
|
114
|
+
|
|
115
|
+
// Emit bus event for widget update
|
|
116
|
+
try {
|
|
117
|
+
getBus().emit(Events.TODOS_UPDATE, { source: "main", todos: state.read() });
|
|
118
|
+
} catch {
|
|
119
|
+
// Bus may not be initialized in subagent child process — ignore
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stats = state.getStats();
|
|
123
|
+
const todos = state.read();
|
|
124
|
+
|
|
125
|
+
let message = `Todos have been modified successfully. ${stats.completed}/${stats.total} completed. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable.`;
|
|
126
|
+
|
|
127
|
+
if (todoList.length < 3) {
|
|
128
|
+
message += `\n\nWarning: Small todo list (<3 items). This task might not need a todo list.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: message }],
|
|
133
|
+
details: { operation: "write", todos } as TodoDetails,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
renderCall(args: ManageTodoListInput, theme: Theme) {
|
|
138
|
+
let text = theme.fg("toolTitle", theme.bold("manage_todo_list "));
|
|
139
|
+
text += theme.fg("muted", args.operation);
|
|
140
|
+
|
|
141
|
+
if (args.operation === "write" && args.todoList) {
|
|
142
|
+
const count = args.todoList.length;
|
|
143
|
+
text += theme.fg("dim", ` (${count} item${count !== 1 ? "s" : ""})`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return new Text(text, 0, 0);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
renderResult(
|
|
150
|
+
result: AgentToolResult<TodoDetails | undefined>,
|
|
151
|
+
{ expanded }: ToolRenderResultOptions,
|
|
152
|
+
theme: Theme
|
|
153
|
+
) {
|
|
154
|
+
const details = result.details;
|
|
155
|
+
if (!details) {
|
|
156
|
+
const first = result.content[0];
|
|
157
|
+
return new Text(first && "text" in first ? first.text : "", 0, 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (details.error) {
|
|
161
|
+
return new Text(theme.fg("error", `✗ ${details.error}`), 0, 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const todos = details.todos;
|
|
165
|
+
const completed = todos.filter((t) => t.status === "completed").length;
|
|
166
|
+
const total = todos.length;
|
|
167
|
+
|
|
168
|
+
if (total === 0) {
|
|
169
|
+
return new Text(theme.fg("dim", "No todos"), 0, 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let text = theme.fg("success", "✓ ") + theme.fg("muted", `${completed}/${total} completed`);
|
|
173
|
+
|
|
174
|
+
if (expanded) {
|
|
175
|
+
for (const todo of todos) {
|
|
176
|
+
const iconChar = STATUS_ICONS[todo.status] ?? "?";
|
|
177
|
+
const icon =
|
|
178
|
+
todo.status === "completed"
|
|
179
|
+
? theme.fg("success", iconChar)
|
|
180
|
+
: todo.status === "in-progress"
|
|
181
|
+
? theme.fg("warning", iconChar)
|
|
182
|
+
: theme.fg("dim", iconChar);
|
|
183
|
+
const title =
|
|
184
|
+
todo.status === "completed"
|
|
185
|
+
? theme.fg("dim", theme.strikethrough(todo.title))
|
|
186
|
+
: todo.status === "in-progress"
|
|
187
|
+
? theme.fg("warning", todo.title)
|
|
188
|
+
: theme.fg("muted", todo.title);
|
|
189
|
+
text += `\n ${icon} ${theme.fg("accent", `${todo.id}.`)} ${title}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return new Text(text, 0, 0);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the todo list extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Status of a single todo item */
|
|
6
|
+
export type TodoStatus = "not-started" | "in-progress" | "completed";
|
|
7
|
+
|
|
8
|
+
/** A single todo item */
|
|
9
|
+
export interface TodoItem {
|
|
10
|
+
/** Sequential identifier starting from 1 */
|
|
11
|
+
id: number;
|
|
12
|
+
/** Concise action-oriented label (3-7 words). Displayed in UI. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** Detailed context, requirements, or implementation notes. */
|
|
15
|
+
description: string;
|
|
16
|
+
/** Current status. */
|
|
17
|
+
status: TodoStatus;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Stored in tool result details for session persistence */
|
|
21
|
+
export interface TodoDetails {
|
|
22
|
+
operation: "read" | "write";
|
|
23
|
+
todos: TodoItem[];
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Stats about the current todo list */
|
|
28
|
+
export interface TodoStats {
|
|
29
|
+
total: number;
|
|
30
|
+
completed: number;
|
|
31
|
+
inProgress: number;
|
|
32
|
+
notStarted: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Validation result */
|
|
36
|
+
export interface ValidationResult {
|
|
37
|
+
valid: boolean;
|
|
38
|
+
errors: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Status icons for each todo state */
|
|
42
|
+
export const STATUS_ICONS: Record<TodoStatus, string> = {
|
|
43
|
+
"completed": "✓",
|
|
44
|
+
"in-progress": "◉ ",
|
|
45
|
+
"not-started": "○",
|
|
46
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { STATUS_ICONS } from "../types.js";
|
|
4
|
+
import type { TodoItem } from "../types.js";
|
|
5
|
+
import type { TodoStateManager } from "../state-manager.js";
|
|
6
|
+
|
|
7
|
+
const WIDGET_ID = "todo-list";
|
|
8
|
+
|
|
9
|
+
interface Column {
|
|
10
|
+
header: string;
|
|
11
|
+
todos: TodoItem[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Update (or clear) the todo widget.
|
|
16
|
+
*/
|
|
17
|
+
export function updateWidget(
|
|
18
|
+
state: TodoStateManager,
|
|
19
|
+
ctx: ExtensionContext,
|
|
20
|
+
subagentTodos: Map<string, TodoItem[]>,
|
|
21
|
+
): void {
|
|
22
|
+
const mainTodos = state.read();
|
|
23
|
+
|
|
24
|
+
// Hide widget if everything is empty
|
|
25
|
+
if (mainTodos.length === 0 && subagentTodos.size === 0) {
|
|
26
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => {
|
|
31
|
+
// Re-read in case state changed since widget was set
|
|
32
|
+
const currentMain = state.read();
|
|
33
|
+
const currentSub = new Map(subagentTodos);
|
|
34
|
+
|
|
35
|
+
if (currentMain.length === 0 && currentSub.size === 0) {
|
|
36
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
37
|
+
return { render: () => [], invalidate: () => {} };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const currentStats = state.getStats();
|
|
41
|
+
|
|
42
|
+
// Build columns
|
|
43
|
+
const columns: Column[] = [];
|
|
44
|
+
|
|
45
|
+
// Main column (always first, no header label)
|
|
46
|
+
if (currentMain.length > 0) {
|
|
47
|
+
columns.push({ header: "", todos: currentMain });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Subagent columns (sorted by source name)
|
|
51
|
+
const sortedSources = Array.from(currentSub.keys()).sort();
|
|
52
|
+
for (const source of sortedSources) {
|
|
53
|
+
const todos = currentSub.get(source)!;
|
|
54
|
+
if (todos.length > 0) {
|
|
55
|
+
const agentName = source.replace("subagent:", "");
|
|
56
|
+
columns.push({ header: `subagent (${agentName})`, todos });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (columns.length === 0) {
|
|
61
|
+
return { render: () => [], invalidate: () => {} };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const maxRows = Math.max(...columns.map((c) => c.todos.length));
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
render(width: number) {
|
|
68
|
+
const lines: string[] = [];
|
|
69
|
+
|
|
70
|
+
// Header line
|
|
71
|
+
const header =
|
|
72
|
+
theme.fg("accent", " Todo List ") +
|
|
73
|
+
theme.fg("muted", ` — ${currentStats.completed}/${currentStats.total} completed`);
|
|
74
|
+
lines.push(truncateToWidth(header, width));
|
|
75
|
+
|
|
76
|
+
// Calculate column widths
|
|
77
|
+
const numCols = columns.length;
|
|
78
|
+
const divider = theme.fg("dim", " │ ");
|
|
79
|
+
const dividerWidth = numCols > 1 ? (numCols - 1) * visibleWidth(divider) : 0;
|
|
80
|
+
const minColWidth = 20;
|
|
81
|
+
let colWidth = numCols > 0 ? Math.floor((width - dividerWidth) / numCols) : width;
|
|
82
|
+
colWidth = Math.max(colWidth, minColWidth);
|
|
83
|
+
|
|
84
|
+
// Render subagent headers on row 0
|
|
85
|
+
const hasSubHeader = columns.some((c) => c.header.length > 0);
|
|
86
|
+
|
|
87
|
+
for (let row = 0; row < maxRows; row++) {
|
|
88
|
+
const cellParts: string[] = [];
|
|
89
|
+
|
|
90
|
+
for (const column of columns) {
|
|
91
|
+
let cellText: string;
|
|
92
|
+
|
|
93
|
+
if (row === 0 && hasSubHeader && column.header) {
|
|
94
|
+
cellText = ` ${column.header}`;
|
|
95
|
+
} else if (row < column.todos.length) {
|
|
96
|
+
const todo = column.todos[row];
|
|
97
|
+
if (todo) {
|
|
98
|
+
const icon = getStatusIcon(todo.status, theme);
|
|
99
|
+
const idStr = theme.fg("accent", `${todo.id}.`);
|
|
100
|
+
const title = formatTodoTitle(todo, theme);
|
|
101
|
+
cellText = ` ${icon} ${idStr} ${title}`;
|
|
102
|
+
} else {
|
|
103
|
+
cellText = "";
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
cellText = "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Truncate and pad to column width
|
|
110
|
+
const visible = visibleWidth(cellText);
|
|
111
|
+
if (visible > colWidth) {
|
|
112
|
+
// Truncate
|
|
113
|
+
const truncated = truncateToWidth(cellText, colWidth);
|
|
114
|
+
cellParts.push(truncated);
|
|
115
|
+
} else {
|
|
116
|
+
// Pad
|
|
117
|
+
const padding = " ".repeat(colWidth - visible);
|
|
118
|
+
cellParts.push(cellText + padding);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Join columns with dividers
|
|
123
|
+
let line = cellParts.join(divider);
|
|
124
|
+
lines.push(truncateToWidth(line, width));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines;
|
|
128
|
+
},
|
|
129
|
+
invalidate: () => {},
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getStatusIcon(status: TodoItem["status"], theme: Theme): string {
|
|
135
|
+
const icon = STATUS_ICONS[status] ?? "?";
|
|
136
|
+
if (status === "completed") return theme.fg("success", icon);
|
|
137
|
+
if (status === "in-progress") return theme.fg("warning", icon.trim());
|
|
138
|
+
return theme.fg("dim", icon);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatTodoTitle(todo: TodoItem, theme: Theme): string {
|
|
142
|
+
if (todo.status === "completed") {
|
|
143
|
+
return theme.fg("dim", theme.strikethrough(todo.title));
|
|
144
|
+
}
|
|
145
|
+
if (todo.status === "in-progress") {
|
|
146
|
+
return theme.fg("warning", todo.title);
|
|
147
|
+
}
|
|
148
|
+
return theme.fg("muted", todo.title);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Clear the widget */
|
|
152
|
+
export function clearWidget(ctx: ExtensionContext): void {
|
|
153
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
154
|
+
}
|