@structupath/pi-tilldone 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/LICENSE +21 -0
- package/README.md +52 -0
- package/extensions/.a5c/logs/babysitter-stop-hook.log +7 -0
- package/extensions/cmux-status.ts +205 -0
- package/extensions/tilldone.ts +733 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StructuPath LLC
|
|
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,52 @@
|
|
|
1
|
+
# pi-tilldone
|
|
2
|
+
|
|
3
|
+
Task discipline for the [Pi coding agent](https://pi.dev): the agent **must declare what it's going to do before it's allowed to do it** — and gets nudged until it's actually done.
|
|
4
|
+
|
|
5
|
+
"todo" → "tilldone": work *till* it's *done*.
|
|
6
|
+
|
|
7
|
+
By [StructuPath](https://structupath.ai).
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@structupath/pi-tilldone
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Why
|
|
16
|
+
|
|
17
|
+
Todo-list extensions record what the agent plans to do. TillDone **enforces** it:
|
|
18
|
+
|
|
19
|
+
- **Hard gate** — write/execute tools are blocked until the agent creates a task list and marks a task in-progress. Read-only tools (read, grep, find, ls, glob) stay open so it can explore before planning.
|
|
20
|
+
- **Single focus** — only one task can be in-progress at a time; toggling a new one auto-pauses the old one.
|
|
21
|
+
- **Completion nudge** — if the agent stops with incomplete tasks, it gets pushed back to work: *"Don't stop until it's done!"* (once per user turn, so it can't loop forever).
|
|
22
|
+
- **Survives everything** — state is reconstructed from the session on start, switch, fork, and tree navigation.
|
|
23
|
+
|
|
24
|
+
## UI
|
|
25
|
+
|
|
26
|
+
- **Footer** — live task list with title, progress `[done/total]`, and per-status counts
|
|
27
|
+
- **Widget** — prominent `● WORKING ON #3 <task>` banner below the editor
|
|
28
|
+
- **Status line** — compact summary
|
|
29
|
+
- **/tilldone** — full-screen overlay with every task
|
|
30
|
+
|
|
31
|
+
The agent drives it through a single `tilldone` tool: `new-list`, `add` (single or batch), `toggle` (cycles idle → inprogress → done), `update`, `remove`, `list`, `clear`. Destructive actions (`new-list` over an existing list, `clear`) ask **you** to confirm first.
|
|
32
|
+
|
|
33
|
+
## Bonus: cmux integration
|
|
34
|
+
|
|
35
|
+
If you run Pi inside [cmux](https://cmux.io), the bundled `cmux-status` extension syncs task state to your workspace tabs — at a glance, across every parallel agent:
|
|
36
|
+
|
|
37
|
+
| Tab color | Meaning |
|
|
38
|
+
|---|---|
|
|
39
|
+
| 🟡 yellow | working — a task is in progress |
|
|
40
|
+
| 🟢 green | all tasks done (+ desktop notification) |
|
|
41
|
+
| 🟠 orange | blocked — tasks exist, none in progress |
|
|
42
|
+
| ⚪ gray | idle — no tasks yet |
|
|
43
|
+
|
|
44
|
+
Tab title shows `⚡ <list> [2/5]`, tab description shows the current task. Outside cmux it no-ops silently — zero overhead.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
More Pi packages from StructuPath: [`@structupath/pi-steel`](https://pi.dev/packages/@structupath/pi-steel) — structural steel estimating skills.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[INFO] 2026-07-20T01:12:06.965Z handleHookRunStop started
|
|
2
|
+
[INFO] 2026-07-20T01:12:06.966Z Hook input received
|
|
3
|
+
[INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Session ID: a8a94862-1ef3-468c-a826-a2661f015a52
|
|
4
|
+
[INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Resolved pluginRoot: /Users/vics/.claude/plugins/cache/a5c-ai/babysitter/4.0.157
|
|
5
|
+
[INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Resolved stateDir: /Users/vics/.a5c/state
|
|
6
|
+
[INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] Checking session file at: /Users/vics/.a5c/state/a8a94862-1ef3-468c-a826-a2661f015a52.md
|
|
7
|
+
[INFO] 2026-07-20T01:12:06.966Z [session=a8a94862-1ef3-468c-a826-a2661f015a52] No active loop found for session a8a94862-1ef3-468c-a826-a2661f015a52 — allowing exit
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cmux Status Extension — Colored Tabs & Progress
|
|
3
|
+
*
|
|
4
|
+
* Syncs agent task state to cmux workspace visuals:
|
|
5
|
+
* - Tab COLOR reflects status (idle/working/done/error)
|
|
6
|
+
* - Tab TITLE shows progress (emoji + task count)
|
|
7
|
+
* - Tab DESCRIPTION shows the current in-progress task
|
|
8
|
+
*
|
|
9
|
+
* Hooks into tilldone tool calls and agent lifecycle events.
|
|
10
|
+
* Only activates when running inside cmux (CMUX_WORKSPACE_ID is set).
|
|
11
|
+
*
|
|
12
|
+
* Usage: installed with @structupath/pi-tilldone; no-ops outside cmux
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
// ── Color Palette ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const COLORS = {
|
|
20
|
+
idle: "#6b7280", // gray — no tasks yet
|
|
21
|
+
working: "#eab308", // yellow — tasks in progress
|
|
22
|
+
done: "#22c55e", // green — all tasks complete
|
|
23
|
+
error: "#ef4444", // red — something went wrong
|
|
24
|
+
blocked: "#f97316", // orange — tasks exist but none in progress
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const STATUS_EMOJI = {
|
|
28
|
+
idle: "💤",
|
|
29
|
+
working: "⚡",
|
|
30
|
+
done: "✅",
|
|
31
|
+
error: "❌",
|
|
32
|
+
blocked: "⏸️",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function isCmux(): boolean {
|
|
38
|
+
return !!process.env.CMUX_WORKSPACE_ID;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function cmux(pi: ExtensionAPI, ...args: string[]): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await pi.exec("cmux", args);
|
|
44
|
+
return stdout.trim();
|
|
45
|
+
} catch {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
let originalTitle = "";
|
|
53
|
+
let originalTitleCaptured = false;
|
|
54
|
+
|
|
55
|
+
type Status = keyof typeof COLORS;
|
|
56
|
+
|
|
57
|
+
interface TaskSnapshot {
|
|
58
|
+
total: number;
|
|
59
|
+
done: number;
|
|
60
|
+
inprogress: number;
|
|
61
|
+
idle: number;
|
|
62
|
+
currentTask: string;
|
|
63
|
+
listTitle: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function deriveStatus(snap: TaskSnapshot): Status {
|
|
67
|
+
if (snap.total === 0) return "idle";
|
|
68
|
+
if (snap.done === snap.total) return "done";
|
|
69
|
+
if (snap.inprogress > 0) return "working";
|
|
70
|
+
if (snap.idle > 0) return "blocked";
|
|
71
|
+
return "idle";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildTitle(snap: TaskSnapshot, status: Status): string {
|
|
75
|
+
const emoji = STATUS_EMOJI[status];
|
|
76
|
+
if (snap.total === 0) return originalTitle || `${emoji} Ready`;
|
|
77
|
+
|
|
78
|
+
const progress = `${snap.done}/${snap.total}`;
|
|
79
|
+
const label = snap.listTitle || "Tasks";
|
|
80
|
+
return `${emoji} ${label} [${progress}]`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildDescription(snap: TaskSnapshot, status: Status): string {
|
|
84
|
+
if (status === "done") return `All ${snap.total} tasks complete ✓`;
|
|
85
|
+
if (status === "idle") return "";
|
|
86
|
+
if (status === "blocked") return "Waiting — no task in progress";
|
|
87
|
+
return snap.currentTask ? `► ${snap.currentTask}` : "Working...";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Tilldone tool result details (shared shape with tilldone.ts) ───────
|
|
91
|
+
|
|
92
|
+
interface TillDoneTask {
|
|
93
|
+
id: number;
|
|
94
|
+
text: string;
|
|
95
|
+
status: "idle" | "inprogress" | "done";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface TillDoneDetails {
|
|
99
|
+
tasks: TillDoneTask[];
|
|
100
|
+
listTitle?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function snapshotFromDetails(details: TillDoneDetails): TaskSnapshot {
|
|
104
|
+
const tasks = details.tasks;
|
|
105
|
+
return {
|
|
106
|
+
total: tasks.length,
|
|
107
|
+
done: tasks.filter((t) => t.status === "done").length,
|
|
108
|
+
inprogress: tasks.filter((t) => t.status === "inprogress").length,
|
|
109
|
+
idle: tasks.filter((t) => t.status === "idle").length,
|
|
110
|
+
currentTask: tasks.find((t) => t.status === "inprogress")?.text ?? "",
|
|
111
|
+
listTitle: details.listTitle ?? "",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Main Extension ─────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export default function (pi: ExtensionAPI) {
|
|
118
|
+
if (!isCmux()) return; // silently skip if not in cmux
|
|
119
|
+
|
|
120
|
+
let lastSnap: TaskSnapshot = {
|
|
121
|
+
total: 0, done: 0, inprogress: 0, idle: 0,
|
|
122
|
+
currentTask: "", listTitle: "",
|
|
123
|
+
};
|
|
124
|
+
let lastStatus: Status = "idle";
|
|
125
|
+
|
|
126
|
+
async function captureOriginalTitle() {
|
|
127
|
+
if (originalTitleCaptured) return;
|
|
128
|
+
originalTitleCaptured = true;
|
|
129
|
+
// Get current workspace name before we modify it
|
|
130
|
+
const tree = await cmux(pi, "current-workspace");
|
|
131
|
+
const match = tree.match(/workspace:\d+\s+(.+?)(?:\s+\[selected\])?$/m);
|
|
132
|
+
if (match) {
|
|
133
|
+
originalTitle = match[1].trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function updateCmux(snap: TaskSnapshot) {
|
|
138
|
+
const status = deriveStatus(snap);
|
|
139
|
+
const title = buildTitle(snap, status);
|
|
140
|
+
const desc = buildDescription(snap, status);
|
|
141
|
+
|
|
142
|
+
// Only update if something changed
|
|
143
|
+
const statusChanged = status !== lastStatus;
|
|
144
|
+
const snapChanged = JSON.stringify(snap) !== JSON.stringify(lastSnap);
|
|
145
|
+
if (!statusChanged && !snapChanged) return;
|
|
146
|
+
|
|
147
|
+
lastSnap = { ...snap };
|
|
148
|
+
lastStatus = status;
|
|
149
|
+
|
|
150
|
+
// Fire all updates in parallel
|
|
151
|
+
const updates: Promise<string>[] = [
|
|
152
|
+
cmux(pi, "workspace-action", "--action", "set-color", "--color", COLORS[status]),
|
|
153
|
+
cmux(pi, "rename-workspace", title),
|
|
154
|
+
];
|
|
155
|
+
if (desc) {
|
|
156
|
+
updates.push(cmux(pi, "workspace-action", "--action", "set-description", "--description", desc));
|
|
157
|
+
} else {
|
|
158
|
+
updates.push(cmux(pi, "workspace-action", "--action", "clear-description"));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await Promise.all(updates);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Hooks ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
pi.on("session_start", async () => {
|
|
167
|
+
await captureOriginalTitle();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// After every tilldone tool call, refresh cmux state from the
|
|
171
|
+
// structured details the tilldone tool attaches to its result
|
|
172
|
+
pi.on("tool_result", async (event) => {
|
|
173
|
+
await captureOriginalTitle();
|
|
174
|
+
|
|
175
|
+
if (event.toolName !== "tilldone") return;
|
|
176
|
+
|
|
177
|
+
const details = event.details as TillDoneDetails | undefined;
|
|
178
|
+
if (!details?.tasks) return;
|
|
179
|
+
|
|
180
|
+
await updateCmux(snapshotFromDetails(details));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// On agent end, do a final status update from the last known state
|
|
184
|
+
pi.on("agent_end", async () => {
|
|
185
|
+
if (lastSnap.total > 0) {
|
|
186
|
+
await updateCmux(lastSnap);
|
|
187
|
+
// If all done, send a notification too
|
|
188
|
+
if (lastSnap.done === lastSnap.total) {
|
|
189
|
+
await cmux(pi, "notify", "--title", "✅ All Tasks Complete",
|
|
190
|
+
"--body", `${lastSnap.listTitle}: ${lastSnap.total} tasks done`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Clean up on session shutdown — restore original title and clear color
|
|
196
|
+
pi.on("session_shutdown", async () => {
|
|
197
|
+
if (originalTitle) {
|
|
198
|
+
await Promise.all([
|
|
199
|
+
cmux(pi, "rename-workspace", originalTitle),
|
|
200
|
+
cmux(pi, "workspace-action", "--action", "clear-color"),
|
|
201
|
+
cmux(pi, "workspace-action", "--action", "clear-description"),
|
|
202
|
+
]);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TillDone Extension — Work Till It's Done
|
|
3
|
+
*
|
|
4
|
+
* A task-driven discipline extension. The agent MUST define what it's going
|
|
5
|
+
* to do (via `tilldone add`) before it can use any other tools. On agent
|
|
6
|
+
* completion, if tasks remain incomplete, the agent gets nudged to continue
|
|
7
|
+
* or mark them done. Play on words: "todo" → "tilldone" (work till done).
|
|
8
|
+
*
|
|
9
|
+
* Three-state lifecycle: idle → inprogress → done
|
|
10
|
+
*
|
|
11
|
+
* Each list has a title and description that give the tasks a theme.
|
|
12
|
+
* Use `new-list` to start a fresh list. `clear` wipes tasks with user confirm.
|
|
13
|
+
*
|
|
14
|
+
* UI surfaces:
|
|
15
|
+
* - Footer: persistent task list with live progress + list title
|
|
16
|
+
* - Widget: prominent "current task" display (the inprogress task)
|
|
17
|
+
* - Status: compact summary in the status line
|
|
18
|
+
* - /tilldone: interactive overlay with full task details
|
|
19
|
+
*
|
|
20
|
+
* Usage: pi install npm:@structupath/pi-tilldone
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
24
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
25
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
26
|
+
import { Container, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
27
|
+
import { Type } from "typebox";
|
|
28
|
+
|
|
29
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
type TaskStatus = "idle" | "inprogress" | "done";
|
|
32
|
+
|
|
33
|
+
interface Task {
|
|
34
|
+
id: number;
|
|
35
|
+
text: string;
|
|
36
|
+
status: TaskStatus;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface TillDoneDetails {
|
|
40
|
+
action: string;
|
|
41
|
+
tasks: Task[];
|
|
42
|
+
nextId: number;
|
|
43
|
+
listTitle?: string;
|
|
44
|
+
listDescription?: string;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const TillDoneParams = Type.Object({
|
|
49
|
+
action: StringEnum(["new-list", "add", "toggle", "remove", "update", "list", "clear"] as const),
|
|
50
|
+
text: Type.Optional(Type.String({ description: "Task text (for add/update), or list title (for new-list)" })),
|
|
51
|
+
texts: Type.Optional(Type.Array(Type.String(), { description: "Multiple task texts (for add). Use this to batch-add several tasks at once." })),
|
|
52
|
+
description: Type.Optional(Type.String({ description: "List description (for new-list)" })),
|
|
53
|
+
id: Type.Optional(Type.Number({ description: "Task ID (for toggle/remove/update)" })),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── Status helpers ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const STATUS_ICON: Record<TaskStatus, string> = { idle: "○", inprogress: "●", done: "✓" };
|
|
59
|
+
const NEXT_STATUS: Record<TaskStatus, TaskStatus> = { idle: "inprogress", inprogress: "done", done: "idle" };
|
|
60
|
+
const STATUS_LABEL: Record<TaskStatus, string> = { idle: "idle", inprogress: "in progress", done: "done" };
|
|
61
|
+
|
|
62
|
+
// ── /tilldone overlay component ────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
class TillDoneListComponent {
|
|
65
|
+
private tasks: Task[];
|
|
66
|
+
private title: string | undefined;
|
|
67
|
+
private desc: string | undefined;
|
|
68
|
+
private theme: Theme;
|
|
69
|
+
private onClose: () => void;
|
|
70
|
+
private cachedWidth?: number;
|
|
71
|
+
private cachedLines?: string[];
|
|
72
|
+
|
|
73
|
+
constructor(tasks: Task[], title: string | undefined, desc: string | undefined, theme: Theme, onClose: () => void) {
|
|
74
|
+
this.tasks = tasks;
|
|
75
|
+
this.title = title;
|
|
76
|
+
this.desc = desc;
|
|
77
|
+
this.theme = theme;
|
|
78
|
+
this.onClose = onClose;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
handleInput(data: string): void {
|
|
82
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
83
|
+
this.onClose();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
render(width: number): string[] {
|
|
88
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
89
|
+
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
const th = this.theme;
|
|
92
|
+
|
|
93
|
+
lines.push("");
|
|
94
|
+
const heading = this.title
|
|
95
|
+
? th.fg("accent", ` ${this.title} `)
|
|
96
|
+
: th.fg("accent", " TillDone ");
|
|
97
|
+
const headingLen = this.title ? this.title.length + 2 : 10;
|
|
98
|
+
lines.push(truncateToWidth(
|
|
99
|
+
th.fg("borderMuted", "─".repeat(3)) + heading +
|
|
100
|
+
th.fg("borderMuted", "─".repeat(Math.max(0, width - 3 - headingLen))),
|
|
101
|
+
width,
|
|
102
|
+
));
|
|
103
|
+
|
|
104
|
+
if (this.desc) {
|
|
105
|
+
lines.push(truncateToWidth(` ${th.fg("muted", this.desc)}`, width));
|
|
106
|
+
}
|
|
107
|
+
lines.push("");
|
|
108
|
+
|
|
109
|
+
if (this.tasks.length === 0) {
|
|
110
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to add some!")}`, width));
|
|
111
|
+
} else {
|
|
112
|
+
const done = this.tasks.filter((t) => t.status === "done").length;
|
|
113
|
+
const active = this.tasks.filter((t) => t.status === "inprogress").length;
|
|
114
|
+
const idle = this.tasks.filter((t) => t.status === "idle").length;
|
|
115
|
+
|
|
116
|
+
lines.push(truncateToWidth(
|
|
117
|
+
" " +
|
|
118
|
+
th.fg("success", `${done} done`) + th.fg("dim", " ") +
|
|
119
|
+
th.fg("accent", `${active} active`) + th.fg("dim", " ") +
|
|
120
|
+
th.fg("muted", `${idle} idle`),
|
|
121
|
+
width,
|
|
122
|
+
));
|
|
123
|
+
lines.push("");
|
|
124
|
+
|
|
125
|
+
for (const task of this.tasks) {
|
|
126
|
+
const icon = task.status === "done"
|
|
127
|
+
? th.fg("success", STATUS_ICON.done)
|
|
128
|
+
: task.status === "inprogress"
|
|
129
|
+
? th.fg("accent", STATUS_ICON.inprogress)
|
|
130
|
+
: th.fg("dim", STATUS_ICON.idle);
|
|
131
|
+
const id = th.fg("accent", `#${task.id}`);
|
|
132
|
+
const text = task.status === "done"
|
|
133
|
+
? th.fg("dim", task.text)
|
|
134
|
+
: task.status === "inprogress"
|
|
135
|
+
? th.fg("success", task.text)
|
|
136
|
+
: th.fg("muted", task.text);
|
|
137
|
+
lines.push(truncateToWidth(` ${icon} ${id} ${text}`, width));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
|
|
143
|
+
lines.push("");
|
|
144
|
+
|
|
145
|
+
this.cachedWidth = width;
|
|
146
|
+
this.cachedLines = lines;
|
|
147
|
+
return lines;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
invalidate(): void {
|
|
151
|
+
this.cachedWidth = undefined;
|
|
152
|
+
this.cachedLines = undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Extension entry point ──────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export default function (pi: ExtensionAPI) {
|
|
159
|
+
let tasks: Task[] = [];
|
|
160
|
+
let nextId = 1;
|
|
161
|
+
let listTitle: string | undefined;
|
|
162
|
+
let listDescription: string | undefined;
|
|
163
|
+
let nudgedThisCycle = false;
|
|
164
|
+
|
|
165
|
+
// ── Snapshot for details ───────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
const makeDetails = (action: string, error?: string): TillDoneDetails => ({
|
|
168
|
+
action,
|
|
169
|
+
tasks: [...tasks],
|
|
170
|
+
nextId,
|
|
171
|
+
listTitle,
|
|
172
|
+
listDescription,
|
|
173
|
+
...(error ? { error } : {}),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── UI refresh ─────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const refreshWidget = (ctx: ExtensionContext) => {
|
|
179
|
+
const current = tasks.find((t) => t.status === "inprogress");
|
|
180
|
+
|
|
181
|
+
if (!current) {
|
|
182
|
+
ctx.ui.setWidget("tilldone-current", undefined);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
ctx.ui.setWidget("tilldone-current", (_tui, theme) => {
|
|
187
|
+
const container = new Container();
|
|
188
|
+
const borderFn = (s: string) => theme.fg("dim", s);
|
|
189
|
+
|
|
190
|
+
container.addChild(new Text("", 0, 0));
|
|
191
|
+
container.addChild(new DynamicBorder(borderFn));
|
|
192
|
+
const content = new Text("", 1, 0);
|
|
193
|
+
container.addChild(content);
|
|
194
|
+
container.addChild(new DynamicBorder(borderFn));
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
render(width: number): string[] {
|
|
198
|
+
const cur = tasks.find((t) => t.status === "inprogress");
|
|
199
|
+
if (!cur) return [];
|
|
200
|
+
|
|
201
|
+
const line =
|
|
202
|
+
theme.fg("accent", "● ") +
|
|
203
|
+
theme.fg("dim", "WORKING ON ") +
|
|
204
|
+
theme.fg("accent", `#${cur.id}`) +
|
|
205
|
+
theme.fg("dim", " ") +
|
|
206
|
+
theme.fg("success", cur.text);
|
|
207
|
+
|
|
208
|
+
content.setText(truncateToWidth(line, width - 4));
|
|
209
|
+
return container.render(width);
|
|
210
|
+
},
|
|
211
|
+
invalidate() { container.invalidate(); },
|
|
212
|
+
};
|
|
213
|
+
}, { placement: "belowEditor" });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const refreshFooter = (ctx: ExtensionContext) => {
|
|
217
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
218
|
+
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
dispose: unsub,
|
|
222
|
+
invalidate() {},
|
|
223
|
+
render(width: number): string[] {
|
|
224
|
+
const done = tasks.filter((t) => t.status === "done").length;
|
|
225
|
+
const active = tasks.filter((t) => t.status === "inprogress").length;
|
|
226
|
+
const idle = tasks.filter((t) => t.status === "idle").length;
|
|
227
|
+
const total = tasks.length;
|
|
228
|
+
|
|
229
|
+
// ── Line 1: list title + progress (left), counts (right) ──
|
|
230
|
+
const titleDisplay = listTitle
|
|
231
|
+
? theme.fg("accent", ` ${listTitle} `)
|
|
232
|
+
: theme.fg("dim", " TillDone ");
|
|
233
|
+
|
|
234
|
+
const l1Left = total === 0
|
|
235
|
+
? titleDisplay + theme.fg("muted", "no tasks")
|
|
236
|
+
: titleDisplay +
|
|
237
|
+
theme.fg("warning", "[") +
|
|
238
|
+
theme.fg("success", `${done}`) +
|
|
239
|
+
theme.fg("dim", "/") +
|
|
240
|
+
theme.fg("success", `${total}`) +
|
|
241
|
+
theme.fg("warning", "]");
|
|
242
|
+
|
|
243
|
+
const l1Right = total === 0
|
|
244
|
+
? ""
|
|
245
|
+
: theme.fg("dim", STATUS_ICON.idle + " ") + theme.fg("muted", `${idle}`) +
|
|
246
|
+
theme.fg("dim", " ") +
|
|
247
|
+
theme.fg("accent", STATUS_ICON.inprogress + " ") + theme.fg("accent", `${active}`) +
|
|
248
|
+
theme.fg("dim", " ") +
|
|
249
|
+
theme.fg("success", STATUS_ICON.done + " ") + theme.fg("success", `${done}`) +
|
|
250
|
+
theme.fg("dim", " ");
|
|
251
|
+
|
|
252
|
+
const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right)));
|
|
253
|
+
const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, "");
|
|
254
|
+
|
|
255
|
+
if (total === 0) return [line1];
|
|
256
|
+
|
|
257
|
+
// ── Rows: inprogress first, then most recent done, max 5 ──
|
|
258
|
+
const activeTasks = tasks.filter((t) => t.status === "inprogress");
|
|
259
|
+
const doneTasks = tasks.filter((t) => t.status === "done").reverse();
|
|
260
|
+
const visible = [...activeTasks, ...doneTasks].slice(0, 5);
|
|
261
|
+
const remaining = total - visible.length;
|
|
262
|
+
|
|
263
|
+
const rows = visible.map((t) => {
|
|
264
|
+
const icon = t.status === "done"
|
|
265
|
+
? theme.fg("success", STATUS_ICON.done)
|
|
266
|
+
: theme.fg("accent", STATUS_ICON.inprogress);
|
|
267
|
+
const text = t.status === "done"
|
|
268
|
+
? theme.fg("dim", t.text)
|
|
269
|
+
: theme.fg("success", t.text);
|
|
270
|
+
return truncateToWidth(` ${icon} ${text}`, width, "");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (remaining > 0) {
|
|
274
|
+
rows.push(truncateToWidth(
|
|
275
|
+
` ${theme.fg("dim", ` +${remaining} more`)}`,
|
|
276
|
+
width, "",
|
|
277
|
+
));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return [line1, ...rows];
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const refreshUI = (ctx: ExtensionContext) => {
|
|
287
|
+
if (tasks.length === 0) {
|
|
288
|
+
ctx.ui.setStatus("📋 TillDone: no tasks", "tilldone");
|
|
289
|
+
} else {
|
|
290
|
+
const remaining = tasks.filter((t) => t.status !== "done").length;
|
|
291
|
+
const label = listTitle ? `📋 ${listTitle}` : "📋 TillDone";
|
|
292
|
+
ctx.ui.setStatus(`${label}: ${tasks.length} tasks (${remaining} remaining)`, "tilldone");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
refreshWidget(ctx);
|
|
296
|
+
refreshFooter(ctx);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ── State reconstruction from session ──────────────────────────────
|
|
300
|
+
|
|
301
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
302
|
+
tasks = [];
|
|
303
|
+
nextId = 1;
|
|
304
|
+
listTitle = undefined;
|
|
305
|
+
listDescription = undefined;
|
|
306
|
+
|
|
307
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
308
|
+
if (entry.type !== "message") continue;
|
|
309
|
+
const msg = entry.message;
|
|
310
|
+
if (msg.role !== "toolResult" || msg.toolName !== "tilldone") continue;
|
|
311
|
+
|
|
312
|
+
const details = msg.details as TillDoneDetails | undefined;
|
|
313
|
+
if (details) {
|
|
314
|
+
tasks = details.tasks;
|
|
315
|
+
nextId = details.nextId;
|
|
316
|
+
listTitle = details.listTitle;
|
|
317
|
+
listDescription = details.listDescription;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
refreshUI(ctx);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// session_start covers startup, reload, new, resume, and fork
|
|
325
|
+
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
|
326
|
+
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
|
327
|
+
|
|
328
|
+
// ── Blocking gate ──────────────────────────────────────────────────
|
|
329
|
+
// Only blocks write/execute tools. Read-only tools (read, grep, find,
|
|
330
|
+
// ls, glob) are always allowed so agents can explore before planning.
|
|
331
|
+
// Subagent tools and dispatch tools are also whitelisted.
|
|
332
|
+
|
|
333
|
+
const READ_ONLY_TOOLS = new Set([
|
|
334
|
+
"read", "grep", "find", "ls", "glob",
|
|
335
|
+
"query_experts",
|
|
336
|
+
"subagent_create", "subagent_continue", "subagent_list", "subagent_remove",
|
|
337
|
+
"dispatch_agent",
|
|
338
|
+
"run_chain",
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
pi.on("tool_call", async (event, _ctx) => {
|
|
342
|
+
if (event.toolName === "tilldone") return { block: false };
|
|
343
|
+
if (READ_ONLY_TOOLS.has(event.toolName)) return { block: false };
|
|
344
|
+
|
|
345
|
+
const pending = tasks.filter((t) => t.status !== "done");
|
|
346
|
+
const active = tasks.filter((t) => t.status === "inprogress");
|
|
347
|
+
|
|
348
|
+
if (tasks.length === 0) {
|
|
349
|
+
return {
|
|
350
|
+
block: true,
|
|
351
|
+
reason: "🚫 No TillDone tasks defined. Use `tilldone new-list` or `tilldone add` to define your tasks before using write/execute tools.",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (pending.length === 0) {
|
|
355
|
+
return {
|
|
356
|
+
block: true,
|
|
357
|
+
reason: "🚫 All TillDone tasks are done. Use `tilldone add` for new tasks or `tilldone new-list` to start a fresh list.",
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
if (active.length === 0) {
|
|
361
|
+
return {
|
|
362
|
+
block: true,
|
|
363
|
+
reason: "🚫 No task is in progress. Use `tilldone toggle` to mark a task as inprogress before doing any work.",
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { block: false };
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// ── Auto-nudge on agent_end ────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
pi.on("agent_end", async (_event, _ctx) => {
|
|
373
|
+
const incomplete = tasks.filter((t) => t.status !== "done");
|
|
374
|
+
if (incomplete.length === 0 || nudgedThisCycle) return;
|
|
375
|
+
|
|
376
|
+
nudgedThisCycle = true;
|
|
377
|
+
|
|
378
|
+
const taskList = incomplete
|
|
379
|
+
.map((t) => ` ${STATUS_ICON[t.status]} #${t.id} [${STATUS_LABEL[t.status]}]: ${t.text}`)
|
|
380
|
+
.join("\n");
|
|
381
|
+
|
|
382
|
+
pi.sendMessage(
|
|
383
|
+
{
|
|
384
|
+
customType: "tilldone-nudge",
|
|
385
|
+
content: `⚠️ You still have ${incomplete.length} incomplete task(s):\n\n${taskList}\n\nEither continue working on them or mark them done with \`tilldone toggle\`. Don't stop until it's done!`,
|
|
386
|
+
display: true,
|
|
387
|
+
},
|
|
388
|
+
{ triggerTurn: true },
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
pi.on("input", async () => {
|
|
393
|
+
nudgedThisCycle = false;
|
|
394
|
+
return { action: "continue" as const };
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── Register tilldone tool ─────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
pi.registerTool({
|
|
400
|
+
name: "tilldone",
|
|
401
|
+
label: "TillDone",
|
|
402
|
+
description:
|
|
403
|
+
"Manage your task list. You MUST add tasks before using any other tools. " +
|
|
404
|
+
"Actions: new-list (text=title, description), add (text or texts[] for batch), toggle (id) — cycles idle→inprogress→done, remove (id), update (id + text), list, clear. " +
|
|
405
|
+
"Always toggle a task to inprogress before starting work on it, and to done when finished. " +
|
|
406
|
+
"Use new-list to start a themed list with a title and description. " +
|
|
407
|
+
"IMPORTANT: If the user's new request does not fit the current list's theme, use clear to wipe the slate and new-list to start fresh.",
|
|
408
|
+
parameters: TillDoneParams,
|
|
409
|
+
|
|
410
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
411
|
+
switch (params.action) {
|
|
412
|
+
case "new-list": {
|
|
413
|
+
if (!params.text) {
|
|
414
|
+
return {
|
|
415
|
+
content: [{ type: "text" as const, text: "Error: text (title) required for new-list" }],
|
|
416
|
+
details: makeDetails("new-list", "text required"),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// If a list already exists, confirm before replacing
|
|
421
|
+
if (tasks.length > 0 || listTitle) {
|
|
422
|
+
const confirmed = await ctx.ui.confirm(
|
|
423
|
+
"Start a new list?",
|
|
424
|
+
`This will replace${listTitle ? ` "${listTitle}"` : " the current list"} (${tasks.length} task(s)). Continue?`,
|
|
425
|
+
{ timeout: 30000 },
|
|
426
|
+
);
|
|
427
|
+
if (!confirmed) {
|
|
428
|
+
return {
|
|
429
|
+
content: [{ type: "text" as const, text: "New list cancelled by user." }],
|
|
430
|
+
details: makeDetails("new-list", "cancelled"),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
tasks = [];
|
|
436
|
+
nextId = 1;
|
|
437
|
+
listTitle = params.text;
|
|
438
|
+
listDescription = params.description || undefined;
|
|
439
|
+
|
|
440
|
+
const result = {
|
|
441
|
+
content: [{
|
|
442
|
+
type: "text" as const,
|
|
443
|
+
text: `New list: "${listTitle}"${listDescription ? ` — ${listDescription}` : ""}`,
|
|
444
|
+
}],
|
|
445
|
+
details: makeDetails("new-list"),
|
|
446
|
+
};
|
|
447
|
+
refreshUI(ctx);
|
|
448
|
+
return result;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
case "list": {
|
|
452
|
+
const header = listTitle ? `${listTitle}:` : "";
|
|
453
|
+
const result = {
|
|
454
|
+
content: [{
|
|
455
|
+
type: "text" as const,
|
|
456
|
+
text: tasks.length
|
|
457
|
+
? (header ? header + "\n" : "") +
|
|
458
|
+
tasks.map((t) => `[${STATUS_ICON[t.status]}] #${t.id} (${t.status}): ${t.text}`).join("\n")
|
|
459
|
+
: "No tasks defined yet.",
|
|
460
|
+
}],
|
|
461
|
+
details: makeDetails("list"),
|
|
462
|
+
};
|
|
463
|
+
refreshUI(ctx);
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case "add": {
|
|
468
|
+
const items = params.texts?.length ? params.texts : params.text ? [params.text] : [];
|
|
469
|
+
if (items.length === 0) {
|
|
470
|
+
return {
|
|
471
|
+
content: [{ type: "text" as const, text: "Error: text or texts required for add" }],
|
|
472
|
+
details: makeDetails("add", "text required"),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
const added: Task[] = [];
|
|
476
|
+
for (const item of items) {
|
|
477
|
+
const t: Task = { id: nextId++, text: item, status: "idle" };
|
|
478
|
+
tasks.push(t);
|
|
479
|
+
added.push(t);
|
|
480
|
+
}
|
|
481
|
+
const msg = added.length === 1
|
|
482
|
+
? `Added task #${added[0].id}: ${added[0].text}`
|
|
483
|
+
: `Added ${added.length} tasks: ${added.map((t) => `#${t.id}`).join(", ")}`;
|
|
484
|
+
const result = {
|
|
485
|
+
content: [{ type: "text" as const, text: msg }],
|
|
486
|
+
details: makeDetails("add"),
|
|
487
|
+
};
|
|
488
|
+
refreshUI(ctx);
|
|
489
|
+
return result;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
case "toggle": {
|
|
493
|
+
if (params.id === undefined) {
|
|
494
|
+
return {
|
|
495
|
+
content: [{ type: "text" as const, text: "Error: id required for toggle" }],
|
|
496
|
+
details: makeDetails("toggle", "id required"),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
const task = tasks.find((t) => t.id === params.id);
|
|
500
|
+
if (!task) {
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
|
|
503
|
+
details: makeDetails("toggle", `#${params.id} not found`),
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const prev = task.status;
|
|
507
|
+
task.status = NEXT_STATUS[task.status];
|
|
508
|
+
|
|
509
|
+
// Enforce single inprogress — demote any other active task
|
|
510
|
+
const demoted: Task[] = [];
|
|
511
|
+
if (task.status === "inprogress") {
|
|
512
|
+
for (const t of tasks) {
|
|
513
|
+
if (t.id !== task.id && t.status === "inprogress") {
|
|
514
|
+
t.status = "idle";
|
|
515
|
+
demoted.push(t);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
let msg = `Task #${task.id}: ${prev} → ${task.status}`;
|
|
521
|
+
if (demoted.length > 0) {
|
|
522
|
+
msg += `\n(Auto-paused ${demoted.map((t) => `#${t.id}`).join(", ")} → idle. Only one task can be in progress at a time.)`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const result = {
|
|
526
|
+
content: [{
|
|
527
|
+
type: "text" as const,
|
|
528
|
+
text: msg,
|
|
529
|
+
}],
|
|
530
|
+
details: makeDetails("toggle"),
|
|
531
|
+
};
|
|
532
|
+
refreshUI(ctx);
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
case "remove": {
|
|
537
|
+
if (params.id === undefined) {
|
|
538
|
+
return {
|
|
539
|
+
content: [{ type: "text" as const, text: "Error: id required for remove" }],
|
|
540
|
+
details: makeDetails("remove", "id required"),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const idx = tasks.findIndex((t) => t.id === params.id);
|
|
544
|
+
if (idx === -1) {
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
|
|
547
|
+
details: makeDetails("remove", `#${params.id} not found`),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
const removed = tasks.splice(idx, 1)[0];
|
|
551
|
+
const result = {
|
|
552
|
+
content: [{ type: "text" as const, text: `Removed task #${removed.id}: ${removed.text}` }],
|
|
553
|
+
details: makeDetails("remove"),
|
|
554
|
+
};
|
|
555
|
+
refreshUI(ctx);
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
case "update": {
|
|
560
|
+
if (params.id === undefined) {
|
|
561
|
+
return {
|
|
562
|
+
content: [{ type: "text" as const, text: "Error: id required for update" }],
|
|
563
|
+
details: makeDetails("update", "id required"),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
if (!params.text) {
|
|
567
|
+
return {
|
|
568
|
+
content: [{ type: "text" as const, text: "Error: text required for update" }],
|
|
569
|
+
details: makeDetails("update", "text required"),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const toUpdate = tasks.find((t) => t.id === params.id);
|
|
573
|
+
if (!toUpdate) {
|
|
574
|
+
return {
|
|
575
|
+
content: [{ type: "text" as const, text: `Task #${params.id} not found` }],
|
|
576
|
+
details: makeDetails("update", `#${params.id} not found`),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
const oldText = toUpdate.text;
|
|
580
|
+
toUpdate.text = params.text;
|
|
581
|
+
const result = {
|
|
582
|
+
content: [{ type: "text" as const, text: `Updated #${toUpdate.id}: "${oldText}" → "${toUpdate.text}"` }],
|
|
583
|
+
details: makeDetails("update"),
|
|
584
|
+
};
|
|
585
|
+
refreshUI(ctx);
|
|
586
|
+
return result;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
case "clear": {
|
|
590
|
+
if (tasks.length > 0) {
|
|
591
|
+
const confirmed = await ctx.ui.confirm(
|
|
592
|
+
"Clear TillDone list?",
|
|
593
|
+
`This will remove all ${tasks.length} task(s)${listTitle ? ` from "${listTitle}"` : ""}. Continue?`,
|
|
594
|
+
{ timeout: 30000 },
|
|
595
|
+
);
|
|
596
|
+
if (!confirmed) {
|
|
597
|
+
return {
|
|
598
|
+
content: [{ type: "text" as const, text: "Clear cancelled by user." }],
|
|
599
|
+
details: makeDetails("clear", "cancelled"),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const count = tasks.length;
|
|
605
|
+
tasks = [];
|
|
606
|
+
nextId = 1;
|
|
607
|
+
listTitle = undefined;
|
|
608
|
+
listDescription = undefined;
|
|
609
|
+
|
|
610
|
+
const result = {
|
|
611
|
+
content: [{ type: "text" as const, text: `Cleared ${count} task(s)` }],
|
|
612
|
+
details: makeDetails("clear"),
|
|
613
|
+
};
|
|
614
|
+
refreshUI(ctx);
|
|
615
|
+
return result;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
default:
|
|
619
|
+
return {
|
|
620
|
+
content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }],
|
|
621
|
+
details: makeDetails("list", `unknown action: ${params.action}`),
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
renderCall(args, theme) {
|
|
627
|
+
let text = theme.fg("toolTitle", theme.bold("tilldone ")) + theme.fg("muted", args.action);
|
|
628
|
+
if (args.texts?.length) text += ` ${theme.fg("dim", `${args.texts.length} tasks`)}`;
|
|
629
|
+
else if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`;
|
|
630
|
+
if (args.description) text += ` ${theme.fg("dim", `— ${args.description}`)}`;
|
|
631
|
+
if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`;
|
|
632
|
+
return new Text(text, 0, 0);
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
renderResult(result, { expanded }, theme) {
|
|
636
|
+
const details = result.details as TillDoneDetails | undefined;
|
|
637
|
+
if (!details) {
|
|
638
|
+
const text = result.content[0];
|
|
639
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (details.error) {
|
|
643
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const taskList = details.tasks;
|
|
647
|
+
|
|
648
|
+
switch (details.action) {
|
|
649
|
+
case "new-list": {
|
|
650
|
+
let msg = theme.fg("success", "✓ New list ") + theme.fg("accent", `"${details.listTitle}"`);
|
|
651
|
+
if (details.listDescription) {
|
|
652
|
+
msg += theme.fg("dim", ` — ${details.listDescription}`);
|
|
653
|
+
}
|
|
654
|
+
return new Text(msg, 0, 0);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
case "list": {
|
|
658
|
+
if (taskList.length === 0) return new Text(theme.fg("dim", "No tasks"), 0, 0);
|
|
659
|
+
|
|
660
|
+
let listText = "";
|
|
661
|
+
if (details.listTitle) {
|
|
662
|
+
listText += theme.fg("accent", details.listTitle) + theme.fg("dim", " ");
|
|
663
|
+
}
|
|
664
|
+
listText += theme.fg("muted", `${taskList.length} task(s):`);
|
|
665
|
+
const display = expanded ? taskList : taskList.slice(0, 5);
|
|
666
|
+
for (const t of display) {
|
|
667
|
+
const icon = t.status === "done"
|
|
668
|
+
? theme.fg("success", STATUS_ICON.done)
|
|
669
|
+
: t.status === "inprogress"
|
|
670
|
+
? theme.fg("accent", STATUS_ICON.inprogress)
|
|
671
|
+
: theme.fg("dim", STATUS_ICON.idle);
|
|
672
|
+
const itemText = t.status === "done"
|
|
673
|
+
? theme.fg("dim", t.text)
|
|
674
|
+
: t.status === "inprogress"
|
|
675
|
+
? theme.fg("success", t.text)
|
|
676
|
+
: theme.fg("muted", t.text);
|
|
677
|
+
listText += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${itemText}`;
|
|
678
|
+
}
|
|
679
|
+
if (!expanded && taskList.length > 5) {
|
|
680
|
+
listText += `\n${theme.fg("dim", `... ${taskList.length - 5} more`)}`;
|
|
681
|
+
}
|
|
682
|
+
return new Text(listText, 0, 0);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
case "add": {
|
|
686
|
+
const text = result.content[0];
|
|
687
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
688
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
case "toggle": {
|
|
692
|
+
const text = result.content[0];
|
|
693
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
694
|
+
return new Text(theme.fg("accent", "⟳ ") + theme.fg("muted", msg), 0, 0);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
case "remove": {
|
|
698
|
+
const text = result.content[0];
|
|
699
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
700
|
+
return new Text(theme.fg("warning", "✕ ") + theme.fg("muted", msg), 0, 0);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
case "update": {
|
|
704
|
+
const text = result.content[0];
|
|
705
|
+
const msg = text?.type === "text" ? text.text : "";
|
|
706
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
case "clear":
|
|
710
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all tasks"), 0, 0);
|
|
711
|
+
|
|
712
|
+
default:
|
|
713
|
+
return new Text(theme.fg("dim", "done"), 0, 0);
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ── /tilldone command ──────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
pi.registerCommand("tilldone", {
|
|
721
|
+
description: "Show all TillDone tasks on the current branch",
|
|
722
|
+
handler: async (_args, ctx) => {
|
|
723
|
+
if (!ctx.hasUI) {
|
|
724
|
+
ctx.ui.notify("/tilldone requires interactive mode", "error");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
|
|
729
|
+
return new TillDoneListComponent(tasks, listTitle, listDescription, theme, () => done());
|
|
730
|
+
});
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@structupath/pi-tilldone",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Task-discipline extension for the Pi coding agent — the agent must declare tasks before it can touch write/execute tools, and gets nudged until every task is done. Includes cmux tab-status integration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"tasks",
|
|
10
|
+
"todo",
|
|
11
|
+
"discipline",
|
|
12
|
+
"planning",
|
|
13
|
+
"agent-workflow",
|
|
14
|
+
"cmux"
|
|
15
|
+
],
|
|
16
|
+
"author": "StructuPath (https://structupath.ai)",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/StructuPath/pi-tilldone.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://structupath.ai",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.6.0"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./extensions/tilldone.ts",
|
|
29
|
+
"./extensions/cmux-status.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"extensions",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@mariozechner/pi-ai": "*",
|
|
42
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
43
|
+
"@mariozechner/pi-tui": "*",
|
|
44
|
+
"typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"typescript": "^5.6.0"
|
|
49
|
+
}
|
|
50
|
+
}
|