feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
.pi/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 lesetong
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,150 @@
|
||||
## Forked from [leset0ng/pi-todo-herdr](https://github.com/leset0ng/pi-todo-herdr)
|
||||
|
||||
# pi-todo-herdr
|
||||
|
||||
Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic batch tools, a live task-tree widget, and optional [Herdr](https://herdr.dev) sidebar integration.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Five focused tools: `set_tasks`, `patch_tasks`, `rm_task`, `list_task`, and `get_task`
|
||||
- Parent/child task trees with generated integer IDs and atomic append/replace modes
|
||||
- Atomic batch mutations: every requested change succeeds or none of it is applied
|
||||
- Bounded task input and widget output to keep sessions and terminals responsive
|
||||
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
||||
- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore
|
||||
- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction
|
||||
- A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder
|
||||
- Compact, theme-aware Pi Todo header and task tree with hierarchy, status colors, timers, and an adaptive progress bar
|
||||
- Optional responsive Todo + Subagents panels when the interactive-subagents extension is present
|
||||
- Optional Herdr metadata showing the current task in the agent sidebar
|
||||
|
||||
## Requirements
|
||||
|
||||
- Pi 0.81 or newer
|
||||
- Node.js 22.19 or newer
|
||||
- Herdr is optional; all task tools and the Pi widget work without it
|
||||
|
||||
## Install
|
||||
|
||||
Install the latest version from npm:
|
||||
|
||||
```bash
|
||||
pi install npm:@_jb/pi-todo-herdr
|
||||
```
|
||||
|
||||
Then start Pi. If Pi is already running, use `/reload` to load the extension.
|
||||
|
||||
To try the package for one run without installing it:
|
||||
|
||||
```bash
|
||||
pi -e npm:@_jb/pi-todo-herdr
|
||||
```
|
||||
|
||||
Update or remove it with Pi's package commands:
|
||||
|
||||
```bash
|
||||
pi update npm:@_jb/pi-todo-herdr
|
||||
pi remove npm:@_jb/pi-todo-herdr
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
For work with three or more distinct steps, the extension instructs Pi to explore enough to understand the scope, then call `set_tasks` before implementation. Smaller work remains untracked. Pi then updates task states at each real transition instead of reconstructing progress at the end.
|
||||
|
||||
After automatic or manual context compaction, the extension sends one short hidden reminder only when unfinished tasks exist. Pi calls `list_task` to recover the authoritative current IDs and statuses; the full tree is not injected on every model call.
|
||||
|
||||
| Tool | Parameters | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `set_tasks` | `{ mode, tasks, force }` | `append` extends the tree with `force: false`; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. |
|
||||
| `patch_tasks` | `{ tasks: [...] }` | Atomically patches multiple tasks. Use `null` for unchanged text/status, `parentId: -1` for unchanged or `0` to move to root, and `position: -1` to keep the current order. Completed trees may auto-clear; do not recreate cleared completed tasks. |
|
||||
| `rm_task` | `{ ids: [...], reason }` | Removes only mistaken, duplicate, or canceled tasks. Removing a parent cascades through its descendants. |
|
||||
| `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. |
|
||||
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. |
|
||||
|
||||
Example append call:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "append",
|
||||
"tasks": [
|
||||
{
|
||||
"name": "Implement authentication",
|
||||
"description": "Add session validation and route protection.",
|
||||
"status": "in-progress",
|
||||
"parentId": null,
|
||||
"children": [
|
||||
{
|
||||
"name": "Add auth middleware",
|
||||
"description": "Validate the session before protected handlers run.",
|
||||
"status": "pending",
|
||||
"parentId": null,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"force": false
|
||||
}
|
||||
```
|
||||
|
||||
Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields.
|
||||
|
||||
To keep model-generated input safe and bounded, a session may contain up to 200 tasks. One mutation may affect up to 50 explicitly supplied tasks. The widget header acts as a virtual root above three stored task levels: categories, items, and leaf sub-items. Names are limited to 200 Unicode characters and descriptions to 4,000 Unicode characters. Names and descriptions reject terminal controls and bidirectional override characters; descriptions may still contain line feeds for normal multi-line detail.
|
||||
|
||||
IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
||||
|
||||
A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601.
|
||||
|
||||
## Pi Widget
|
||||
|
||||
The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Ten seconds after every task is completed, the task tree is automatically deleted and task IDs reset to `1`; adding or reopening unfinished work during that delay cancels the clear. Its compact, unboxed header retains the themed `Todos` title, done/total count, blocked-work count when present, and an adaptive progress bar; the bar drops away first at narrow widths. Body rows retain the task tree's virtual-root connectors, hierarchy, statuses, and timers. The outline uses only the labels needed by the deepest task: one-level trees render every task as an unlabeled leaf, two-level trees use numbered parents and unlabeled leaves, and three-level trees use Roman-numeral categories, numbered items, and unlabeled leaves. Internal task IDs remain unchanged and visible in tool output. In-progress circles rotate through `◐ ◓ ◑ ◒` twice per second; other status symbols remain still. The standalone widget shows at most 12 task rows plus a compact count of hidden tasks.
|
||||
|
||||
```text
|
||||
Todos 2/6 · !1 ━━━━────
|
||||
├─ I. ◐ Build auth · 12:08
|
||||
│ ├─ 1. ! Resolve token issue · 03:41
|
||||
│ └─ ○ Inspect provider response
|
||||
└─ II. ✓ Define schema · 04:31
|
||||
```
|
||||
|
||||
When the optional interactive-subagents extension exposes its `statusWidget` integration, Todo claims the Subagents panel only while both extensions have content. The duck-typed widget must provide `hasContent()`, `getBodyRowCount()`, `render(width, maxBodyRows?, theme?)`, `claim(onChange)`, and `isClaimed(onChange)`. The optional `theme` is the current Pi theme object with the structural `fg(color, text)` formatter; Todo passes that exact object unchanged, while older providers may ignore the third argument. `isClaimed` must return true only for the exact callback supplied by the current runtime claim; this lets Todo recover safely after a provider reload or invalidation. Above 120 columns they render side by side with a one-cell gap and up to ten body rows per panel; the shorter panel is padded with plain spaces. At 120 columns or fewer, Todo renders first and Subagents below it with one blank line between sections. Stacked panels share ten body rows, preserving one row for each panel and reporting overflow honestly. If the extension is absent or exposes an older API, Todo falls back to its full-width Todo widget.
|
||||
|
||||
Tool calls remain compact in the conversation. Expand a tool result with Pi's normal tool-output shortcut to inspect affected tasks or full descriptions.
|
||||
|
||||
## Herdr Integration
|
||||
|
||||
When Pi runs inside Herdr, the extension reports the most specific `in-progress` task as a `task` pane metadata token. If nothing is in progress, it falls back to a blocked task. Parallel current tasks use a separate `task_count` token so Herdr can truncate long task names responsively while keeping the remaining count visible, for example `#7 Implement auth · +2`. The `task_progress` token reports aggregate completion as `done/total` while tasks remain unfinished and is omitted once all tasks are complete.
|
||||
|
||||
Add `$task`, `$task_count`, and `$task_progress` to the Herdr agent sidebar in `~/.config/herdr/config.toml`:
|
||||
|
||||
```toml
|
||||
[ui.sidebar.agents]
|
||||
rows = [["state_icon", "workspace", "tab"], ["$ask", "$ask_count"], ["$task_progress", "$task", "$task_count"]]
|
||||
```
|
||||
|
||||
Apply the change to a running Herdr server:
|
||||
|
||||
```bash
|
||||
herdr server reload-config
|
||||
```
|
||||
|
||||
Metadata failures never block task operations. The extension does not change Herdr's agent lifecycle or notification state.
|
||||
|
||||
## Development
|
||||
|
||||
Development uses the monorepo's pinned Bun toolchain and shared catalog.
|
||||
|
||||
```bash
|
||||
bun install --frozen-lockfile
|
||||
bun run --filter '@_jb/pi-todo-herdr' typecheck
|
||||
bun run --filter '@_jb/pi-todo-herdr' test
|
||||
(cd extensions/todo-herdr && bun pm pack --destination ../../dist)
|
||||
```
|
||||
|
||||
The package has no third-party runtime dependencies. Pi's runtime packages are declared as peers and supplied by Pi.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,5 @@
|
||||
# Compose Todo and Subagent widgets through an optional lease
|
||||
|
||||
Todo owns the combined Pi widget and discovers the Subagents panel through a structural, process-local interface. Subagents retains its standalone widget until Todo claims a callback-identified lease, and Todo verifies that lease before every combined render with `isClaimed(onChange)`. `isClaimed` recognizes only the exact callback for the current runtime claim, so a provider reload or invalidation causes Todo to release and reacquire instead of trusting a stale local handle. This avoids a package dependency across the two extensions, preserves either extension's standalone behavior, and lets reload invalidate stale UI callbacks without losing running subagents.
|
||||
|
||||
The optional render seam is `render(width, maxBodyRows?, theme?)`. When composing, Todo passes the exact current Pi theme object as `theme`; the only structural requirement is `fg(color, text): string`, and providers may ignore the third argument for compatibility with older implementations. Todo renders a compact unboxed header and task body. Above 120 columns the panels share a row with a one-cell gap and plain-space padding for a shorter panel; at 120 columns and below they stack with one blank line between sections. Combined layouts cap body rows at a shared ten-line budget.
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { createCompletedTaskAutoClear } from "./src/auto-clear.ts";
|
||||
import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts";
|
||||
import {
|
||||
COMPACTION_REMINDER,
|
||||
COMPACTION_REMINDER_TYPE,
|
||||
hasUnfinishedTasks,
|
||||
} from "./src/prompt.ts";
|
||||
import { TASK_STATE_ENTRY_TYPE, TaskState } from "./src/state.ts";
|
||||
import { registerTaskTools } from "./src/tools.ts";
|
||||
import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts";
|
||||
|
||||
export default function piTodoHerdr(pi: ExtensionAPI): void {
|
||||
const state = new TaskState();
|
||||
let widgetVisible = true;
|
||||
let activeContext: ExtensionContext | undefined;
|
||||
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => {
|
||||
try {
|
||||
pi.appendEntry(TASK_STATE_ENTRY_TYPE, { tasks: [], nextId: 1 });
|
||||
} catch (error) {
|
||||
console.error("[pi-todo-herdr] failed to persist automatic task clear; retrying:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
state.set("replace", [], false);
|
||||
if (activeContext !== undefined) {
|
||||
try {
|
||||
updateTaskWidget(state, activeContext, widgetVisible);
|
||||
void reportCurrentTask(state.list());
|
||||
} catch (error) {
|
||||
console.error("[pi-todo-herdr] failed to refresh task UI after automatic clear:", error);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const refresh = (ctx: ExtensionContext, restartAutoClear = false) => {
|
||||
activeContext = ctx;
|
||||
updateTaskWidget(state, ctx, widgetVisible);
|
||||
void reportCurrentTask(state.list());
|
||||
autoClear.sync(restartAutoClear);
|
||||
};
|
||||
|
||||
const restore = (ctx: ExtensionContext) => {
|
||||
state.loadFromSession(ctx);
|
||||
refresh(ctx, true);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => restore(ctx));
|
||||
pi.on("session_tree", async (_event, ctx) => restore(ctx));
|
||||
pi.on("session_shutdown", async (event) => {
|
||||
autoClear.cancel();
|
||||
if (activeContext !== undefined) clearTaskWidget(activeContext);
|
||||
activeContext = undefined;
|
||||
if (event.reason === "quit") await clearCurrentTask();
|
||||
});
|
||||
pi.on("session_compact", () => {
|
||||
if (!hasUnfinishedTasks(state.list())) return;
|
||||
pi.sendMessage(
|
||||
{
|
||||
customType: COMPACTION_REMINDER_TYPE,
|
||||
content: COMPACTION_REMINDER,
|
||||
display: false,
|
||||
},
|
||||
{ deliverAs: "steer" },
|
||||
);
|
||||
});
|
||||
|
||||
registerTaskTools(pi, state, refresh);
|
||||
|
||||
pi.registerCommand("todo", {
|
||||
description: "Show or hide the todo tree widget",
|
||||
handler: async (_args, ctx) => {
|
||||
if (state.list().length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
ctx.ui.notify("No todos yet.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
widgetVisible = !widgetVisible;
|
||||
if (widgetVisible) {
|
||||
updateTaskWidget(state, ctx, true);
|
||||
ctx.ui.notify("Todo widget shown.", "info");
|
||||
} else {
|
||||
clearTaskWidget(ctx);
|
||||
ctx.ui.notify("Todo widget hidden.", "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@_jb/pi-todo-herdr",
|
||||
"version": "0.2.5",
|
||||
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "JB",
|
||||
"url": "https://git.jb.codes/jb"
|
||||
},
|
||||
"homepage": "https://git.jb.codes/jb/harness-tools/src/branch/main/extensions/todo-herdr",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@git.jb.codes:7722/jb/harness-tools.git",
|
||||
"directory": "extensions/todo-herdr"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://git.jb.codes/jb/harness-tools/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"main": "./index.ts",
|
||||
"files": [
|
||||
"index.ts",
|
||||
"src/**/*",
|
||||
"media/**/*",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"keywords": [
|
||||
"pi-package",
|
||||
"pi-extension",
|
||||
"todo",
|
||||
"task-management",
|
||||
"herdr",
|
||||
"productivity"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"image": "https://git.jb.codes/jb/harness-tools/raw/branch/main/extensions/todo-herdr/media/screenshot.png"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "bun run typecheck && bun run test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-ai": "catalog:pi-peer",
|
||||
"@earendil-works/pi-coding-agent": "catalog:pi-peer",
|
||||
"@earendil-works/pi-tui": "catalog:pi-peer",
|
||||
"typebox": "catalog:pi-peer"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@earendil-works/pi-ai": "catalog:",
|
||||
"@earendil-works/pi-coding-agent": "catalog:",
|
||||
"@earendil-works/pi-tui": "catalog:",
|
||||
"typebox": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.jb.codes/api/packages/jb/npm/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { TaskState } from "./state.ts";
|
||||
|
||||
export const TODO_AUTO_CLEAR_DELAY_MS = 10_000;
|
||||
|
||||
export interface CompletedTaskAutoClear {
|
||||
sync(forceRestart?: boolean): void;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function createCompletedTaskAutoClear(
|
||||
state: TaskState,
|
||||
tryClear: () => boolean,
|
||||
delayMs = TODO_AUTO_CLEAR_DELAY_MS,
|
||||
): CompletedTaskAutoClear {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let scheduledKey: string | undefined;
|
||||
|
||||
const completionKey = () => {
|
||||
const tasks = state.list();
|
||||
if (tasks.length === 0 || tasks.some((task) => task.status !== "done")) return undefined;
|
||||
return tasks.map((task) => `${task.id}:${task.status}`).join("|");
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
scheduledKey = undefined;
|
||||
};
|
||||
|
||||
const sync = (forceRestart = false) => {
|
||||
const nextKey = completionKey();
|
||||
if (nextKey === undefined) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (!forceRestart && timer !== undefined && scheduledKey === nextKey) return;
|
||||
|
||||
cancel();
|
||||
scheduledKey = nextKey;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
if (completionKey() !== scheduledKey) return;
|
||||
if (!tryClear()) sync();
|
||||
}, Math.max(0, delayMs));
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
return { sync, cancel };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import net from "node:net";
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows, snapshotStats } from "./ui.ts";
|
||||
|
||||
const SOURCE = "pi-todo-herdr";
|
||||
const TASK_TOKEN = "task";
|
||||
const COUNT_TOKEN = "task_count";
|
||||
const PROGRESS_TOKEN = "task_progress";
|
||||
let reportSeq = Date.now() * 1000;
|
||||
|
||||
function nextSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
export function isHerdrEnvironment(): boolean {
|
||||
return process.env.HERDR_ENV === "1" && !!process.env.HERDR_SOCKET_PATH && !!process.env.HERDR_PANE_ID;
|
||||
}
|
||||
|
||||
function hasDescendantWithStatus(task: Task, status: Task["status"], byId: Map<number, Task>): boolean {
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
if (child.status === status || hasDescendantWithStatus(child, status, byId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function currentTaskTokens(tasks: Task[]): Record<string, string | null> {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const stats = snapshotStats(tasks);
|
||||
const ordered = orderedTreeRows(tasks).map(({ task }) => task);
|
||||
const inProgress = ordered.filter(
|
||||
(task) => task.status === "in-progress" && !hasDescendantWithStatus(task, "in-progress", byId),
|
||||
);
|
||||
const candidates =
|
||||
inProgress.length > 0
|
||||
? inProgress
|
||||
: ordered.filter((task) => task.status === "blocked" && !hasDescendantWithStatus(task, "blocked", byId));
|
||||
return {
|
||||
[TASK_TOKEN]: candidates.length > 0 ? `#${candidates[0]!.id} ${candidates[0]!.name}` : null,
|
||||
[COUNT_TOKEN]: candidates.length > 1 ? `+${candidates.length - 1}` : null,
|
||||
[PROGRESS_TOKEN]: stats.total > 0 && stats.done < stats.total ? `${stats.done}/${stats.total}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentTaskLabel(tasks: Task[]): string | null {
|
||||
const tokens = currentTaskTokens(tasks);
|
||||
const task = tokens[TASK_TOKEN];
|
||||
const count = tokens[COUNT_TOKEN];
|
||||
return task ? `${task}${count ? ` ${count}` : ""}` : null;
|
||||
}
|
||||
|
||||
function sendAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!isHerdrEnvironment()) return Promise.resolve(true);
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH!;
|
||||
const endpoint = process.platform === "win32" ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const socket = net.createConnection(endpoint);
|
||||
const finish = (delivered: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timer = setTimeout(() => finish(false), timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function send(request: unknown): Promise<void> {
|
||||
if (await sendAttempt(request, 500)) return;
|
||||
await sendAttempt(request, 1500);
|
||||
}
|
||||
|
||||
async function reportTokens(tokens: Record<string, string | null>): Promise<void> {
|
||||
if (!isHerdrEnvironment()) return;
|
||||
await send({
|
||||
id: `${SOURCE}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_metadata",
|
||||
params: {
|
||||
pane_id: process.env.HERDR_PANE_ID,
|
||||
source: SOURCE,
|
||||
tokens,
|
||||
seq: nextSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function reportCurrentTask(tasks: Task[]): Promise<void> {
|
||||
return reportTokens(currentTaskTokens(tasks));
|
||||
}
|
||||
|
||||
export function clearCurrentTask(): Promise<void> {
|
||||
return reportTokens({ [TASK_TOKEN]: null, [COUNT_TOKEN]: null, [PROGRESS_TOKEN]: null });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const MAX_TASKS = 200;
|
||||
export const MAX_TASKS_PER_MUTATION = 50;
|
||||
export const MAX_TASK_DEPTH = 3;
|
||||
export const MAX_TASK_NAME_LENGTH = 200;
|
||||
export const MAX_TASK_DESCRIPTION_LENGTH = 4_000;
|
||||
export const MAX_WIDGET_TASK_ROWS = 12;
|
||||
|
||||
export const TASK_NAME_PATTERN =
|
||||
"^[^\\u0000-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
export const TASK_DESCRIPTION_PATTERN =
|
||||
"^[^\\u0000-\\u0009\\u000B-\\u001F\\u007F-\\u009F\\u061C\\u200E\\u200F\\u202A-\\u202E\\u2066-\\u206F]*$";
|
||||
|
||||
const UNSAFE_TASK_TEXT = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F\u061C\u200E\u200F\u202A-\u202E\u2066-\u206F]/u;
|
||||
|
||||
export function hasUnsafeTaskText(value: string): boolean {
|
||||
return UNSAFE_TASK_TEXT.test(value);
|
||||
}
|
||||
|
||||
export function taskTextLength(value: string): number {
|
||||
return [...value].length;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Task } from "./types.ts";
|
||||
import { orderedTreeRows } from "./ui.ts";
|
||||
|
||||
export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction";
|
||||
export const COMPACTION_REMINDER =
|
||||
"Active tasks exist. Call list_task now; treat its result as authoritative.";
|
||||
|
||||
export const TASK_PROMPT_GUIDELINES = [
|
||||
"For work with 3+ distinct steps, explore enough to understand the scope, then call set_tasks before implementation; otherwise skip tracking. Start one task in-progress.",
|
||||
"Use patch_tasks when tasks start, block, or finish; keep statuses accurate before the final response.",
|
||||
];
|
||||
|
||||
export function formatCompactTaskTree(tasks: Task[]): string {
|
||||
if (tasks.length === 0) return "No tasks.";
|
||||
return orderedTreeRows(tasks)
|
||||
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function hasUnfinishedTasks(tasks: Task[]): boolean {
|
||||
return tasks.some((task) => task.status !== "done");
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import {
|
||||
hasUnsafeTaskText,
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
taskTextLength,
|
||||
} from "./limits.ts";
|
||||
import type {
|
||||
AddTaskDraft,
|
||||
Task,
|
||||
TaskPatch,
|
||||
TaskSnapshot,
|
||||
TaskStats,
|
||||
TaskStatus,
|
||||
TaskTimingSnapshot,
|
||||
TaskToolDetails,
|
||||
} from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
|
||||
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "patch_tasks", "rm_task"]);
|
||||
export const TASK_STATE_ENTRY_TYPE = "pi-todo-herdr-state";
|
||||
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||
const MAX_DATE_MS = 8_640_000_000_000_000;
|
||||
|
||||
interface TaskRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
startedAt?: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||
return records.map((task) => ({ ...task }));
|
||||
}
|
||||
|
||||
function indexRecords(records: TaskRecord[]): Map<number, TaskRecord> {
|
||||
return new Map(records.map((task) => [task.id, task]));
|
||||
}
|
||||
|
||||
function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||
}
|
||||
|
||||
function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||
return records.map((task) => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
...(task.description !== undefined ? { description: task.description } : {}),
|
||||
status: task.status,
|
||||
parentId: task.parentId,
|
||||
childrenIds: childIds(records, task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
function durationMs(task: TaskRecord, now: number): number {
|
||||
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||
return Math.min(MAX_DATE_MS, task.accumulatedMs + activeMs);
|
||||
}
|
||||
|
||||
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||
const byId = indexRecords(records);
|
||||
return snapshotTasks(records).map((task) => {
|
||||
const record = byId.get(task.id);
|
||||
if (!record || record.startedAt === undefined) return task;
|
||||
return {
|
||||
...task,
|
||||
startedAt: new Date(record.startedAt).toISOString(),
|
||||
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
|
||||
durationMs: durationMs(record, now),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
|
||||
const timings: TaskTimingSnapshot[] = [];
|
||||
for (const task of records) {
|
||||
if (task.startedAt === undefined) continue;
|
||||
timings.push({
|
||||
taskId: task.id,
|
||||
startedAt: task.startedAt,
|
||||
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
|
||||
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
|
||||
accumulatedMs: task.accumulatedMs,
|
||||
});
|
||||
}
|
||||
return timings;
|
||||
}
|
||||
|
||||
function startTimer(task: TaskRecord, now: number): void {
|
||||
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
|
||||
task.startedAt ??= startedAt;
|
||||
task.activeStartedAt = startedAt;
|
||||
task.stoppedAt = undefined;
|
||||
}
|
||||
|
||||
function stopTimer(task: TaskRecord, now: number): void {
|
||||
if (task.activeStartedAt === undefined) return;
|
||||
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||
task.accumulatedMs = Math.min(MAX_DATE_MS, task.accumulatedMs + stoppedAt - task.activeStartedAt);
|
||||
task.activeStartedAt = undefined;
|
||||
task.stoppedAt = stoppedAt;
|
||||
}
|
||||
|
||||
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isValidTimestamp(value: unknown): value is number {
|
||||
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
|
||||
}
|
||||
|
||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||
const result: number[] = [];
|
||||
const visit = (id: number) => {
|
||||
for (const childId of childIds(records, id)) {
|
||||
result.push(childId);
|
||||
visit(childId);
|
||||
}
|
||||
};
|
||||
visit(rootId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateGraph(records: TaskRecord[]): string[] {
|
||||
const errors: string[] = [];
|
||||
const byId = indexRecords(records);
|
||||
const ids = new Set<number>();
|
||||
|
||||
for (const task of records) {
|
||||
if (!Number.isSafeInteger(task.id) || task.id < 1) {
|
||||
errors.push(`task ${task.id}: id must be a positive safe integer`);
|
||||
}
|
||||
if (ids.has(task.id)) errors.push(`task ${task.id}: duplicate id`);
|
||||
ids.add(task.id);
|
||||
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
|
||||
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
|
||||
if (hasUnsafeTaskText(task.name)) errors.push(`task ${task.id}: name contains an unsafe control character`);
|
||||
if (taskTextLength(task.name) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`task ${task.id}: name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (task.description !== undefined) {
|
||||
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
|
||||
if (taskTextLength(task.description) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`task ${task.id}: description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
if (hasUnsafeTaskText(task.description)) {
|
||||
errors.push(`task ${task.id}: description contains an unsafe control character`);
|
||||
}
|
||||
}
|
||||
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||
if (!isNonNegativeSafeInteger(task.accumulatedMs) || task.accumulatedMs > MAX_DATE_MS) {
|
||||
errors.push(`task ${task.id}: accumulatedMs must be an integer between 0 and ${MAX_DATE_MS}`);
|
||||
}
|
||||
if (task.startedAt === undefined) {
|
||||
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||
errors.push(`task ${task.id}: timer fields require startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
|
||||
} else {
|
||||
if (!isValidTimestamp(task.startedAt)) {
|
||||
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
|
||||
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
|
||||
}
|
||||
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
|
||||
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
|
||||
}
|
||||
if (task.status === "in-progress") {
|
||||
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
|
||||
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
|
||||
} else {
|
||||
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
|
||||
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
|
||||
}
|
||||
}
|
||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<number>();
|
||||
const visited = new Set<number>();
|
||||
const visit = (id: number): boolean => {
|
||||
if (visiting.has(id)) return true;
|
||||
if (visited.has(id)) return false;
|
||||
visiting.add(id);
|
||||
const parentId = byId.get(id)?.parentId;
|
||||
if (parentId !== null && parentId !== undefined && visit(parentId)) return true;
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const task of records) {
|
||||
if (visit(task.id)) {
|
||||
errors.push(`task ${task.id}: parentId creates a cycle`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
const depths = new Map<number, number>();
|
||||
const depthOf = (task: TaskRecord): number => {
|
||||
const cached = depths.get(task.id);
|
||||
if (cached !== undefined) return cached;
|
||||
const parent = task.parentId === null ? undefined : byId.get(task.parentId);
|
||||
const depth = parent === undefined ? 1 : depthOf(parent) + 1;
|
||||
depths.set(task.id, depth);
|
||||
return depth;
|
||||
};
|
||||
for (const task of records) {
|
||||
if (depthOf(task) > MAX_TASK_DEPTH) {
|
||||
errors.push(`task ${task.id}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) return errors;
|
||||
|
||||
for (const task of records) {
|
||||
if (task.status !== "done") continue;
|
||||
const unfinished = collectDescendantIds(records, task.id).filter((id) => byId.get(id)?.status !== "done");
|
||||
if (unfinished.length > 0) {
|
||||
errors.push(`task ${task.id}: done task has unfinished descendants ${unfinished.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function insertAtSiblingPosition(records: TaskRecord[], id: number, position: number): string | undefined {
|
||||
const byId = indexRecords(records);
|
||||
const task = byId.get(id);
|
||||
if (!task) return `task ${id}: does not exist`;
|
||||
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
if (!Number.isInteger(position) || position < 0 || position > siblings.length) {
|
||||
return `task ${id}: position must be between 0 and ${siblings.length}`;
|
||||
}
|
||||
|
||||
const recordIndex = records.findIndex((record) => record.id === id);
|
||||
const [record] = records.splice(recordIndex, 1);
|
||||
if (!record) return `task ${id}: does not exist`;
|
||||
|
||||
if (position < siblings.length) {
|
||||
const targetIndex = records.findIndex((candidate) => candidate.id === siblings[position]);
|
||||
records.splice(targetIndex, 0, record);
|
||||
return;
|
||||
}
|
||||
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
function moveToSiblingEnd(records: TaskRecord[], id: number): void {
|
||||
const task = records.find((candidate) => candidate.id === id);
|
||||
if (!task) return;
|
||||
const siblings = childIds(records, task.parentId).filter((siblingId) => siblingId !== id);
|
||||
const currentIndex = records.findIndex((candidate) => candidate.id === id);
|
||||
const [record] = records.splice(currentIndex, 1);
|
||||
if (!record) return;
|
||||
if (siblings.length === 0) {
|
||||
records.push(record);
|
||||
return;
|
||||
}
|
||||
const lastSiblingIndex = records.findIndex((candidate) => candidate.id === siblings[siblings.length - 1]);
|
||||
records.splice(lastSiblingIndex + 1, 0, record);
|
||||
}
|
||||
|
||||
export class TaskState {
|
||||
private records: TaskRecord[] = [];
|
||||
private nextId = 1;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(now: () => number = Date.now) {
|
||||
this.now = now;
|
||||
}
|
||||
|
||||
private currentTime(): number {
|
||||
const now = Math.floor(this.now());
|
||||
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
|
||||
return now;
|
||||
}
|
||||
|
||||
list(): Task[] {
|
||||
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
|
||||
return publicTasks(this.records, now);
|
||||
}
|
||||
|
||||
get(ids: number[]): Task[] {
|
||||
const byId = new Map(this.list().map((task) => [task.id, task]));
|
||||
return ids.map((id) => byId.get(id)).filter((task): task is Task => task !== undefined);
|
||||
}
|
||||
|
||||
snapshot(): TaskSnapshot {
|
||||
const timings = timingSnapshots(this.records);
|
||||
return {
|
||||
tasks: snapshotTasks(this.records),
|
||||
nextId: this.nextId,
|
||||
...(timings.length > 0 ? { timings } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
stats(): TaskStats {
|
||||
const tasks = this.records;
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
add(drafts: AddTaskDraft[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(drafts) || drafts.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (drafts.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`]);
|
||||
}
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const existingIds = new Set(working.map((task) => task.id));
|
||||
const now = this.currentTime();
|
||||
let nextId = this.nextId;
|
||||
const addedIds: number[] = [];
|
||||
let mutationTaskCount = 0;
|
||||
|
||||
const append = (
|
||||
draft: AddTaskDraft,
|
||||
implicitParentId: number | null,
|
||||
path: string,
|
||||
nested: boolean,
|
||||
depth: number,
|
||||
) => {
|
||||
mutationTaskCount += 1;
|
||||
if (mutationTaskCount > MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
return;
|
||||
}
|
||||
if (depth > MAX_TASK_DEPTH) {
|
||||
errors.push(`${path}: depth must not exceed ${MAX_TASK_DEPTH} levels`);
|
||||
return;
|
||||
}
|
||||
if (working.length >= MAX_TASKS) {
|
||||
errors.push(`task session is limited to ${MAX_TASKS} tasks`);
|
||||
return;
|
||||
}
|
||||
if (!draft || typeof draft !== "object") {
|
||||
errors.push(`${path}: task must be an object`);
|
||||
return;
|
||||
}
|
||||
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
|
||||
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (typeof draft.name === "string" && hasUnsafeTaskText(draft.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof draft.name === "string" && taskTextLength(draft.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (draft.description !== undefined && draft.description !== null) {
|
||||
if (typeof draft.description !== "string" || !draft.description.trim()) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
} else {
|
||||
if (hasUnsafeTaskText(draft.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(draft.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
|
||||
errors.push(`${path}.status is invalid`);
|
||||
}
|
||||
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
errors.push(`${path}.parentId is not allowed inside children`);
|
||||
}
|
||||
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
|
||||
|
||||
let parentId = implicitParentId;
|
||||
if (!nested && draft.parentId !== undefined && draft.parentId !== null) {
|
||||
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
|
||||
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
|
||||
} else {
|
||||
parentId = draft.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextId >= Number.MAX_SAFE_INTEGER) {
|
||||
errors.push("no safe task IDs remain");
|
||||
return;
|
||||
}
|
||||
const id = nextId++;
|
||||
const status = draft.status ?? "pending";
|
||||
addedIds.push(id);
|
||||
working.push({
|
||||
id,
|
||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
|
||||
});
|
||||
|
||||
const children = Array.isArray(draft.children) ? draft.children : [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(child, id, `${path}.children[${index}]`, true, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [index, draft] of drafts.entries()) {
|
||||
if (mutationTaskCount >= MAX_TASKS_PER_MUTATION) {
|
||||
errors.push(`tasks must contain at most ${MAX_TASKS_PER_MUTATION} tasks per mutation`);
|
||||
break;
|
||||
}
|
||||
append(draft, null, `tasks[${index}]`, false, 1);
|
||||
}
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
this.nextId = nextId;
|
||||
return this.get(addedIds);
|
||||
}
|
||||
|
||||
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
|
||||
if (mode === "append") {
|
||||
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
|
||||
return this.add(drafts);
|
||||
}
|
||||
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
|
||||
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
|
||||
|
||||
const errors: string[] = [];
|
||||
if (this.records.some((task) => task.status !== "done") && force !== true) {
|
||||
errors.push("replace requires force when unfinished tasks exist");
|
||||
}
|
||||
drafts.forEach((draft, index) => {
|
||||
if (draft?.parentId !== undefined && draft.parentId !== null) {
|
||||
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
|
||||
const replacement = new TaskState(this.now);
|
||||
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
||||
this.restore(replacement.snapshot());
|
||||
return affected;
|
||||
}
|
||||
|
||||
update(patches: TaskPatch[]): Task[] {
|
||||
const errors: string[] = [];
|
||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||
if (patches.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`tasks must contain at most ${MAX_TASKS_PER_MUTATION} patches`]);
|
||||
}
|
||||
|
||||
const duplicateIds = patches
|
||||
.map((patch) => patch.id)
|
||||
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||
|
||||
const working = cloneRecords(this.records);
|
||||
const now = this.currentTime();
|
||||
for (const [index, patch] of patches.entries()) {
|
||||
const path = `tasks[${index}]`;
|
||||
const task = working.find((candidate) => candidate.id === patch.id);
|
||||
if (!task) {
|
||||
errors.push(`${path}.id ${patch.id} does not exist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasNameChange = patch.name !== undefined && patch.name !== null;
|
||||
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
|
||||
const hasStatusChange = patch.status !== undefined && patch.status !== null;
|
||||
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
|
||||
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
|
||||
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
|
||||
errors.push(`${path} must include at least one field to update`);
|
||||
}
|
||||
if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) {
|
||||
errors.push(`${path}.name must not be empty`);
|
||||
}
|
||||
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
|
||||
if (typeof patch.name === "string" && hasUnsafeTaskText(patch.name)) {
|
||||
errors.push(`${path}.name contains an unsafe control character`);
|
||||
}
|
||||
if (typeof patch.name === "string" && taskTextLength(patch.name.trim()) > MAX_TASK_NAME_LENGTH) {
|
||||
errors.push(`${path}.name must be at most ${MAX_TASK_NAME_LENGTH} characters`);
|
||||
}
|
||||
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
|
||||
errors.push(`${path}.description must not be empty`);
|
||||
}
|
||||
if (typeof patch.description === "string") {
|
||||
if (hasUnsafeTaskText(patch.description)) {
|
||||
errors.push(`${path}.description contains an unsafe control character`);
|
||||
}
|
||||
if (taskTextLength(patch.description.trim()) > MAX_TASK_DESCRIPTION_LENGTH) {
|
||||
errors.push(`${path}.description must be at most ${MAX_TASK_DESCRIPTION_LENGTH} characters`);
|
||||
}
|
||||
}
|
||||
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
|
||||
if (
|
||||
hasParentChange &&
|
||||
patch.parentId !== null &&
|
||||
patch.parentId !== 0 &&
|
||||
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
|
||||
) {
|
||||
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
|
||||
}
|
||||
|
||||
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
|
||||
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
|
||||
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
|
||||
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
|
||||
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
|
||||
task.status = patch.status;
|
||||
}
|
||||
if (
|
||||
patch.parentId !== undefined &&
|
||||
patch.parentId !== -1 &&
|
||||
(patch.parentId === null ||
|
||||
patch.parentId === 0 ||
|
||||
working.some((item) => item.id === patch.parentId))
|
||||
) {
|
||||
task.parentId = patch.parentId === 0 ? null : patch.parentId;
|
||||
moveToSiblingEnd(working, task.id);
|
||||
}
|
||||
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
|
||||
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
|
||||
if (positionError) errors.push(`${path}.position: ${positionError}`);
|
||||
}
|
||||
}
|
||||
|
||||
errors.push(...validateGraph(working));
|
||||
if (errors.length > 0) throw new TaskValidationError([...new Set(errors)]);
|
||||
|
||||
this.records = working;
|
||||
return this.get(patches.map((patch) => patch.id));
|
||||
}
|
||||
|
||||
remove(ids: number[]): Task[] {
|
||||
if (!Array.isArray(ids) || ids.length === 0) throw new TaskValidationError(["ids must not be empty"]);
|
||||
if (ids.length > MAX_TASKS_PER_MUTATION) {
|
||||
throw new TaskValidationError([`ids must contain at most ${MAX_TASKS_PER_MUTATION} entries`]);
|
||||
}
|
||||
const uniqueIds = [...new Set(ids)];
|
||||
const missing = uniqueIds.filter((id) => !this.records.some((task) => task.id === id));
|
||||
if (missing.length > 0) throw new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]);
|
||||
|
||||
const removedIds = new Set<number>();
|
||||
for (const id of uniqueIds) {
|
||||
removedIds.add(id);
|
||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||
}
|
||||
const now = this.currentTime();
|
||||
for (const task of this.records) {
|
||||
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
|
||||
}
|
||||
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
|
||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||
return removed;
|
||||
}
|
||||
|
||||
restore(snapshot: TaskSnapshot): void {
|
||||
if (!snapshot || !Array.isArray(snapshot.tasks)) {
|
||||
throw new TaskValidationError(["invalid task snapshot"]);
|
||||
}
|
||||
if (!Number.isSafeInteger(snapshot.nextId) || snapshot.nextId < 1) {
|
||||
throw new TaskValidationError(["nextId must be a positive safe integer"]);
|
||||
}
|
||||
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||
}
|
||||
if (snapshot.tasks.length > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task session is limited to ${MAX_TASKS} tasks`]);
|
||||
}
|
||||
if ((snapshot.timings?.length ?? 0) > MAX_TASKS) {
|
||||
throw new TaskValidationError([`task snapshot timings are limited to ${MAX_TASKS} entries`]);
|
||||
}
|
||||
|
||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
parentId,
|
||||
accumulatedMs: 0,
|
||||
}));
|
||||
const byId = indexRecords(records);
|
||||
const timingErrors: string[] = [];
|
||||
const timedIds = new Set<number>();
|
||||
|
||||
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
|
||||
const path = `timings[${index}]`;
|
||||
if (!timing || typeof timing !== "object") {
|
||||
timingErrors.push(`${path} must be an object`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isSafeInteger(timing.taskId) || timing.taskId < 1) {
|
||||
timingErrors.push(`${path}.taskId must be a positive safe integer`);
|
||||
continue;
|
||||
}
|
||||
if (timedIds.has(timing.taskId)) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
|
||||
continue;
|
||||
}
|
||||
timedIds.add(timing.taskId);
|
||||
const task = byId.get(timing.taskId);
|
||||
if (!task) {
|
||||
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
|
||||
continue;
|
||||
}
|
||||
task.startedAt = timing.startedAt;
|
||||
task.activeStartedAt = timing.activeStartedAt;
|
||||
task.stoppedAt = timing.stoppedAt;
|
||||
task.accumulatedMs = timing.accumulatedMs;
|
||||
}
|
||||
|
||||
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
|
||||
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
|
||||
const now = this.currentTime();
|
||||
for (const task of records) {
|
||||
if (task.status === "in-progress") startTimer(task, now);
|
||||
}
|
||||
}
|
||||
|
||||
const errors = validateGraph(records);
|
||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||
this.records = cloneRecords(records);
|
||||
this.nextId = snapshot.nextId;
|
||||
}
|
||||
|
||||
loadFromSession(ctx: ExtensionContext): void {
|
||||
this.records = [];
|
||||
this.nextId = 1;
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
let snapshot: TaskSnapshot | undefined;
|
||||
if (entry.type === "custom" && entry.customType === TASK_STATE_ENTRY_TYPE) {
|
||||
snapshot = entry.data as TaskSnapshot | undefined;
|
||||
} else if (entry.type === "message") {
|
||||
const message = entry.message;
|
||||
if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) {
|
||||
snapshot = (message.details as TaskToolDetails | undefined)?.snapshot;
|
||||
}
|
||||
}
|
||||
if (!snapshot) continue;
|
||||
try {
|
||||
this.restore(snapshot);
|
||||
} catch {
|
||||
// Ignore corrupt historical snapshots and keep the latest valid state.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type, type Static } from "typebox";
|
||||
import {
|
||||
MAX_TASK_DEPTH,
|
||||
MAX_TASK_DESCRIPTION_LENGTH,
|
||||
MAX_TASK_NAME_LENGTH,
|
||||
MAX_TASKS_PER_MUTATION,
|
||||
TASK_DESCRIPTION_PATTERN,
|
||||
TASK_NAME_PATTERN,
|
||||
} from "./limits.ts";
|
||||
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||
import { TaskValidationError } from "./types.ts";
|
||||
import { renderToolResult } from "./ui.ts";
|
||||
|
||||
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
|
||||
description: "Task status.",
|
||||
});
|
||||
|
||||
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
|
||||
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic emits bare local refs ("AddTask") instead of URI
|
||||
* references ("#/$defs/AddTask"). Strict JSON Schema gateways (e.g. the
|
||||
* moonshot-flavored validator behind Console Go) reject those with a 400.
|
||||
* Rewrite every local $ref in place to the #/$defs/ form.
|
||||
*/
|
||||
function normalizeLocalRefs<T>(schema: T): T {
|
||||
if (Array.isArray(schema)) {
|
||||
for (const item of schema) normalizeLocalRefs(item);
|
||||
return schema;
|
||||
}
|
||||
if (schema && typeof schema === "object") {
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes("://")) {
|
||||
(schema as Record<string, unknown>)[key] = `#/$defs/${value}`;
|
||||
} else {
|
||||
normalizeLocalRefs(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeBox's Type.Cyclic nests $defs inside the referenced item schema, but
|
||||
* refs like "#/$defs/AddTask" resolve from the document root. Strict gateways
|
||||
* (moonshot-flavored) reject a root-style ref when the definition is nested.
|
||||
* Hoist every $defs entry to the schema root and drop the synthetic $id, which
|
||||
* would otherwise change fragment resolution inside the definition.
|
||||
*/
|
||||
function hoistDefsToRoot<T>(schema: T): T {
|
||||
const defs = new Map<string, unknown>();
|
||||
function walk(node: unknown): void {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") return;
|
||||
const obj = node as Record<string, unknown>;
|
||||
const nested = obj["$defs"];
|
||||
if (nested && typeof nested === "object") {
|
||||
for (const [name, def] of Object.entries(nested)) {
|
||||
if (!defs.has(name)) defs.set(name, def);
|
||||
}
|
||||
delete obj["$defs"];
|
||||
}
|
||||
for (const value of Object.values(obj)) walk(value);
|
||||
}
|
||||
walk(schema);
|
||||
for (const def of defs.values()) {
|
||||
if (def && typeof def === "object") {
|
||||
delete (def as Record<string, unknown>)["$id"];
|
||||
}
|
||||
}
|
||||
if (defs.size > 0) (schema as Record<string, unknown>)["$defs"] = Object.fromEntries(defs);
|
||||
return schema;
|
||||
}
|
||||
|
||||
const AddTaskSchema = Type.Cyclic(
|
||||
{
|
||||
AddTask: Type.Object(
|
||||
{
|
||||
name: Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_NAME_LENGTH,
|
||||
pattern: TASK_NAME_PATTERN,
|
||||
}),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
Type.Null(),
|
||||
],
|
||||
{
|
||||
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
|
||||
},
|
||||
),
|
||||
status: TaskStatusSchema,
|
||||
parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
|
||||
description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
|
||||
}),
|
||||
children: Type.Array(Type.Ref("AddTask"), { maxItems: MAX_TASKS_PER_MUTATION }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
},
|
||||
"AddTask",
|
||||
);
|
||||
|
||||
export const SetTaskParams = hoistDefsToRoot(
|
||||
normalizeLocalRefs(
|
||||
Type.Object(
|
||||
{
|
||||
mode: SetTaskModeSchema,
|
||||
tasks: Type.Array(AddTaskSchema, {
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Tasks to append or install. May be empty only in replace mode.",
|
||||
}),
|
||||
force: Type.Boolean({
|
||||
description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const TaskPatchSchema = Type.Object(
|
||||
{
|
||||
id: Type.Integer({ minimum: 1 }),
|
||||
name: Type.Union(
|
||||
[
|
||||
Type.String({ minLength: 1, maxLength: MAX_TASK_NAME_LENGTH, pattern: TASK_NAME_PATTERN }),
|
||||
Type.Null(),
|
||||
],
|
||||
{ description: "New name, or null to leave it unchanged." },
|
||||
),
|
||||
description: Type.Union(
|
||||
[
|
||||
Type.String({
|
||||
minLength: 1,
|
||||
maxLength: MAX_TASK_DESCRIPTION_LENGTH,
|
||||
pattern: TASK_DESCRIPTION_PATTERN,
|
||||
}),
|
||||
Type.Null(),
|
||||
],
|
||||
{ description: "New description, or null to leave it unchanged." },
|
||||
),
|
||||
status: Type.Union([TaskStatusSchema, Type.Null()], {
|
||||
description: "New status, or null to leave it unchanged.",
|
||||
}),
|
||||
parentId: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.",
|
||||
}),
|
||||
position: Type.Integer({
|
||||
minimum: -1,
|
||||
description: "Zero-based position among final siblings, or -1 to leave it unchanged.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const PatchTasksParams = Type.Object(
|
||||
{
|
||||
tasks: Type.Array(TaskPatchSchema, {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task patches applied atomically in array order.",
|
||||
}),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
|
||||
minItems: 1,
|
||||
maxItems: MAX_TASKS_PER_MUTATION,
|
||||
description: "Task IDs to process. Duplicate IDs are deduplicated.",
|
||||
});
|
||||
|
||||
export const RemoveTaskParams = Type.Object(
|
||||
{
|
||||
ids: TaskIdsSchema,
|
||||
reason: RemovalReasonSchema,
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
|
||||
|
||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||
|
||||
interface DraftPreparationState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function prepareTaskDraft(value: unknown, depth: number, state: DraftPreparationState): unknown {
|
||||
state.count += 1;
|
||||
if (depth > MAX_TASK_DEPTH || state.count > MAX_TASKS_PER_MUTATION) return {};
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const children =
|
||||
input.children === undefined
|
||||
? []
|
||||
: Array.isArray(input.children)
|
||||
? input.children
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((child) => prepareTaskDraft(child, depth + 1, state))
|
||||
: input.children;
|
||||
return {
|
||||
...input,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? "pending" : input.status,
|
||||
parentId: input.parentId === undefined ? null : input.parentId,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof SetTaskParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
const state: DraftPreparationState = { count: 0 };
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks
|
||||
.slice(0, MAX_TASKS_PER_MUTATION + 1)
|
||||
.map((task) => prepareTaskDraft(task, 1, state))
|
||||
: input.tasks,
|
||||
force: input.force === undefined ? false : input.force,
|
||||
} as Static<typeof SetTaskParams>;
|
||||
}
|
||||
|
||||
function prepareTaskPatch(value: unknown): unknown {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
name: input.name === undefined ? null : input.name,
|
||||
description: input.description === undefined ? null : input.description,
|
||||
status: input.status === undefined ? null : input.status,
|
||||
parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId,
|
||||
position: input.position === undefined || input.position === null ? -1 : input.position,
|
||||
};
|
||||
}
|
||||
|
||||
function preparePatchTasksArguments(value: unknown): Static<typeof PatchTasksParams> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value as Static<typeof PatchTasksParams>;
|
||||
}
|
||||
const input = value as Record<string, unknown>;
|
||||
return {
|
||||
...input,
|
||||
tasks: Array.isArray(input.tasks)
|
||||
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
||||
: input.tasks,
|
||||
} as Static<typeof PatchTasksParams>;
|
||||
}
|
||||
|
||||
interface SetTaskInput {
|
||||
mode: "append" | "replace";
|
||||
tasks: AddTaskDraft[];
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
interface PatchTasksInput {
|
||||
tasks: TaskPatch[];
|
||||
}
|
||||
|
||||
interface IdsInput {
|
||||
ids: number[];
|
||||
}
|
||||
|
||||
interface RemoveTaskInput extends IdsInput {
|
||||
reason: "mistaken" | "duplicate" | "canceled";
|
||||
}
|
||||
|
||||
type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void;
|
||||
|
||||
function taskIds(tasks: Task[]): string {
|
||||
return tasks.map((task) => task.id).join(", ");
|
||||
}
|
||||
|
||||
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
|
||||
return { action, snapshot: state.snapshot(), affected };
|
||||
}
|
||||
|
||||
function mutationText(verb: string, affected: Task[], state: TaskState): string {
|
||||
const stats = state.stats();
|
||||
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
|
||||
}
|
||||
|
||||
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
|
||||
if (mode === "append") return mutationText("Added", affected, state);
|
||||
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
|
||||
const stats = state.stats();
|
||||
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
|
||||
}
|
||||
|
||||
function throwToolError(label: string, error: unknown): never {
|
||||
if (error instanceof TaskValidationError) {
|
||||
const count = error.errors.length;
|
||||
throw new Error(
|
||||
`${label} failed · ${count} validation error${count === 1 ? "" : "s"}\n${error.errors.map((item) => `- ${item}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
function renderCall(title: string, count: number | undefined, theme: Theme): Text {
|
||||
const suffix = count === undefined ? "" : theme.fg("dim", ` (${count})`);
|
||||
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
|
||||
}
|
||||
|
||||
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void {
|
||||
try {
|
||||
onChange(ctx, restartAutoClear);
|
||||
} catch (error) {
|
||||
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
|
||||
pi.registerTool({
|
||||
name: "set_tasks",
|
||||
label: "Set Tasks",
|
||||
description:
|
||||
`Atomically append tasks or replace the task tree. Supply at most ${MAX_TASKS_PER_MUTATION} total tasks across ${MAX_TASK_DEPTH} stored levels: root categories, child items, and leaf sub-items beneath the widget's virtual root. Pass force false unless unfinished work is explicitly discarded.`,
|
||||
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||
parameters: SetTaskParams,
|
||||
prepareArguments: prepareSetTaskArguments,
|
||||
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.set(params.mode, params.tasks, params.force);
|
||||
notifyChange(onChange, ctx, params.mode === "replace");
|
||||
return {
|
||||
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
||||
details: details(params.mode, state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Set", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: SetTaskInput, theme: Theme) {
|
||||
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "patch_tasks",
|
||||
label: "Patch Tasks",
|
||||
description:
|
||||
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged. Completed task trees may auto-clear; do not recreate cleared completed tasks.",
|
||||
parameters: PatchTasksParams,
|
||||
prepareArguments: preparePatchTasksArguments,
|
||||
async execute(_toolCallId, params: PatchTasksInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
if (state.list().length === 0) {
|
||||
throw new TaskValidationError([
|
||||
"task tree is empty and may have auto-cleared after completion; do not recreate completed tasks. Use set_tasks only for genuinely new or remaining work",
|
||||
]);
|
||||
}
|
||||
const affected = state.update(params.tasks);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: mutationText("Updated", affected, state) }],
|
||||
details: details("update", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Patch", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: PatchTasksInput, theme: Theme) {
|
||||
return renderCall("Patch Tasks", args.tasks?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "rm_task",
|
||||
label: "Remove Tasks",
|
||||
description:
|
||||
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
|
||||
parameters: RemoveTaskParams,
|
||||
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
|
||||
try {
|
||||
const affected = state.remove(params.ids);
|
||||
notifyChange(onChange, ctx);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
|
||||
},
|
||||
],
|
||||
details: details("remove", state, affected),
|
||||
};
|
||||
} catch (error) {
|
||||
throwToolError("Remove", error);
|
||||
}
|
||||
},
|
||||
renderCall(args: RemoveTaskInput, theme: Theme) {
|
||||
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "list_task",
|
||||
label: "List Tasks",
|
||||
description: "List the full task tree compactly with id, name, and status.",
|
||||
parameters: ListTaskParams,
|
||||
async execute() {
|
||||
const affected = state.list();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
|
||||
details: details("list", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(_args, theme: Theme) {
|
||||
return renderCall("List Tasks", undefined, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "get_task",
|
||||
label: "Get Tasks",
|
||||
description: "Get complete details for one or more task IDs, including parent and child IDs.",
|
||||
parameters: IdsParams,
|
||||
async execute(_toolCallId, params: IdsInput) {
|
||||
const uniqueIds = [...new Set(params.ids)];
|
||||
const affected = state.get(uniqueIds);
|
||||
const found = new Set(affected.map((task) => task.id));
|
||||
const missing = uniqueIds.filter((id) => !found.has(id));
|
||||
if (missing.length > 0) {
|
||||
throwToolError("Get", new TaskValidationError([`task ids do not exist: ${missing.join(", ")}`]));
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(affected) }],
|
||||
details: details("get", state, affected),
|
||||
};
|
||||
},
|
||||
renderCall(args: IdsInput, theme: Theme) {
|
||||
return renderCall("Get Tasks", args.ids?.length, theme);
|
||||
},
|
||||
renderResult(result, { expanded }, theme) {
|
||||
return renderToolResult(result, expanded, theme);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
parentId: number | null;
|
||||
childrenIds: number[];
|
||||
startedAt?: string;
|
||||
stoppedAt?: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface TaskTimingSnapshot {
|
||||
taskId: number;
|
||||
startedAt: number;
|
||||
activeStartedAt?: number;
|
||||
stoppedAt?: number;
|
||||
accumulatedMs: number;
|
||||
}
|
||||
|
||||
export interface TaskSnapshot {
|
||||
tasks: Task[];
|
||||
nextId: number;
|
||||
timings?: TaskTimingSnapshot[];
|
||||
}
|
||||
|
||||
export interface AddTaskDraft {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
children?: AddTaskDraft[];
|
||||
}
|
||||
|
||||
export interface TaskPatch {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
status?: TaskStatus | null;
|
||||
parentId?: number | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface TaskStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
blocked: number;
|
||||
done: number;
|
||||
}
|
||||
|
||||
export interface TaskToolDetails {
|
||||
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
|
||||
snapshot: TaskSnapshot;
|
||||
affected: Task[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export class TaskValidationError extends Error {
|
||||
readonly errors: string[];
|
||||
|
||||
constructor(errors: string[]) {
|
||||
super(errors.join("\n"));
|
||||
this.name = "TaskValidationError";
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
import type { Component } from "@earendil-works/pi-tui";
|
||||
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
||||
import type { Theme } from "@earendil-works/pi-coding-agent";
|
||||
import { MAX_WIDGET_TASK_ROWS } from "./limits.ts";
|
||||
import type { Task, TaskStats, TaskToolDetails } from "./types.ts";
|
||||
|
||||
export const STATUS_ICONS = {
|
||||
pending: "○",
|
||||
"in-progress": "◉",
|
||||
blocked: "!",
|
||||
done: "✓",
|
||||
} as const;
|
||||
|
||||
const IN_PROGRESS_WIDGET_FRAMES = ["◐", "◓", "◑", "◒"] as const;
|
||||
|
||||
interface DescendantStats {
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
task: Task;
|
||||
prefix: string;
|
||||
depth: number;
|
||||
siblingIndex: number;
|
||||
collapsed?: DescendantStats;
|
||||
}
|
||||
|
||||
function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number, DescendantStats>): DescendantStats {
|
||||
const cached = memo.get(task.id);
|
||||
if (cached) return cached;
|
||||
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const childId of task.childrenIds) {
|
||||
const child = byId.get(childId);
|
||||
if (!child) continue;
|
||||
|
||||
total += 1;
|
||||
if (child.status === "done") done += 1;
|
||||
const nested = descendantStats(child, byId, memo);
|
||||
done += nested.done;
|
||||
total += nested.total;
|
||||
}
|
||||
|
||||
const stats = { done, total };
|
||||
memo.set(task.id, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
const memo = new Map<number, DescendantStats>();
|
||||
const rows: TreeRow[] = [];
|
||||
|
||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
|
||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||
const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ ";
|
||||
const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
|
||||
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
|
||||
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) });
|
||||
if (collapsed) return;
|
||||
|
||||
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
|
||||
children.forEach((child, index) => {
|
||||
const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast];
|
||||
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
|
||||
});
|
||||
};
|
||||
|
||||
const roots = tasks.filter((task) => task.parentId === null);
|
||||
roots.forEach((root, index) => visit(root, [], index === roots.length - 1, 0, index));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function orderedTreeRows(tasks: Task[]): TreeRow[] {
|
||||
return buildTreeRows(tasks, false);
|
||||
}
|
||||
|
||||
function styleTaskName(task: Task, theme: Theme): string {
|
||||
if (task.status === "done") return theme.fg("dim", theme.strikethrough(task.name));
|
||||
if (task.status === "in-progress") return theme.fg("warning", task.name);
|
||||
return task.name;
|
||||
}
|
||||
|
||||
function styleStatusIcon(task: Task, theme: Theme, inProgressFrame?: number): string {
|
||||
const frameIndex = inProgressFrame === undefined ? undefined : Math.abs(Math.trunc(inProgressFrame));
|
||||
const icon =
|
||||
task.status === "in-progress" && frameIndex !== undefined
|
||||
? IN_PROGRESS_WIDGET_FRAMES[frameIndex % IN_PROGRESS_WIDGET_FRAMES.length]
|
||||
: STATUS_ICONS[task.status];
|
||||
if (task.status === "done") return theme.fg("success", icon);
|
||||
if (task.status === "in-progress") return theme.fg("warning", icon);
|
||||
if (task.status === "blocked") return theme.fg("error", icon);
|
||||
return theme.fg("dim", icon);
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number): string {
|
||||
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
|
||||
const totalSeconds = Math.floor(safeMs / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||
}
|
||||
|
||||
function renderTaskLineWithSuffix(
|
||||
task: Task,
|
||||
prefix: string,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
suffix?: string,
|
||||
identifier = `${task.id}.`,
|
||||
identifierBeforeStatus = false,
|
||||
inProgressFrame?: number,
|
||||
): string {
|
||||
const status = styleStatusIcon(task, theme, inProgressFrame);
|
||||
const styledIdentifier = identifier.length === 0 ? "" : theme.fg("accent", identifier);
|
||||
const identifierFits =
|
||||
!identifierBeforeStatus ||
|
||||
visibleWidth(prefix) + visibleWidth(styledIdentifier) + 1 + visibleWidth(status) <= width;
|
||||
const visibleIdentifier = identifierFits ? styledIdentifier : "";
|
||||
const markers = identifierBeforeStatus ? [visibleIdentifier, status] : [status, visibleIdentifier];
|
||||
const leading = theme.fg("dim", prefix) + markers.filter((marker) => marker.length > 0).join(" ");
|
||||
const styledName = styleTaskName(task, theme);
|
||||
const renderMain = (targetWidth: number): string => {
|
||||
const safeWidth = Math.max(1, targetWidth);
|
||||
if (!identifierBeforeStatus) return truncateToWidth(`${leading} ${styledName}`, safeWidth);
|
||||
|
||||
const availableNameWidth = safeWidth - visibleWidth(leading) - 1;
|
||||
if (availableNameWidth < 1) return truncateToWidth(leading, safeWidth);
|
||||
return `${leading} ${truncateToWidth(styledName, availableNameWidth)}`;
|
||||
};
|
||||
const trailing = [
|
||||
suffix === undefined ? undefined : theme.fg("dim", ` · ${suffix}`),
|
||||
task.durationMs === undefined ? undefined : theme.fg("dim", ` · ${formatDuration(task.durationMs)}`),
|
||||
]
|
||||
.filter((part): part is string => part !== undefined)
|
||||
.join("");
|
||||
if (trailing.length === 0) return renderMain(width);
|
||||
|
||||
const trailingWidth = visibleWidth(trailing);
|
||||
const minimumTaskWidth = Math.max(suffix === undefined ? 6 : 12, visibleWidth(leading));
|
||||
if (width <= trailingWidth + minimumTaskWidth) return renderMain(width);
|
||||
const taskWidth = Math.max(1, width - trailingWidth);
|
||||
return renderMain(taskWidth) + trailing;
|
||||
}
|
||||
|
||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||
return renderTaskLineWithSuffix(task, prefix, theme, width);
|
||||
}
|
||||
|
||||
const STATUS_ICON_WIDTH = 1;
|
||||
const TRUNCATED_STATUS_WIDTH = STATUS_ICON_WIDTH + 3;
|
||||
|
||||
function indentDeepestLeaf(
|
||||
prefix: string,
|
||||
depth: number,
|
||||
maximumDepth: number,
|
||||
width: number,
|
||||
minimumContentWidth: number,
|
||||
): string {
|
||||
if (depth === 0 || depth !== maximumDepth) return prefix;
|
||||
|
||||
const indentedPrefix = `${prefix} `;
|
||||
return visibleWidth(indentedPrefix) + minimumContentWidth <= width ? indentedPrefix : prefix;
|
||||
}
|
||||
|
||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||
const maximumDepth = maximumTaskDepth(tasks);
|
||||
return orderedTreeRows(tasks).map(({ task, prefix, depth }) =>
|
||||
renderTaskLine(
|
||||
task,
|
||||
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, TRUNCATED_STATUS_WIDTH),
|
||||
theme,
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toRomanNumeral(value: number): string {
|
||||
const numerals: Array<[number, string]> = [
|
||||
[100, "C"],
|
||||
[90, "XC"],
|
||||
[50, "L"],
|
||||
[40, "XL"],
|
||||
[10, "X"],
|
||||
[9, "IX"],
|
||||
[5, "V"],
|
||||
[4, "IV"],
|
||||
[1, "I"],
|
||||
];
|
||||
let remaining = value;
|
||||
let result = "";
|
||||
for (const [amount, numeral] of numerals) {
|
||||
while (remaining >= amount) {
|
||||
result += numeral;
|
||||
remaining -= amount;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function maximumTaskDepth(tasks: Task[]): number {
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
let maximumDepth = 0;
|
||||
|
||||
for (const task of tasks) {
|
||||
let depth = 0;
|
||||
let parentId = task.parentId;
|
||||
while (parentId !== null) {
|
||||
depth += 1;
|
||||
parentId = byId.get(parentId)?.parentId ?? null;
|
||||
}
|
||||
maximumDepth = Math.max(maximumDepth, depth);
|
||||
}
|
||||
|
||||
return maximumDepth;
|
||||
}
|
||||
|
||||
function widgetOutlineLabel(depth: number, siblingIndex: number, maximumDepth: number): string {
|
||||
if (depth === maximumDepth) return "";
|
||||
if (maximumDepth === 2 && depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`;
|
||||
return `${siblingIndex + 1}.`;
|
||||
}
|
||||
|
||||
export function renderWidgetTree(
|
||||
tasks: Task[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
padding = " ",
|
||||
inProgressFrame?: number,
|
||||
maxBodyRows?: number,
|
||||
): string[] {
|
||||
const rows = buildTreeRows(tasks, true, true);
|
||||
const maximumDepth = maximumTaskDepth(tasks);
|
||||
const outputLimit =
|
||||
maxBodyRows === undefined ? MAX_WIDGET_TASK_ROWS : Math.max(1, Math.floor(Number.isFinite(maxBodyRows) ? maxBodyRows : 1));
|
||||
const hasOverflow = rows.length > outputLimit;
|
||||
// The legacy, standalone widget allows twelve task rows plus one summary.
|
||||
// A combined panel treats maxBodyRows as a strict total body-line budget.
|
||||
const taskRowLimit = maxBodyRows === undefined ? outputLimit : hasOverflow ? Math.max(0, outputLimit - 1) : outputLimit;
|
||||
const visibleRows = rows.slice(0, taskRowLimit);
|
||||
const lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
|
||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||
const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth);
|
||||
return renderTaskLineWithSuffix(
|
||||
task,
|
||||
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH),
|
||||
theme,
|
||||
width,
|
||||
summary,
|
||||
identifier,
|
||||
true,
|
||||
inProgressFrame,
|
||||
);
|
||||
});
|
||||
if (!hasOverflow) return lines;
|
||||
|
||||
const hiddenTasks = rows.slice(taskRowLimit).reduce(
|
||||
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
|
||||
0,
|
||||
);
|
||||
const label = `${padding}… ${hiddenTasks} more task${hiddenTasks === 1 ? "" : "s"}`;
|
||||
return [...lines, truncateToWidth(theme.fg("dim", label), Math.max(1, width))];
|
||||
}
|
||||
|
||||
export function progressLabel(stats: TaskStats): string {
|
||||
const blocked = stats.blocked > 0 ? ` · !${stats.blocked}` : "";
|
||||
return ` Todos ${stats.done}/${stats.total}${blocked}`;
|
||||
}
|
||||
|
||||
export function renderProgressHeader(stats: TaskStats, theme: Theme, width: number): string {
|
||||
const label = progressLabel(stats);
|
||||
const available = width - visibleWidth(label) - 2;
|
||||
if (available < 5) return truncateToWidth(theme.fg("accent", label), Math.max(1, width));
|
||||
|
||||
const barWidth = Math.min(20, available);
|
||||
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * barWidth);
|
||||
const filledColor = stats.done === stats.total ? "success" : "accent";
|
||||
const bar =
|
||||
theme.fg(filledColor, theme.bold("━".repeat(filled))) + theme.fg("dim", "─".repeat(barWidth - filled));
|
||||
return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width));
|
||||
}
|
||||
|
||||
function compactProgressBar(stats: TaskStats, theme: Theme): string {
|
||||
const width = 8;
|
||||
const filled = stats.total === 0 ? 0 : Math.round((stats.done / stats.total) * width);
|
||||
const filledColor = stats.done === stats.total ? "success" : "accent";
|
||||
return (
|
||||
theme.fg(filledColor, theme.bold("━".repeat(filled))) +
|
||||
theme.fg("borderMuted", "─".repeat(width - filled))
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the compact, unboxed Todo header and task rows. */
|
||||
export function renderTodoPanel(
|
||||
tasks: Task[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame?: number,
|
||||
maxBodyRows?: number,
|
||||
): string[] {
|
||||
if (!Number.isFinite(width) || width <= 0) return [];
|
||||
const safeWidth = Math.max(1, Math.floor(width));
|
||||
const stats = snapshotStats(tasks);
|
||||
const title = theme.fg("accent", "Todos");
|
||||
const count = theme.fg("muted", `${stats.done}/${stats.total}${stats.blocked > 0 ? ` · !${stats.blocked}` : ""}`);
|
||||
const fullHeader = `${title} ${count} ${compactProgressBar(stats, theme)}`;
|
||||
const compactHeader = `${title} ${count}`;
|
||||
const header = visibleWidth(fullHeader) <= safeWidth ? fullHeader : truncateToWidth(compactHeader, safeWidth);
|
||||
return [header, ...renderWidgetTree(tasks, theme, safeWidth, " ", inProgressFrame, maxBodyRows)];
|
||||
}
|
||||
|
||||
class TaskResultComponent implements Component {
|
||||
private readonly build: (width: number) => string[];
|
||||
|
||||
constructor(build: (width: number) => string[]) {
|
||||
this.build = build;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
return this.build(Math.max(1, width)).map((line) => truncateToWidth(line, Math.max(1, width)));
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
}
|
||||
|
||||
function firstText(content: Array<{ type: string; text?: string }>): string {
|
||||
return content.find((item) => item.type === "text")?.text ?? "";
|
||||
}
|
||||
|
||||
function summaryFor(details: TaskToolDetails): string {
|
||||
const stats = snapshotStats(details.snapshot.tasks);
|
||||
const progress = `${stats.done}/${stats.total} done`;
|
||||
switch (details.action) {
|
||||
case "add":
|
||||
case "append":
|
||||
return `${details.affected.length} added · ${progress}`;
|
||||
case "replace":
|
||||
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
|
||||
case "update":
|
||||
return `${details.affected.length} updated · ${progress}`;
|
||||
case "remove":
|
||||
return `${details.affected.length} removed · ${progress}`;
|
||||
case "list":
|
||||
return `${stats.total} task${stats.total === 1 ? "" : "s"} · ${progress}`;
|
||||
case "get":
|
||||
return `${details.affected.length} task${details.affected.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotStats(tasks: Task[]): TaskStats {
|
||||
return {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter((task) => task.status === "pending").length,
|
||||
inProgress: tasks.filter((task) => task.status === "in-progress").length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
function renderFlatTasks(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
return tasks.map((task) => renderTaskLine(task, " ", theme, width));
|
||||
}
|
||||
|
||||
function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
if (index > 0) lines.push("");
|
||||
lines.push(renderTaskLine(task, " ", theme, width));
|
||||
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
|
||||
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
|
||||
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
|
||||
if (task.description) {
|
||||
const prefix = " ";
|
||||
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
|
||||
for (const sourceLine of task.description.split("\n")) {
|
||||
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
|
||||
if (wrapped.length === 0) lines.push(prefix);
|
||||
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isValidTaskToolDetails(details: unknown): details is TaskToolDetails {
|
||||
if (!details || typeof details !== "object") return false;
|
||||
const d = details as Partial<TaskToolDetails>;
|
||||
return (
|
||||
typeof d.action === "string" &&
|
||||
Array.isArray(d.snapshot?.tasks) &&
|
||||
Array.isArray(d.affected)
|
||||
);
|
||||
}
|
||||
|
||||
export function renderToolResult(
|
||||
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
||||
expanded: boolean,
|
||||
theme: Theme,
|
||||
): Component {
|
||||
const raw = firstText(result.content);
|
||||
const details = result.details as TaskToolDetails | undefined;
|
||||
|
||||
if (result.isError || !isValidTaskToolDetails(details)) {
|
||||
const rawLines = raw.split("\n").filter(Boolean);
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = expanded ? rawLines : rawLines.slice(0, 1);
|
||||
return lines.map((line, index) => theme.fg("error", `${index === 0 ? "✗ " : " "}${line}`));
|
||||
});
|
||||
}
|
||||
|
||||
return new TaskResultComponent((width) => {
|
||||
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
||||
if (!expanded || details.affected.length === 0) return lines;
|
||||
if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)];
|
||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
||||
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||
import type { TaskState } from "./state.ts";
|
||||
import { renderTodoPanel } from "./ui.ts";
|
||||
|
||||
const WIDGET_ID = "pi-todo-herdr";
|
||||
const COMBINED_WIDGET_BREAKPOINT = 120;
|
||||
const COMBINED_BODY_ROWS = 10;
|
||||
export const TODO_ANIMATION_INTERVAL_MS = 500;
|
||||
|
||||
interface StatusWidgetTheme {
|
||||
fg(color: string, text: string): string;
|
||||
}
|
||||
|
||||
interface StatusWidget {
|
||||
readonly source: object;
|
||||
readonly hasContent: () => boolean;
|
||||
readonly getBodyRowCount: () => number;
|
||||
readonly render: (width: number, maxBodyRows?: number, theme?: StatusWidgetTheme) => string[];
|
||||
readonly claim: (onChange: () => void) => () => void;
|
||||
readonly isClaimed: (onChange: () => void) => boolean;
|
||||
}
|
||||
|
||||
interface InteractiveSubagentsGlobal {
|
||||
statusWidget?: unknown;
|
||||
}
|
||||
|
||||
class OptionalIntegrationError extends Error {
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, { cause });
|
||||
this.name = "OptionalIntegrationError";
|
||||
}
|
||||
}
|
||||
|
||||
class TuiRenderFailure extends Error {
|
||||
readonly original: unknown;
|
||||
|
||||
constructor(original: unknown) {
|
||||
super("TUI render failed", { cause: original });
|
||||
this.name = "TuiRenderFailure";
|
||||
this.original = original;
|
||||
}
|
||||
}
|
||||
|
||||
function optionalIntegrationFailure(message: string, cause?: unknown): OptionalIntegrationError {
|
||||
return new OptionalIntegrationError(message, cause);
|
||||
}
|
||||
|
||||
function callOptionalIntegration<T>(message: string, operation: () => T): T {
|
||||
try {
|
||||
return operation();
|
||||
} catch (error) {
|
||||
if (error instanceof TuiRenderFailure) throw error.original;
|
||||
if (error instanceof OptionalIntegrationError) throw error;
|
||||
throw optionalIntegrationFailure(message, error);
|
||||
}
|
||||
}
|
||||
|
||||
interface ActiveClaim {
|
||||
owner: object;
|
||||
source: object;
|
||||
onChange: () => void;
|
||||
release: () => void;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// Symbols make the claim and generation survive extension reloads. A newly
|
||||
// loaded module can invalidate a stale component before installing its own.
|
||||
const ACTIVE_CLAIM_KEY = Symbol.for("pi-todo-herdr/active-subagents-claim");
|
||||
const WIDGET_GENERATION_KEY = Symbol.for("pi-todo-herdr/widget-generation");
|
||||
const ACTIVE_RUNTIME_KEY = Symbol.for("pi-todo-herdr/active-runtime");
|
||||
const RUNTIME_TOKEN = Symbol("pi-todo-herdr-runtime");
|
||||
const previousClaim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
||||
Reflect.set(globalThis, ACTIVE_RUNTIME_KEY, RUNTIME_TOKEN);
|
||||
if (previousClaim !== null && typeof previousClaim === "object") {
|
||||
const release = (previousClaim as { release?: unknown }).release;
|
||||
Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
||||
if (typeof release === "function") {
|
||||
try {
|
||||
release.call(previousClaim);
|
||||
} catch {
|
||||
// A stale claim must not prevent the new extension runtime loading.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentRuntime(): boolean {
|
||||
return Reflect.get(globalThis, ACTIVE_RUNTIME_KEY) === RUNTIME_TOKEN;
|
||||
}
|
||||
|
||||
function getActiveClaim(): ActiveClaim | undefined {
|
||||
const claim = Reflect.get(globalThis, ACTIVE_CLAIM_KEY);
|
||||
return claim !== null && typeof claim === "object" ? (claim as ActiveClaim) : undefined;
|
||||
}
|
||||
|
||||
function setActiveClaim(claim: ActiveClaim | undefined): void {
|
||||
if (claim === undefined) Reflect.deleteProperty(globalThis, ACTIVE_CLAIM_KEY);
|
||||
else Reflect.set(globalThis, ACTIVE_CLAIM_KEY, claim);
|
||||
}
|
||||
|
||||
function claimBelongsTo(claim: ActiveClaim | undefined, owner: object, generation: number): claim is ActiveClaim {
|
||||
return claim?.owner === owner && claim.generation === generation;
|
||||
}
|
||||
|
||||
function nextWidgetGeneration(): number {
|
||||
const current = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
||||
const generation = typeof current === "number" && Number.isSafeInteger(current) ? current + 1 : 1;
|
||||
Reflect.set(globalThis, WIDGET_GENERATION_KEY, generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
function currentWidgetGeneration(): number {
|
||||
const generation = Reflect.get(globalThis, WIDGET_GENERATION_KEY);
|
||||
return typeof generation === "number" && Number.isSafeInteger(generation) ? generation : 0;
|
||||
}
|
||||
|
||||
function releaseActiveClaim(owner?: object, generation?: number): void {
|
||||
const activeClaim = getActiveClaim();
|
||||
if (
|
||||
(owner !== undefined && activeClaim?.owner !== owner) ||
|
||||
(generation !== undefined && activeClaim?.generation !== generation)
|
||||
) return;
|
||||
setActiveClaim(undefined);
|
||||
if (activeClaim === undefined) return;
|
||||
try {
|
||||
activeClaim.release();
|
||||
} catch {
|
||||
// A broken optional integration must never prevent the Todo widget clearing.
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusWidget(): StatusWidget | undefined {
|
||||
try {
|
||||
const globals = globalThis as typeof globalThis & {
|
||||
__pi_interactive_subagents?: unknown;
|
||||
};
|
||||
const integration = globals.__pi_interactive_subagents;
|
||||
if (integration === null || typeof integration !== "object") return undefined;
|
||||
const statusWidget = (integration as InteractiveSubagentsGlobal).statusWidget;
|
||||
if (statusWidget === null || typeof statusWidget !== "object") return undefined;
|
||||
|
||||
const candidate = statusWidget as Record<string, unknown>;
|
||||
if (
|
||||
typeof candidate.hasContent !== "function" ||
|
||||
typeof candidate.getBodyRowCount !== "function" ||
|
||||
typeof candidate.render !== "function" ||
|
||||
typeof candidate.claim !== "function" ||
|
||||
typeof candidate.isClaimed !== "function"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = statusWidget;
|
||||
const call = <T>(methodName: string, args: unknown[] = []): T =>
|
||||
callOptionalIntegration(`Subagents ${methodName} failed`, () => {
|
||||
const method = candidate[methodName];
|
||||
if (typeof method !== "function") {
|
||||
throw optionalIntegrationFailure(`invalid Subagents ${methodName} method`);
|
||||
}
|
||||
return method.call(source, ...args) as T;
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
hasContent: () => {
|
||||
const content = call<unknown>("hasContent");
|
||||
if (typeof content !== "boolean") throw optionalIntegrationFailure("invalid Subagents content state");
|
||||
return content;
|
||||
},
|
||||
getBodyRowCount: () => {
|
||||
const count = call<unknown>("getBodyRowCount");
|
||||
if (typeof count !== "number" || !Number.isFinite(count)) {
|
||||
throw optionalIntegrationFailure("invalid Subagents body row count");
|
||||
}
|
||||
return count;
|
||||
},
|
||||
render: (width, maxBodyRows, theme) => {
|
||||
const lines = call<unknown>("render", [width, maxBodyRows, theme]);
|
||||
const hasAllEntries =
|
||||
Array.isArray(lines) &&
|
||||
lines.length > 0 &&
|
||||
Array.from({ length: lines.length }, (_, index) => {
|
||||
return Object.prototype.hasOwnProperty.call(lines, index) && typeof lines[index] === "string";
|
||||
}).every(Boolean);
|
||||
if (!hasAllEntries) throw optionalIntegrationFailure("invalid Subagents render output");
|
||||
const validLines = lines as string[];
|
||||
if (maxBodyRows !== undefined) {
|
||||
const isBordered = validLines[0]?.includes("╭") && validLines.at(-1)?.includes("╰");
|
||||
// Body rows exclude the provider's header. Legacy bordered output has
|
||||
// top and bottom chrome; the current unboxed output has one header.
|
||||
const headerAndChromeRows = isBordered ? 2 : 1;
|
||||
const maxLines = maxBodyRows + headerAndChromeRows;
|
||||
if (validLines.length > maxLines) {
|
||||
throw optionalIntegrationFailure("Subagents render output exceeded its body limit");
|
||||
}
|
||||
}
|
||||
return validLines;
|
||||
},
|
||||
claim: (onChange) => {
|
||||
const release = call<unknown>("claim", [onChange]);
|
||||
if (typeof release !== "function") throw optionalIntegrationFailure("invalid Subagents claim release");
|
||||
return release as () => void;
|
||||
},
|
||||
isClaimed: (onChange) => {
|
||||
const claimed = call<unknown>("isClaimed", [onChange]);
|
||||
if (typeof claimed !== "boolean") throw optionalIntegrationFailure("invalid Subagents claim state");
|
||||
return claimed;
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function claimStatusWidget(
|
||||
statusWidget: StatusWidget,
|
||||
owner: object,
|
||||
generation: number,
|
||||
tui: { requestRender(): void },
|
||||
): boolean {
|
||||
const activeClaim = getActiveClaim();
|
||||
if (activeClaim?.owner !== undefined && activeClaim.owner !== owner) return false;
|
||||
if (claimBelongsTo(activeClaim, owner, generation) && activeClaim.source === statusWidget.source) {
|
||||
try {
|
||||
if (statusWidget.isClaimed(activeClaim.onChange)) return true;
|
||||
} catch (error) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
if (error instanceof OptionalIntegrationError) return false;
|
||||
throw error;
|
||||
}
|
||||
// The provider may have been reset by a reload or another lifecycle event.
|
||||
// Drop the stale local handle before asking it for a fresh claim.
|
||||
}
|
||||
releaseActiveClaim(owner, generation);
|
||||
|
||||
try {
|
||||
const onChange = () => {
|
||||
if (!isCurrentRuntime()) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return;
|
||||
}
|
||||
const currentClaim = getActiveClaim();
|
||||
if (!claimBelongsTo(currentClaim, owner, generation) || currentClaim.onChange !== onChange) return;
|
||||
try {
|
||||
tui.requestRender();
|
||||
} catch (error) {
|
||||
// Let the provider restore its standalone widget when the TUI is gone.
|
||||
releaseActiveClaim(owner, generation);
|
||||
throw new TuiRenderFailure(error);
|
||||
}
|
||||
};
|
||||
const release = statusWidget.claim(onChange);
|
||||
setActiveClaim({ owner, source: statusWidget.source, onChange, release, generation });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof OptionalIntegrationError) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWidth(width: number): number {
|
||||
return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
|
||||
}
|
||||
|
||||
function fitLine(line: string, width: number): string {
|
||||
const fittedWidth = normalizeWidth(width);
|
||||
const fitted = truncateToWidth(line, fittedWidth);
|
||||
return `${fitted}${" ".repeat(Math.max(0, fittedWidth - visibleWidth(fitted)))}`;
|
||||
}
|
||||
|
||||
function renderStatusPanel(
|
||||
statusWidget: StatusWidget,
|
||||
width: number,
|
||||
maxBodyRows: number,
|
||||
theme: StatusWidgetTheme,
|
||||
): string[] {
|
||||
return statusWidget.render(width, maxBodyRows, theme).map((line) => fitLine(line, width));
|
||||
}
|
||||
|
||||
function normalizePanelHeight(lines: string[], height: number, blankLine: string): string[] {
|
||||
if (lines.length >= height) return lines;
|
||||
const blanks = Array.from({ length: height - lines.length }, () => blankLine);
|
||||
const bottom = lines.at(-1);
|
||||
if (bottom?.includes("╰")) return [...lines.slice(0, -1), ...blanks, bottom];
|
||||
return [...lines, ...blanks];
|
||||
}
|
||||
|
||||
function renderSideBySide(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
statusWidget: StatusWidget,
|
||||
): string[] {
|
||||
const leftWidth = Math.floor((width - 1) / 2);
|
||||
const rightWidth = width - 1 - leftWidth;
|
||||
const todo = renderTodoPanel(tasks, theme, leftWidth, inProgressFrame, COMBINED_BODY_ROWS);
|
||||
const subagents = renderStatusPanel(statusWidget, rightWidth, COMBINED_BODY_ROWS, theme);
|
||||
const height = Math.max(todo.length, subagents.length);
|
||||
const left = normalizePanelHeight(todo, height, " ".repeat(leftWidth)).map((line) => fitLine(line, leftWidth));
|
||||
const right = normalizePanelHeight(subagents, height, " ".repeat(rightWidth));
|
||||
return left.map((line, index) => `${line} ${right[index]}`);
|
||||
}
|
||||
|
||||
function bodyRowsForTodo(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
): number {
|
||||
return Math.max(1, renderTodoPanel(tasks, theme, width, inProgressFrame, COMBINED_BODY_ROWS).length - 1);
|
||||
}
|
||||
|
||||
function bodyRowsForSubagents(statusWidget: StatusWidget): number {
|
||||
return Math.max(1, Math.min(COMBINED_BODY_ROWS, Math.floor(statusWidget.getBodyRowCount())));
|
||||
}
|
||||
|
||||
function distributeBodyRows(todoNeed: number, subagentsNeed: number): { todo: number; subagents: number } {
|
||||
let todo = Math.min(todoNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
||||
let subagents = Math.min(subagentsNeed, Math.floor(COMBINED_BODY_ROWS / 2));
|
||||
let remaining = COMBINED_BODY_ROWS - todo - subagents;
|
||||
|
||||
const todoUnused = Math.max(0, todoNeed - todo);
|
||||
const todoExtra = Math.min(remaining, todoUnused);
|
||||
todo += todoExtra;
|
||||
remaining -= todoExtra;
|
||||
|
||||
const subagentsUnused = Math.max(0, subagentsNeed - subagents);
|
||||
subagents += Math.min(remaining, subagentsUnused);
|
||||
return { todo: Math.max(1, todo), subagents: Math.max(1, subagents) };
|
||||
}
|
||||
|
||||
function renderStacked(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
statusWidget: StatusWidget,
|
||||
subagentsNeed: number,
|
||||
): string[] {
|
||||
const todoNeed = bodyRowsForTodo(tasks, theme, width, inProgressFrame);
|
||||
const rows = distributeBodyRows(todoNeed, subagentsNeed);
|
||||
return [
|
||||
...renderTodoPanel(tasks, theme, width, inProgressFrame, rows.todo),
|
||||
"",
|
||||
...renderStatusPanel(statusWidget, width, rows.subagents, theme),
|
||||
];
|
||||
}
|
||||
|
||||
function renderCombinedWidget(
|
||||
tasks: ReturnType<TaskState["list"]>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
inProgressFrame: number,
|
||||
tui: { requestRender(): void },
|
||||
owner: object,
|
||||
generation: number,
|
||||
composeSubagents: boolean,
|
||||
): string[] {
|
||||
if (!Number.isFinite(width) || width <= 0 || tasks.length === 0) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return [];
|
||||
}
|
||||
|
||||
const fittedWidth = normalizeWidth(width);
|
||||
const renderTodoFallback = (): string[] => {
|
||||
releaseActiveClaim(owner, generation);
|
||||
return renderTodoPanel(tasks, theme, fittedWidth, inProgressFrame);
|
||||
};
|
||||
if (!composeSubagents) return renderTodoFallback();
|
||||
|
||||
const statusWidget = getStatusWidget();
|
||||
if (statusWidget === undefined) return renderTodoFallback();
|
||||
|
||||
try {
|
||||
if (!statusWidget.hasContent()) return renderTodoFallback();
|
||||
if (!claimStatusWidget(statusWidget, owner, generation, tui)) return renderTodoFallback();
|
||||
|
||||
const subagentsNeed = bodyRowsForSubagents(statusWidget);
|
||||
return fittedWidth > COMBINED_WIDGET_BREAKPOINT
|
||||
? renderSideBySide(tasks, theme, fittedWidth, inProgressFrame, statusWidget)
|
||||
: renderStacked(tasks, theme, fittedWidth, inProgressFrame, statusWidget, subagentsNeed);
|
||||
} catch (error) {
|
||||
if (!(error instanceof OptionalIntegrationError)) {
|
||||
releaseActiveClaim(owner, generation);
|
||||
throw error;
|
||||
}
|
||||
return renderTodoFallback();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||
if (!isCurrentRuntime()) return;
|
||||
nextWidgetGeneration();
|
||||
releaseActiveClaim();
|
||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||
}
|
||||
|
||||
export function updateTaskWidget(
|
||||
state: TaskState,
|
||||
ctx: ExtensionContext,
|
||||
visible: boolean,
|
||||
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
||||
): void {
|
||||
if (!isCurrentRuntime()) return;
|
||||
const tasks = state.list();
|
||||
if (!visible || tasks.length === 0) {
|
||||
clearTaskWidget(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = nextWidgetGeneration();
|
||||
// Subagents owns a native widget outside this extension. Only compose it
|
||||
// while Pi is rendering the terminal UI, never in RPC/print modes.
|
||||
const composeSubagents = ctx.mode === "tui";
|
||||
|
||||
// Replacing a widget can happen before Pi calls its old dispose hook. Release
|
||||
// the optional Subagents claim eagerly so it cannot hide a standalone widget.
|
||||
releaseActiveClaim();
|
||||
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||
let inProgressFrame = 0;
|
||||
let disposed = false;
|
||||
const owner = {};
|
||||
const isActive = (): boolean =>
|
||||
!disposed && isCurrentRuntime() && currentWidgetGeneration() === generation;
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
if (hasActiveTimer) {
|
||||
timer = setInterval(() => {
|
||||
if (!isActive()) {
|
||||
clearInterval(timer);
|
||||
return;
|
||||
}
|
||||
inProgressFrame += 1;
|
||||
try {
|
||||
tui.requestRender();
|
||||
} catch {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
releaseActiveClaim(owner, generation);
|
||||
}
|
||||
}, Math.max(1, refreshMs));
|
||||
}
|
||||
timer?.unref();
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
if (!isActive()) return [];
|
||||
const currentTasks = state.list();
|
||||
return renderCombinedWidget(
|
||||
currentTasks,
|
||||
theme,
|
||||
width,
|
||||
inProgressFrame,
|
||||
tui,
|
||||
owner,
|
||||
generation,
|
||||
composeSubagents,
|
||||
);
|
||||
},
|
||||
invalidate(): void {},
|
||||
dispose(): void {
|
||||
disposed = true;
|
||||
releaseActiveClaim(owner, generation);
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createCompletedTaskAutoClear, TODO_AUTO_CLEAR_DELAY_MS } from "../src/auto-clear.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
function clearState(state: TaskState, onClear: () => void): boolean {
|
||||
state.set("replace", [], false);
|
||||
onClear();
|
||||
return true;
|
||||
}
|
||||
|
||||
test("clears the task tree ten seconds after every task remains done", (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
let clearCount = 0;
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {
|
||||
clearCount += 1;
|
||||
}));
|
||||
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS - 1);
|
||||
assert.equal(state.stats().total, 1);
|
||||
|
||||
context.mock.timers.tick(1);
|
||||
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
|
||||
assert.equal(clearCount, 1);
|
||||
});
|
||||
|
||||
test("cancels a pending clear when unfinished work appears", (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
let clearCount = 0;
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {
|
||||
clearCount += 1;
|
||||
}));
|
||||
|
||||
autoClear.sync();
|
||||
state.add([{ name: "New work" }]);
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
|
||||
|
||||
assert.equal(state.stats().total, 2);
|
||||
assert.equal(clearCount, 0);
|
||||
});
|
||||
|
||||
test("preserves the deadline for unrelated refreshes", (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {}));
|
||||
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(9_000);
|
||||
state.update([{ id: 1, name: "Renamed" }]);
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(1_000);
|
||||
|
||||
assert.equal(state.stats().total, 0);
|
||||
});
|
||||
|
||||
test("restarts the delay for changed or restored completed trees", (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {}));
|
||||
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(9_000);
|
||||
state.add([{ name: "Also done", status: "done" }]);
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(1_000);
|
||||
assert.equal(state.stats().total, 2);
|
||||
|
||||
context.mock.timers.tick(8_000);
|
||||
autoClear.sync(true);
|
||||
context.mock.timers.tick(2_000);
|
||||
assert.equal(state.stats().total, 2);
|
||||
|
||||
context.mock.timers.tick(8_000);
|
||||
assert.equal(state.stats().total, 0);
|
||||
});
|
||||
|
||||
test("retries after a clear transaction fails and supports shutdown cancellation", (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
let attempts = 0;
|
||||
const autoClear = createCompletedTaskAutoClear(state, () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return false;
|
||||
return clearState(state, () => {});
|
||||
});
|
||||
|
||||
autoClear.sync();
|
||||
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
|
||||
assert.equal(state.stats().total, 1);
|
||||
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
|
||||
assert.equal(state.stats().total, 0);
|
||||
|
||||
state.add([{ name: "Done again", status: "done" }]);
|
||||
autoClear.sync();
|
||||
autoClear.cancel();
|
||||
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
|
||||
assert.equal(state.stats().total, 1);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import piTodoHerdr from "../index.ts";
|
||||
import {
|
||||
COMPACTION_REMINDER,
|
||||
COMPACTION_REMINDER_TYPE,
|
||||
formatCompactTaskTree,
|
||||
hasUnfinishedTasks,
|
||||
TASK_PROMPT_GUIDELINES,
|
||||
} from "../src/prompt.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
test("registers the conflict-free /todo command", async () => {
|
||||
type RegisteredCommand = {
|
||||
description: string;
|
||||
handler(args: string, ctx: unknown): Promise<void>;
|
||||
};
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const pi = {
|
||||
on() {},
|
||||
registerTool() {},
|
||||
registerCommand(name: string, command: RegisteredCommand) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
};
|
||||
|
||||
piTodoHerdr(pi as never);
|
||||
|
||||
assert.deepEqual([...commands.keys()], ["todo"]);
|
||||
const todo = commands.get("todo");
|
||||
assert.equal(todo?.description, "Show or hide the todo tree widget");
|
||||
|
||||
const notifications: string[] = [];
|
||||
await todo?.handler("", {
|
||||
ui: {
|
||||
setWidget() {},
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(notifications, ["No todos yet."]);
|
||||
});
|
||||
|
||||
test("persists automatic clears before deletion, retries failures, and cancels on shutdown", async (context) => {
|
||||
context.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
context.mock.method(console, "error", () => {});
|
||||
const handlers = new Map<string, (event: any, ctx: any) => unknown>();
|
||||
const tools = new Map<string, any>();
|
||||
const entries: Array<{ customType: string; data: unknown }> = [];
|
||||
let persistAttempts = 0;
|
||||
const pi = {
|
||||
on(name: string, handler: (event: any, ctx: any) => unknown) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {},
|
||||
appendEntry(customType: string, data: unknown) {
|
||||
persistAttempts += 1;
|
||||
if (persistAttempts === 1) throw new Error("disk unavailable");
|
||||
entries.push({ customType, data });
|
||||
},
|
||||
};
|
||||
const ctx = { ui: { setWidget() {} } };
|
||||
piTodoHerdr(pi as never);
|
||||
|
||||
await tools.get("set_tasks").execute(
|
||||
"set",
|
||||
{ mode: "append", tasks: [{ name: "Done", status: "done" }], force: false },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
context.mock.timers.tick(10_000);
|
||||
assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done");
|
||||
|
||||
context.mock.timers.tick(10_000);
|
||||
assert.equal((await tools.get("list_task").execute()).content[0].text, "No tasks.");
|
||||
assert.deepEqual(entries, [{ customType: "pi-todo-herdr-state", data: { tasks: [], nextId: 1 } }]);
|
||||
|
||||
await tools.get("set_tasks").execute(
|
||||
"set",
|
||||
{ mode: "append", tasks: [{ name: "Done again", status: "done" }], force: false },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
await handlers.get("session_shutdown")?.({ reason: "reload" }, ctx);
|
||||
context.mock.timers.tick(10_000);
|
||||
assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done again");
|
||||
});
|
||||
|
||||
test("keeps task guidance short with planning after exploration", () => {
|
||||
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
|
||||
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
|
||||
assert.match(prompt, /work with 3\+ distinct steps/);
|
||||
assert.match(prompt, /explore enough to understand the scope/);
|
||||
assert.match(prompt, /then call set_tasks before implementation/);
|
||||
assert.match(prompt, /otherwise skip tracking/);
|
||||
assert.match(prompt, /Start one task in-progress/);
|
||||
assert.match(prompt, /patch_tasks when tasks start, block, or finish/);
|
||||
assert.match(prompt, /keep statuses accurate before the final response/);
|
||||
assert.ok(prompt.split(/\s+/).length < 40);
|
||||
});
|
||||
|
||||
test("uses a compact one-shot recovery reminder without embedding task data", () => {
|
||||
assert.equal(
|
||||
COMPACTION_REMINDER,
|
||||
"Active tasks exist. Call list_task now; treat its result as authoritative.",
|
||||
);
|
||||
assert.equal(COMPACTION_REMINDER.split(/\s+/).length, 11);
|
||||
assert.doesNotMatch(COMPACTION_REMINDER, /pending|in-progress|blocked|done/);
|
||||
});
|
||||
|
||||
test("reminds after compaction only while unfinished tasks exist", () => {
|
||||
const state = new TaskState();
|
||||
assert.equal(hasUnfinishedTasks(state.list()), false);
|
||||
|
||||
state.add([{ name: "Pending" }]);
|
||||
assert.equal(hasUnfinishedTasks(state.list()), true);
|
||||
|
||||
state.update([{ id: 1, status: "done" }]);
|
||||
assert.equal(hasUnfinishedTasks(state.list()), false);
|
||||
});
|
||||
|
||||
test("formats the compact authoritative tree without descriptions", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Detailed context that should not appear.",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Child" }],
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
formatCompactTaskTree(state.list()),
|
||||
"1 [in-progress] Parent\n└─ 2 [pending] Child",
|
||||
);
|
||||
});
|
||||
|
||||
test("session_compact sends one hidden reminder only for unfinished tasks", async () => {
|
||||
const handlers = new Map<string, (event: any, ctx: any) => unknown>();
|
||||
const tools = new Map<string, any>();
|
||||
const sent: Array<{ message: any; options: any }> = [];
|
||||
const pi = {
|
||||
on(name: string, handler: (event: any, ctx: any) => unknown) {
|
||||
handlers.set(name, handler);
|
||||
},
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
registerCommand() {},
|
||||
sendMessage(message: any, options: any) {
|
||||
sent.push({ message, options });
|
||||
},
|
||||
};
|
||||
piTodoHerdr(pi as never);
|
||||
const ctx = { ui: { setWidget() {} } };
|
||||
|
||||
await tools.get("set_tasks").execute(
|
||||
"set",
|
||||
{ mode: "append", tasks: [{ name: "Continue work", status: "in-progress" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
handlers.get("session_compact")?.({}, ctx);
|
||||
assert.deepEqual(sent, [
|
||||
{
|
||||
message: {
|
||||
customType: COMPACTION_REMINDER_TYPE,
|
||||
content: COMPACTION_REMINDER,
|
||||
display: false,
|
||||
},
|
||||
options: { deliverAs: "steer" },
|
||||
},
|
||||
]);
|
||||
|
||||
await tools.get("patch_tasks").execute(
|
||||
"patch",
|
||||
{ tasks: [{ id: 1, status: "done" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
handlers.get("session_compact")?.({}, ctx);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts";
|
||||
import { TaskValidationError } from "../src/types.ts";
|
||||
|
||||
function names(state: TaskState): string[] {
|
||||
return state.list().filter((task) => task.parentId === null).map((task) => task.name);
|
||||
}
|
||||
|
||||
test("adds a nested batch with generated preorder IDs and derived relations", () => {
|
||||
const state = new TaskState();
|
||||
const added = state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent description",
|
||||
children: [
|
||||
{ name: "First child", description: "First" },
|
||||
{ name: "Second child", description: "Second", status: "blocked" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(added.map((task) => task.id), [1, 2, 3]);
|
||||
assert.deepEqual(state.get([1])[0]?.childrenIds, [2, 3]);
|
||||
assert.equal(state.get([2])[0]?.parentId, 1);
|
||||
assert.equal(state.get([2])[0]?.status, "pending");
|
||||
assert.equal(state.snapshot().nextId, 4);
|
||||
});
|
||||
|
||||
test("rejects invalid nested parentId atomically while accepting null sentinels", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Existing", description: "Existing" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent",
|
||||
children: [{ name: "Child", description: "Child", parentId: 1 }],
|
||||
},
|
||||
]),
|
||||
TaskValidationError,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
const added = state.add([
|
||||
{
|
||||
name: "Nullable parent",
|
||||
parentId: null,
|
||||
children: [{ name: "Implicit child", parentId: null }],
|
||||
},
|
||||
]);
|
||||
assert.equal(added[0]?.parentId, null);
|
||||
assert.equal(added[1]?.parentId, added[0]?.id);
|
||||
});
|
||||
|
||||
test("enforces done descendants against the final atomic update state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
state.update([
|
||||
{ id: 2, status: "done" },
|
||||
{ id: 1, status: "done" },
|
||||
]);
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["done", "done"]);
|
||||
|
||||
assert.throws(() => state.update([{ id: 2, status: "in-progress" }]), /unfinished descendants/);
|
||||
state.update([
|
||||
{ id: 1, status: "in-progress" },
|
||||
{ id: 2, status: "in-progress" },
|
||||
]);
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
||||
});
|
||||
|
||||
test("preserves and accumulates timing across repeated updates and status changes", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
|
||||
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(started?.stoppedAt, undefined);
|
||||
assert.equal(started?.durationMs, 0);
|
||||
|
||||
now += 5_000;
|
||||
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
|
||||
assert.equal(repeated?.startedAt, started?.startedAt);
|
||||
assert.equal(repeated?.stoppedAt, undefined);
|
||||
assert.equal(repeated?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
const [paused] = state.update([{ id: 1, status: "blocked" }]);
|
||||
assert.equal(paused?.startedAt, started?.startedAt);
|
||||
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(paused?.durationMs, 7_000);
|
||||
|
||||
now += 10_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
|
||||
assert.equal(resumed?.startedAt, started?.startedAt);
|
||||
assert.equal(resumed?.stoppedAt, undefined);
|
||||
assert.equal(resumed?.durationMs, 7_000);
|
||||
|
||||
now += 3_000;
|
||||
const [done] = state.update([{ id: 1, status: "done" }]);
|
||||
assert.equal(done?.startedAt, started?.startedAt);
|
||||
assert.equal(done?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(done?.durationMs, 10_000);
|
||||
assert.deepEqual(state.snapshot().timings, [
|
||||
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not stop a timer when an atomic update fails", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Unfinished child" }],
|
||||
},
|
||||
]);
|
||||
|
||||
now += 5_000;
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
const afterFailure = state.get([1])[0];
|
||||
assert.equal(afterFailure?.stoppedAt, undefined);
|
||||
assert.equal(afterFailure?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
});
|
||||
|
||||
test("restores timers and starts legacy in-progress snapshots at load time", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const source = new TaskState(() => now);
|
||||
source.add([{ name: "Persisted", status: "in-progress" }]);
|
||||
now += 2_500;
|
||||
source.update([{ id: 1, status: "pending" }]);
|
||||
|
||||
now += 20_000;
|
||||
const restored = new TaskState(() => now);
|
||||
restored.restore(source.snapshot());
|
||||
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
|
||||
|
||||
const legacy = new TaskState(() => now);
|
||||
legacy.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy active",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
});
|
||||
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
|
||||
assert.equal(legacy.get([1])[0]?.durationMs, 0);
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects unsafe restored identifiers and oversized timing collections", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
|
||||
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
|
||||
/id must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/nextId must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
|
||||
}),
|
||||
/timings are limited to 200 entries/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
/accumulatedMs must be an integer between/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects additions when no safe generated task ID remains", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Corrupt timer",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
timings: [
|
||||
{
|
||||
taskId: 1,
|
||||
startedAt: Number.MAX_SAFE_INTEGER,
|
||||
activeStartedAt: Number.MAX_SAFE_INTEGER,
|
||||
accumulatedMs: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
/startedAt must be a valid non-negative timestamp/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("finalizes an active timer in removed task details", () => {
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Canceled", status: "in-progress" }]);
|
||||
now += 4_000;
|
||||
|
||||
const [removed] = state.remove([1]);
|
||||
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(removed?.durationMs, 4_000);
|
||||
assert.deepEqual(state.list(), []);
|
||||
});
|
||||
|
||||
test("rejects cycles and duplicate patches without partial changes", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, parentId: 2 }, { id: 2, name: "Changed" }]), /cycle/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(() => state.update([{ id: 2, name: "One" }, { id: 2, name: "Two" }]), /duplicate task patches/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("reorders roots with zero-based sequential positions", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{ name: "A", description: "A" },
|
||||
{ name: "B", description: "B" },
|
||||
{ name: "C", description: "C" },
|
||||
]);
|
||||
|
||||
state.update([{ id: 1, position: 2 }]);
|
||||
assert.deepEqual(names(state), ["B", "C", "A"]);
|
||||
|
||||
state.update([
|
||||
{ id: 1, position: 0 },
|
||||
{ id: 2, position: 2 },
|
||||
]);
|
||||
assert.deepEqual(names(state), ["A", "C", "B"]);
|
||||
});
|
||||
|
||||
test("reparents and positions tasks among their final siblings", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "One",
|
||||
description: "One",
|
||||
children: [
|
||||
{ name: "A", description: "A" },
|
||||
{ name: "B", description: "B" },
|
||||
],
|
||||
},
|
||||
{ name: "Two", description: "Two", children: [{ name: "C", description: "C" }] },
|
||||
]);
|
||||
|
||||
state.update([{ id: 3, parentId: 4, position: 0 }]);
|
||||
assert.deepEqual(state.get([4])[0]?.childrenIds, [3, 5]);
|
||||
assert.deepEqual(state.get([1])[0]?.childrenIds, [2]);
|
||||
});
|
||||
|
||||
test("removes overlapping parent and child IDs as one cascade", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Root",
|
||||
description: "Root",
|
||||
children: [{ name: "Child", description: "Child", children: [{ name: "Leaf", description: "Leaf" }] }],
|
||||
},
|
||||
{ name: "Keep", description: "Keep" },
|
||||
]);
|
||||
|
||||
const removed = state.remove([1, 2, 1]);
|
||||
assert.deepEqual(removed.map((task) => task.id), [1, 2, 3]);
|
||||
assert.deepEqual(state.list().map((task) => task.id), [4]);
|
||||
assert.equal(state.snapshot().nextId, 5);
|
||||
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.remove([999]), /do not exist/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("restores snapshots and never reuses deleted IDs", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "One", description: "One" }, { name: "Two", description: "Two" }]);
|
||||
state.remove([2]);
|
||||
const snapshot = state.snapshot();
|
||||
|
||||
const restored = new TaskState();
|
||||
restored.restore(snapshot);
|
||||
const added = restored.add([{ name: "Three", description: "Three" }]);
|
||||
assert.equal(added[0]?.id, 3);
|
||||
});
|
||||
|
||||
test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
const first = new TaskState();
|
||||
first.add([{ name: "First", description: "First" }]);
|
||||
const second = new TaskState();
|
||||
second.restore(first.snapshot());
|
||||
second.add([{ name: "Second", description: "Second" }]);
|
||||
|
||||
const branch = [first.snapshot(), second.snapshot()].map((snapshot, index) => ({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: index === 0 ? "add_task" : "patch_tasks",
|
||||
details: { action: "add", snapshot, affected: [] },
|
||||
},
|
||||
}));
|
||||
const state = new TaskState();
|
||||
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("loads legacy update_task mutation snapshots", () => {
|
||||
const completed = new TaskState();
|
||||
completed.add([{ name: "Legacy completed task", status: "done" }]);
|
||||
const branch = [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: "update_task",
|
||||
details: { action: "update", snapshot: completed.snapshot(), affected: [] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const state = new TaskState();
|
||||
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["Legacy completed task"]);
|
||||
});
|
||||
|
||||
test("loads a persisted automatic clear after task tool snapshots", () => {
|
||||
const completed = new TaskState();
|
||||
completed.add([{ name: "Completed", status: "done" }]);
|
||||
const branch = [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: "update_task",
|
||||
details: { action: "update", snapshot: completed.snapshot(), affected: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
customType: TASK_STATE_ENTRY_TYPE,
|
||||
data: { tasks: [], nextId: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const state = new TaskState();
|
||||
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
|
||||
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
|
||||
});
|
||||
|
||||
test("rejects terminal controls and bidirectional overrides without changing state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
|
||||
assert.throws(() => state.add([{ name }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
}
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces task name and description length limits by Unicode character", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
|
||||
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces per-mutation and per-session task limits atomically", () => {
|
||||
const state = new TaskState();
|
||||
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
|
||||
|
||||
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
|
||||
assert.equal(state.list().length, 0);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) state.add(batch);
|
||||
assert.equal(state.list().length, 200);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new TaskState().restore({
|
||||
tasks: Array.from({ length: 201 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Restored ${index + 1}`,
|
||||
status: "pending" as const,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
})),
|
||||
nextId: 202,
|
||||
}),
|
||||
/session is limited to 200 tasks/,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => {
|
||||
const nested = (depth: number) => {
|
||||
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
|
||||
for (let level = depth - 1; level >= 1; level -= 1) {
|
||||
task = { name: `Level ${level}`, children: [task] };
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const state = new TaskState();
|
||||
state.add([nested(3) as never, { name: "Other category" }]);
|
||||
assert.equal(state.list().length, 4);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{ id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] },
|
||||
{ id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] },
|
||||
{ id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] },
|
||||
{ id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] },
|
||||
],
|
||||
nextId: 5,
|
||||
}),
|
||||
/depth must not exceed 3 levels/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("supports nullable descriptions and reports invalid children as validation errors", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
|
||||
assert.throws(
|
||||
() => state.add([{ name: "Invalid children", children: "not-an-array" } as never]),
|
||||
/children must be an array/,
|
||||
);
|
||||
state.add([
|
||||
{ name: "No description" },
|
||||
{ name: "Null description", description: null },
|
||||
{ name: "Detailed", description: "Line one\nLine two" },
|
||||
]);
|
||||
assert.equal(state.list()[0]?.description, undefined);
|
||||
assert.equal(state.list()[1]?.description, undefined);
|
||||
assert.equal(state.list()[2]?.description, "Line one\nLine two");
|
||||
});
|
||||
|
||||
test("set append accepts false force and rejects true force", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
|
||||
state.set("append", [{ name: "Task" }], false);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("set replace is atomic, guarded, and resets IDs", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Old", status: "in-progress" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.set("replace", [{ name: "New" }]), /requires force/);
|
||||
assert.throws(
|
||||
() => state.set("replace", [{ name: "Invalid parent", parentId: 1 }], true),
|
||||
/parentId is not allowed in replace mode/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
const replaced = state.set(
|
||||
"replace",
|
||||
[{ name: "New", parentId: null, children: [{ name: "Child", parentId: null }] }],
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(replaced.map((task) => task.id), [1, 2]);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["New", "Child"]);
|
||||
assert.equal(state.snapshot().nextId, 3);
|
||||
|
||||
state.set("replace", [], true);
|
||||
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
|
||||
});
|
||||
|
||||
test("set replace does not require force after all existing tasks are done", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Done", status: "done" }]);
|
||||
state.set("replace", [{ name: "Fresh" }]);
|
||||
assert.deepEqual(state.list().map((task) => [task.id, task.name]), [[1, "Fresh"]]);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
const { validateToolArguments } = await import(
|
||||
new URL("./utils/validation.js", import.meta.resolve("@earendil-works/pi-ai")).href
|
||||
);
|
||||
import { registerTaskTools } from "../src/tools.ts";
|
||||
|
||||
function registeredTools(state = new TaskState()) {
|
||||
const tools = new Map<string, any>();
|
||||
let changes = 0;
|
||||
const autoClearRestarts: boolean[] = [];
|
||||
const pi = {
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
};
|
||||
registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => {
|
||||
changes += 1;
|
||||
autoClearRestarts.push(restartAutoClear);
|
||||
});
|
||||
return { tools, state, changes: () => changes, autoClearRestarts };
|
||||
}
|
||||
|
||||
async function execute(tool: any, params: unknown) {
|
||||
const prepared = tool.prepareArguments?.(params) ?? params;
|
||||
const validated = validateToolArguments(tool, { name: tool.name, arguments: prepared } as never);
|
||||
return tool.execute("call", validated, undefined, undefined, {});
|
||||
}
|
||||
|
||||
test("registers five focused tools without the old add_task alias", () => {
|
||||
const { tools } = registeredTools();
|
||||
assert.deepEqual([...tools.keys()], ["set_tasks", "patch_tasks", "rm_task", "list_task", "get_task"]);
|
||||
assert.deepEqual(Object.keys(tools.get("set_tasks").parameters.properties), ["mode", "tasks", "force"]);
|
||||
assert.deepEqual(Object.keys(tools.get("patch_tasks").parameters.properties), ["tasks"]);
|
||||
assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids", "reason"]);
|
||||
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
|
||||
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
|
||||
assert.match(tools.get("patch_tasks").description, /do not recreate cleared completed tasks/);
|
||||
});
|
||||
|
||||
test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||
const { tools } = registeredTools();
|
||||
const params = tools.get("set_tasks").parameters;
|
||||
const itemSchema = params.properties.tasks.items;
|
||||
// $defs must live at the document root: root-style refs resolve from there,
|
||||
// and moonshot-flavored validators reject nested definitions (Console Go).
|
||||
assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root");
|
||||
assert.deepEqual(params.required, ["mode", "tasks", "force"]);
|
||||
assert.deepEqual(params.$defs.AddTask.required, ["name", "description", "status", "parentId", "children"]);
|
||||
assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items");
|
||||
assert.equal(itemSchema.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped");
|
||||
const serialized = JSON.stringify(params);
|
||||
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
|
||||
});
|
||||
|
||||
test("task schemas publish text and batch limits", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setParams = tools.get("set_tasks").parameters;
|
||||
const addTask = setParams.$defs.AddTask;
|
||||
const updateTask = tools.get("patch_tasks").parameters.properties.tasks.items;
|
||||
|
||||
assert.equal(setParams.properties.tasks.maxItems, 50);
|
||||
assert.equal(addTask.properties.children.maxItems, 50);
|
||||
assert.equal(addTask.properties.name.maxLength, 200);
|
||||
assert.match(addTask.properties.name.pattern, /061C/i);
|
||||
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
||||
assert.equal(tools.get("patch_tasks").parameters.properties.tasks.maxItems, 50);
|
||||
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
||||
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50);
|
||||
assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50);
|
||||
|
||||
const setTool = tools.get("set_tasks");
|
||||
const validUnicode = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }],
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never),
|
||||
);
|
||||
const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("set_tasks preparation bounds hostile recursive input before validation", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
let nested: Record<string, unknown> = { name: "Leaf" };
|
||||
for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] };
|
||||
|
||||
const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const oversized = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })),
|
||||
});
|
||||
assert.equal(oversized.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const updateTool = tools.get("patch_tasks");
|
||||
const oversizedUpdate = updateTool.prepareArguments({
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
||||
});
|
||||
assert.equal(oversizedUpdate.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("strict task schemas normalize legacy optional arguments", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
const updateTool = tools.get("patch_tasks");
|
||||
|
||||
assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"parentId",
|
||||
"position",
|
||||
]);
|
||||
assert.deepEqual(setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", children: [{ name: "Child" }] }],
|
||||
}), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{
|
||||
name: "Parent",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [
|
||||
{
|
||||
name: "Child",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
force: false,
|
||||
});
|
||||
assert.deepEqual(updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: null,
|
||||
description: null,
|
||||
status: "done",
|
||||
parentId: -1,
|
||||
position: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0);
|
||||
|
||||
const validated = validateToolArguments(updateTool, {
|
||||
name: "patch_tasks",
|
||||
arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }),
|
||||
} as never);
|
||||
assert.equal(validated.tasks[0].parentId, -1);
|
||||
assert.equal(validated.tasks[0].position, -1);
|
||||
});
|
||||
|
||||
test("validated update sentinels preserve order and map legacy null parent to root", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{ name: "Parent", children: [{ name: "Child" }] },
|
||||
{ name: "Sibling" },
|
||||
],
|
||||
});
|
||||
|
||||
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 3, status: "blocked" }] });
|
||||
assert.deepEqual(
|
||||
harness.state.list().filter((task) => task.parentId === null).map((task) => task.id),
|
||||
[1, 3],
|
||||
);
|
||||
|
||||
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 2, parentId: null }] });
|
||||
assert.equal(harness.state.get([2])[0]?.parentId, null);
|
||||
});
|
||||
|
||||
test("patch_tasks explains an auto-cleared empty tree without recreating tasks", async () => {
|
||||
const harness = registeredTools();
|
||||
|
||||
await assert.rejects(
|
||||
execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, status: "done" }] }),
|
||||
/Patch failed · 1 validation error\n- task tree is empty and may have auto-cleared after completion; do not recreate completed tasks\. Use set_tasks only for genuinely new or remaining work/,
|
||||
);
|
||||
assert.deepEqual(harness.state.list(), []);
|
||||
assert.equal(harness.changes(), 0);
|
||||
});
|
||||
|
||||
test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
|
||||
const harness = registeredTools();
|
||||
const result = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", children: [{ name: "Child", description: "Useful detail" }] }],
|
||||
});
|
||||
|
||||
assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done.");
|
||||
assert.equal(result.details.action, "append");
|
||||
assert.equal(result.details.snapshot.tasks.length, 2);
|
||||
assert.equal(result.details.snapshot.tasks[0].description, undefined);
|
||||
assert.equal(result.details.snapshot.nextId, 3);
|
||||
assert.equal(harness.changes(), 1);
|
||||
assert.deepEqual(harness.autoClearRestarts, [false]);
|
||||
});
|
||||
|
||||
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Old", status: "done" }],
|
||||
});
|
||||
|
||||
const replaced = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "replace",
|
||||
tasks: [{ name: "New", status: "in-progress" }],
|
||||
});
|
||||
assert.equal(replaced.content[0].text, "Replaced task tree with 1 task. 0/1 done. Task IDs restart at 1.");
|
||||
assert.deepEqual(harness.state.list().map((task) => [task.id, task.name]), [[1, "New"]]);
|
||||
|
||||
const cleared = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "replace",
|
||||
tasks: [],
|
||||
force: true,
|
||||
});
|
||||
assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1.");
|
||||
assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 });
|
||||
assert.deepEqual(harness.autoClearRestarts, [false, true, true]);
|
||||
});
|
||||
|
||||
test("tool validation errors preserve atomic state", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", children: [{ name: "Child" }] }],
|
||||
});
|
||||
const before = harness.state.snapshot();
|
||||
|
||||
await assert.rejects(
|
||||
execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, parentId: 2 }] }),
|
||||
/Patch failed · 1 validation error\n- task [12]: parentId creates a cycle/,
|
||||
);
|
||||
assert.deepEqual(harness.state.snapshot(), before);
|
||||
assert.equal(harness.changes(), 1);
|
||||
});
|
||||
|
||||
test("rm_task requires and reports a constrained removal reason", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Duplicate" }],
|
||||
});
|
||||
const removed = await execute(harness.tools.get("rm_task"), { ids: [1], reason: "duplicate" });
|
||||
assert.match(removed.content[0].text, /Reason: duplicate\.$/);
|
||||
assert.deepEqual(harness.state.list(), []);
|
||||
});
|
||||
|
||||
test("list is compact and get returns available details for multiple IDs", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child" }] }],
|
||||
});
|
||||
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child");
|
||||
|
||||
const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] });
|
||||
const parsed = JSON.parse(get.content[0].text);
|
||||
assert.equal(parsed[0].description, "Detailed");
|
||||
assert.deepEqual(parsed[0].childrenIds, [2]);
|
||||
assert.equal(parsed[1].description, undefined);
|
||||
assert.equal(parsed[1].parentId, 1);
|
||||
});
|
||||
|
||||
test("get_task exposes cumulative timing while list output stays compact", async () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const harness = registeredTools(new TaskState(() => now));
|
||||
const added = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Timed", status: "in-progress" }],
|
||||
});
|
||||
assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done.");
|
||||
assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt);
|
||||
|
||||
now += 65_432;
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [in-progress] Timed");
|
||||
|
||||
const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.deepEqual(active[0], {
|
||||
id: 1,
|
||||
name: "Timed",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: 65_432,
|
||||
});
|
||||
|
||||
now += 568;
|
||||
const updated = await execute(harness.tools.get("patch_tasks"), {
|
||||
tasks: [{ id: 1, status: "blocked" }],
|
||||
});
|
||||
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||
|
||||
now += 10_000;
|
||||
const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.equal(paused[0].startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString());
|
||||
assert.equal(paused[0].durationMs, 66_000);
|
||||
});
|
||||
@@ -0,0 +1,910 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import {
|
||||
formatDuration,
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTodoPanel,
|
||||
renderTree,
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||
|
||||
const plainTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
|
||||
type TestTui = { requestRender(): void };
|
||||
type TestWidget = { render(width: number): string[]; dispose(): void };
|
||||
|
||||
function mountWidget(widget: unknown, tui: TestTui = { requestRender() {} }, theme: unknown = plainTheme): TestWidget {
|
||||
return (widget as (tui: TestTui, theme: unknown) => TestWidget)(tui, theme);
|
||||
}
|
||||
|
||||
function setSubagentsIntegration(integration: unknown): void {
|
||||
const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown };
|
||||
if (integration === undefined) delete globals.__pi_interactive_subagents;
|
||||
else globals.__pi_interactive_subagents = integration;
|
||||
}
|
||||
|
||||
function withSubagentsIntegration<T>(integration: unknown, callback: () => T): T {
|
||||
const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown };
|
||||
const previous = globals.__pi_interactive_subagents;
|
||||
setSubagentsIntegration(integration);
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
setSubagentsIntegration(previous);
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function fixture(): TaskState {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Build auth",
|
||||
description: "Auth work",
|
||||
status: "in-progress",
|
||||
children: [
|
||||
{
|
||||
name: "Resolve token issue",
|
||||
description: "Investigate token validation and provider behavior.",
|
||||
status: "blocked",
|
||||
children: [{ name: "Inspect provider response", description: "Inspect response" }],
|
||||
},
|
||||
{ name: "Add middleware", description: "Middleware" },
|
||||
],
|
||||
},
|
||||
{ name: "Define schema", description: "Schema", status: "done" },
|
||||
]);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("renders an adaptive progress bar and stays within width", () => {
|
||||
const stats = fixture().stats();
|
||||
const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40));
|
||||
assert.match(wide, /^ Todos 1\/5 · !1 ━{4}─{16}$/);
|
||||
assert.ok(visibleWidth(wide) <= 40);
|
||||
|
||||
const narrow = renderProgressHeader(stats, plainTheme as never, 12);
|
||||
assert.ok(visibleWidth(narrow) <= 12);
|
||||
assert.doesNotMatch(stripAnsi(narrow), /━|─/);
|
||||
});
|
||||
|
||||
test("renders lightweight connectors without a detached root continuation", () => {
|
||||
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(output, [
|
||||
" ◉ 1. Build auth · 00:00",
|
||||
" ├─ ! 2. Resolve token issue",
|
||||
" │ └─ ○ 3. Inspect provider response",
|
||||
" └─ ○ 4. Add middleware",
|
||||
" ✓ 5. ~Define schema~",
|
||||
]);
|
||||
});
|
||||
|
||||
test("collapses completed widget subtrees while keeping list trees expanded", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Release",
|
||||
children: [
|
||||
{
|
||||
name: "Completed branch",
|
||||
children: [
|
||||
{ name: "Done one", status: "done" },
|
||||
{ name: "Done two", status: "done" },
|
||||
],
|
||||
},
|
||||
{ name: "Open branch", children: [{ name: "Open leaf" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pending parent",
|
||||
children: [{ name: "Finished child", status: "done" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(widgetLines, [
|
||||
" ├─ I. ○ Release",
|
||||
" │ ├─ 1. ○ Completed branch · 2/2 done",
|
||||
" │ └─ 2. ○ Open branch",
|
||||
" │ └─ ○ Open leaf",
|
||||
" └─ II. ○ Pending parent · 1/1 done",
|
||||
]);
|
||||
|
||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(fullTree.some((line) => line.includes("Done one")));
|
||||
assert.ok(fullTree.some((line) => line.includes("Finished child")));
|
||||
|
||||
state.update([{ id: 3, status: "pending" }]);
|
||||
const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(reopened.some((line) => line.includes("Done one")));
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("uses only the outline levels needed while leaving leaf tasks unlabeled", () => {
|
||||
const flatState = new TaskState(() => 0);
|
||||
flatState.add([{ name: "First leaf" }, { name: "Second leaf" }]);
|
||||
assert.deepEqual(renderWidgetTree(flatState.list(), plainTheme as never, 80).map(stripAnsi), [
|
||||
" ├─ ○ First leaf",
|
||||
" └─ ○ Second leaf",
|
||||
]);
|
||||
|
||||
const twoLevelState = new TaskState(() => 0);
|
||||
twoLevelState.add([
|
||||
{ name: "First group", children: [{ name: "First nested leaf" }] },
|
||||
{ name: "Second group", children: [{ name: "Second nested leaf" }] },
|
||||
]);
|
||||
assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [
|
||||
" ├─ 1. ○ First group",
|
||||
" │ └─ ○ First nested leaf",
|
||||
" └─ 2. ○ Second group",
|
||||
" └─ ○ Second nested leaf",
|
||||
]);
|
||||
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category one" },
|
||||
{ name: "Category two" },
|
||||
{ name: "Category three" },
|
||||
{
|
||||
name: "Category four",
|
||||
children: [
|
||||
{ name: "First item", children: [{ name: "Leaf sub-item" }] },
|
||||
{ name: "Second item" },
|
||||
],
|
||||
},
|
||||
{ name: "Category five", children: [{ name: "Reset item numbering" }] },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" ├─ I. ○ Category one",
|
||||
" ├─ II. ○ Category two",
|
||||
" ├─ III. ○ Category three",
|
||||
" ├─ IV. ○ Category four",
|
||||
" │ ├─ 1. ○ First item",
|
||||
" │ │ └─ ○ Leaf sub-item",
|
||||
" │ └─ 2. ○ Second item",
|
||||
" └─ V. ○ Category five",
|
||||
" └─ 1. ○ Reset item numbering",
|
||||
]);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 18);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 18));
|
||||
|
||||
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
|
||||
assert.match(statusPriority[3] ?? "", /○/);
|
||||
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi);
|
||||
assert.match(childStatusPriority[4] ?? "", /○/);
|
||||
const leafStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 12).map(stripAnsi);
|
||||
assert.match(leafStatusPriority[5] ?? "", /○/);
|
||||
const listLeafStatusPriority = renderTree(state.list(), plainTheme as never, 12).map(stripAnsi);
|
||||
assert.match(listLeafStatusPriority[5] ?? "", /○/);
|
||||
|
||||
const timedState = new TaskState(() => 0);
|
||||
timedState.add([
|
||||
{ name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] },
|
||||
]);
|
||||
const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? "");
|
||||
const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? "");
|
||||
assert.match(timedRoot, /◉/);
|
||||
assert.match(timedChild, /◉/);
|
||||
});
|
||||
|
||||
test("animates only in-progress widget circles while preserving narrow-width status markers", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Active", status: "in-progress" },
|
||||
{ name: "Pending" },
|
||||
{ name: "Blocked", status: "blocked" },
|
||||
{ name: "Done", status: "done" },
|
||||
]);
|
||||
|
||||
const orbitFrames = ["◐", "◓", "◑", "◒"];
|
||||
for (const [frame, icon] of orbitFrames.entries()) {
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi);
|
||||
assert.match(lines[0] ?? "", new RegExp(icon));
|
||||
assert.match(lines[1] ?? "", /○/);
|
||||
assert.match(lines[2] ?? "", /!/);
|
||||
assert.match(lines[3] ?? "", /✓/);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi);
|
||||
assert.match(narrow[0] ?? "", new RegExp(icon));
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 6));
|
||||
}
|
||||
|
||||
assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/);
|
||||
});
|
||||
|
||||
test("renumbers widget labels after task reordering and reparenting", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category A", children: [{ name: "A1" }, { name: "A2" }] },
|
||||
{ name: "Category B", children: [{ name: "B1" }] },
|
||||
]);
|
||||
state.update([
|
||||
{ id: 4, position: 0 },
|
||||
{ id: 3, parentId: 4, position: 0 },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" ├─ 1. ○ Category B",
|
||||
" │ ├─ ○ A2",
|
||||
" │ └─ ○ B1",
|
||||
" └─ 2. ○ Category A",
|
||||
" └─ ○ A1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("caps the widget at twelve task rows with an honest overflow summary", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(lines.length, 13);
|
||||
assert.match(lines[0] ?? "", /Task 1/);
|
||||
assert.match(lines[11] ?? "", /Task 12/);
|
||||
assert.equal(lines[12], " … 8 more tasks");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
|
||||
|
||||
const collapsedOverflow = new TaskState(() => 0);
|
||||
collapsedOverflow.add([
|
||||
...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })),
|
||||
{
|
||||
name: "Hidden completed branch",
|
||||
children: [
|
||||
{ name: "Hidden done one", status: "done" as const },
|
||||
{ name: "Hidden done two", status: "done" as const },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(overflowLines[12], " … 3 more tasks");
|
||||
});
|
||||
|
||||
test("hides a completed widget summary before squeezing the task name", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);
|
||||
|
||||
const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? "";
|
||||
assert.ok(visibleWidth(line) <= 20);
|
||||
assert.doesNotMatch(stripAnsi(line), /done$/);
|
||||
});
|
||||
|
||||
test("formats compact timers and adds hours only when needed", () => {
|
||||
assert.equal(formatDuration(0), "00:00");
|
||||
assert.equal(formatDuration(65_432), "01:05");
|
||||
assert.equal(formatDuration(3_599_999), "59:59");
|
||||
assert.equal(formatDuration(3_600_000), "01:00:00");
|
||||
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
now = 65_432;
|
||||
const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24));
|
||||
assert.match(line, / · 01:05$/);
|
||||
assert.ok(visibleWidth(line) <= 24);
|
||||
});
|
||||
|
||||
test("truncates every task row at narrow widths", () => {
|
||||
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
||||
assert.ok(lines.some((line) => stripAnsi(line).includes("...")));
|
||||
});
|
||||
|
||||
test("uses semantic icon colors and old-plugin text styling", () => {
|
||||
const calls: Array<{ color: string; text: string }> = [];
|
||||
const theme = {
|
||||
fg: (color: string, text: string) => {
|
||||
calls.push({ color, text });
|
||||
return text;
|
||||
},
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
const tasks = fixture().list();
|
||||
for (const task of tasks) renderTaskLine(task, "", theme as never, 80);
|
||||
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "○"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉"));
|
||||
assert.ok(calls.some((call) => call.color === "error" && call.text === "!"));
|
||||
assert.ok(calls.some((call) => call.color === "success" && call.text === "✓"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth"));
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~"));
|
||||
assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue"));
|
||||
});
|
||||
|
||||
test("selects the most specific in-progress task and compresses parallel leaves", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Child", description: "Child", status: "in-progress" }],
|
||||
},
|
||||
{ name: "Parallel", description: "Parallel", status: "in-progress" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#2 Child +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#2 Child",
|
||||
task_count: "+1",
|
||||
task_progress: "0/3",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses blocked tasks only when nothing is in progress", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{ name: "Blocked one", description: "One", status: "blocked" },
|
||||
{ name: "Blocked two", description: "Two", status: "blocked" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#1 Blocked one",
|
||||
task_count: "+1",
|
||||
task_progress: "0/2",
|
||||
});
|
||||
state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
|
||||
assert.equal(currentTaskLabel(state.list()), null);
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears sidebar progress for an empty task tree", () => {
|
||||
assert.deepEqual(currentTaskTokens([]), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("hides an empty widget and renders a populated one", () => {
|
||||
let widget: unknown = "unset";
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
const state = new TaskState();
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(widget, undefined);
|
||||
|
||||
state.add([{ name: "Task", description: "Task" }]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(typeof widget, "function");
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(30).map(stripAnsi);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0] ?? "", /^Todos.*0\/1/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("renders compact unboxed solo panels at narrow widths without overflowing", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long task name", status: "blocked" }]);
|
||||
|
||||
for (const width of [80, 60]) {
|
||||
const lines = renderTodoPanel(state.list(), plainTheme as never, width).map(stripAnsi);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
||||
assert.match(lines[0] ?? "", /^Todos.*0\/1.*!1/);
|
||||
assert.match(lines[0] ?? "", /━|─/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
}
|
||||
|
||||
const narrow = renderTodoPanel(state.list(), plainTheme as never, 20).map(stripAnsi);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 20));
|
||||
assert.doesNotMatch(narrow.join("\n"), /[╭╮╰╯]/);
|
||||
assert.match(narrow[0] ?? "", /0\/1/);
|
||||
|
||||
const boundary = new TaskState(() => 0);
|
||||
boundary.add(Array.from({ length: 13 }, (_, index) => ({ name: `Boundary ${index + 1}` })));
|
||||
const boundaryLines = renderTodoPanel(boundary.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.equal(boundaryLines.length, 14);
|
||||
assert.match(boundaryLines[12] ?? "", /Boundary 12/);
|
||||
assert.match(boundaryLines[13] ?? "", /… 1 more task/);
|
||||
assert.ok(boundaryLines.every((line) => !line.includes("Boundary 13")));
|
||||
});
|
||||
|
||||
test("passes the current Pi theme unchanged to composed Subagents rendering", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => text,
|
||||
};
|
||||
let receivedTheme: unknown;
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: (_width: number, _maxBodyRows?: number, providerTheme?: unknown) => {
|
||||
receivedTheme = providerTheme;
|
||||
return ["subagents"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, { requestRender() {} }, theme);
|
||||
component.render(80);
|
||||
assert.equal(receivedTheme, theme);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps an unboxed Subagents header plus capped body rows composed at 120 columns", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let receivedMaxBodyRows: number | undefined;
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 2,
|
||||
isClaimed: () => true,
|
||||
render: (_width: number, maxBodyRows?: number) => {
|
||||
receivedMaxBodyRows = maxBodyRows;
|
||||
return ["Subagents", "agent one", "agent two"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(120).map(stripAnsi);
|
||||
assert.equal(receivedMaxBodyRows, 2);
|
||||
assert.ok(lines.some((line) => line.includes("Subagents")));
|
||||
assert.ok(lines.some((line) => line.includes("agent two")));
|
||||
assert.equal(lines[2], "");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 120));
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("switches from side-by-side to stacked panels at the 120-column breakpoint", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claimCount = 0;
|
||||
let releaseCount = 0;
|
||||
const renderCalls: Array<[number, number | undefined]> = [];
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 2,
|
||||
isClaimed: () => true,
|
||||
render: (width: number, maxBodyRows?: number) => {
|
||||
renderCalls.push([width, maxBodyRows]);
|
||||
return ["Subagents"];
|
||||
},
|
||||
claim: (_onChange: () => void) => {
|
||||
claimCount += 1;
|
||||
return () => {
|
||||
releaseCount += 1;
|
||||
};
|
||||
},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, tui);
|
||||
const side = component.render(121).map(stripAnsi);
|
||||
assert.match(side[0] ?? "", /Todos.*Subagents/);
|
||||
assert.ok(side.every((line) => visibleWidth(line) === 121));
|
||||
assert.equal(side[1]?.slice(61), " ".repeat(60));
|
||||
assert.doesNotMatch(side.join("\n"), /[╭╮╰╯]/);
|
||||
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Extra ${index + 1}` })));
|
||||
const unequal = component.render(121).map(stripAnsi);
|
||||
const rightPanel = unequal.map((line) => line.slice(61));
|
||||
assert.equal(rightPanel.at(-1), " ".repeat(60));
|
||||
assert.doesNotMatch(unequal.join("\n"), /[╭╮╰╯]/);
|
||||
|
||||
const stacked = component.render(120);
|
||||
assert.match(stripAnsi(stacked[0] ?? ""), /Todos/);
|
||||
const stackedSubagentsIndex = stacked.findIndex((line) => stripAnsi(line).includes("Subagents"));
|
||||
assert.ok(stackedSubagentsIndex > 0);
|
||||
assert.ok(stackedSubagentsIndex < stacked.length);
|
||||
assert.ok(stacked.every((line) => visibleWidth(line) <= 120));
|
||||
assert.equal(stacked[stackedSubagentsIndex - 1], "");
|
||||
assert.doesNotMatch(stacked.map(stripAnsi).join("\n"), /[╭╮╰╯]/);
|
||||
assert.deepEqual(renderCalls.map(([, maxRows]) => maxRows), [10, 10, 2]);
|
||||
assert.equal(claimCount, 1);
|
||||
|
||||
component.dispose();
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to a full-width Todo panel for absent and old Subagents APIs", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
const empty = {
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => [],
|
||||
claim: () => () => {},
|
||||
isClaimed: () => true,
|
||||
},
|
||||
};
|
||||
const malformed = {
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => new Array(1),
|
||||
claim: () => () => {},
|
||||
isClaimed: () => true,
|
||||
},
|
||||
};
|
||||
for (const integration of [undefined, { statusWidget: { render: () => [] } }, empty, malformed]) {
|
||||
withSubagentsIntegration(integration, () => {
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, tui);
|
||||
const lines = component.render(80).map(stripAnsi);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0] ?? "", /Todos/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
component.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("reacquires a claim after the provider invalidates its callback", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claims = 0;
|
||||
let releases = 0;
|
||||
let providerCallback: (() => void) | undefined;
|
||||
let providerClaimed = false;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["subagents"],
|
||||
isClaimed: (onChange: () => void) => providerClaimed && providerCallback === onChange,
|
||||
claim: (onChange: () => void) => {
|
||||
claims += 1;
|
||||
providerCallback = onChange;
|
||||
providerClaimed = true;
|
||||
return () => {
|
||||
releases += 1;
|
||||
if (providerCallback === onChange) {
|
||||
providerCallback = undefined;
|
||||
providerClaimed = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
component.render(80);
|
||||
assert.equal(claims, 1);
|
||||
component.render(80);
|
||||
assert.equal(claims, 1);
|
||||
|
||||
providerClaimed = false;
|
||||
providerCallback = undefined;
|
||||
component.render(80);
|
||||
assert.equal(claims, 2);
|
||||
assert.equal(releases, 1);
|
||||
component.dispose();
|
||||
assert.equal(releases, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mask Todo rendering failures as optional integration failures", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["subagents"],
|
||||
isClaimed: () => true,
|
||||
claim: () => () => {},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const failure = new Error("Todo renderer failed");
|
||||
const badTheme = {
|
||||
fg: () => {
|
||||
throw failure;
|
||||
},
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => text,
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, { requestRender() {} }, badTheme);
|
||||
assert.throws(() => component.render(80), (error) => error === failure);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not compose or claim Subagents outside the TUI mode", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claims = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["Subagents"],
|
||||
claim: () => {
|
||||
claims += 1;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "rpc" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(80).map(stripAnsi);
|
||||
assert.equal(claims, 0);
|
||||
assert.ok(lines.every((line) => !line.includes("Subagents")));
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("shares ten stacked body rows and keeps Todo overflow honest", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
const calls: Array<[number, number | undefined]> = [];
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 3,
|
||||
isClaimed: () => true,
|
||||
render: (width: number, maxBodyRows?: number) => {
|
||||
calls.push([width, maxBodyRows]);
|
||||
return ["one", "two", "three"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(120).map(stripAnsi);
|
||||
assert.deepEqual(calls, [[120, 3]]);
|
||||
assert.ok(lines.some((line) => line.includes("… 14 more tasks")));
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 120));
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("releases a Subagents claim when Todos hide or clear", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let releases = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: () => ["subagents"],
|
||||
claim: () => () => {
|
||||
releases += 1;
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
component.render(80);
|
||||
updateTaskWidget(state, ctx as never, false);
|
||||
assert.equal(releases, 1);
|
||||
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const second = mountWidget(widget);
|
||||
second.render(80);
|
||||
state.remove([1]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(releases, 2);
|
||||
second.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles zero and one-column panel widths cleanly", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
assert.deepEqual(renderTodoPanel(state.list(), plainTheme as never, 0), []);
|
||||
const oneColumn = renderTodoPanel(state.list(), plainTheme as never, 1);
|
||||
assert.ok(oneColumn.length > 0);
|
||||
assert.ok(oneColumn.every((line) => visibleWidth(line) <= 1));
|
||||
});
|
||||
|
||||
test("ignores stale widget generations when Pi replaces the widget", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo", status: "in-progress" }]);
|
||||
let claims = 0;
|
||||
let releases = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: () => ["subagents"],
|
||||
claim: () => {
|
||||
claims += 1;
|
||||
return () => {
|
||||
releases += 1;
|
||||
};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
const widgets: unknown[] = [];
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => widgets.push(content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
const first = mountWidget(widgets[0], tui);
|
||||
first.render(80);
|
||||
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
const second = mountWidget(widgets[1], tui);
|
||||
second.render(80);
|
||||
assert.equal(claims, 2);
|
||||
assert.equal(releases, 1);
|
||||
assert.deepEqual(first.render(80), []);
|
||||
|
||||
first.dispose();
|
||||
assert.equal(releases, 1);
|
||||
clearTaskWidget(ctx as never);
|
||||
assert.equal(releases, 2);
|
||||
second.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("animates active todos twice per second", () => {
|
||||
assert.equal(TODO_ANIMATION_INTERVAL_MS, 500);
|
||||
});
|
||||
|
||||
test("live widgets request rerenders and dispose their interval", async () => {
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
|
||||
let renderRequests = 0;
|
||||
const observedFrames = new Set<string>();
|
||||
let component: TestWidget;
|
||||
component = mountWidget(widget, {
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||
if (frame !== undefined) observedFrames.add(frame);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00/);
|
||||
now = 65_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(renderRequests > 0);
|
||||
assert.ok(observedFrames.size > 0);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/);
|
||||
} finally {
|
||||
component.dispose();
|
||||
}
|
||||
|
||||
const requestsAfterDispose = renderRequests;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(renderRequests, requestsAfterDispose);
|
||||
});
|
||||
|
||||
test("tool results collapse summaries and expand themed details", () => {
|
||||
const state = fixture();
|
||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||
const result = { content: [{ type: "text", text: "list" }], details };
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]);
|
||||
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.equal(expanded.length, 6);
|
||||
assert.match(expanded[1] ?? "", /Build auth/);
|
||||
});
|
||||
|
||||
test("tool errors show one summary line until expanded", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }],
|
||||
isError: true,
|
||||
};
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]);
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]);
|
||||
});
|
||||
|
||||
test("expanded task details tolerate an omitted description", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Name only" }]);
|
||||
const result = {
|
||||
content: [{ type: "text", text: "details" }],
|
||||
details: { action: "get" as const, snapshot: state.snapshot(), affected: state.list() },
|
||||
};
|
||||
const lines = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(lines, ["✓ 1 task", " ○ 1. Name only", " Parent root · Children none"]);
|
||||
});
|
||||
|
||||
test("snapshot stats count every task node", () => {
|
||||
assert.deepEqual(snapshotStats(fixture().list()), {
|
||||
total: 5,
|
||||
pending: 2,
|
||||
inProgress: 1,
|
||||
blocked: 1,
|
||||
done: 1,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user