Compare commits
5
Commits
1a437f50b4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9e1ffec51 | ||
|
|
04a0c08c96 | ||
|
|
9c500a485c | ||
|
|
fbe8baf18c | ||
|
|
91a019de29 |
@@ -8,7 +8,7 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Five focused tools: `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task`
|
- 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
|
- 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
|
- 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
|
- Bounded task input and widget output to keep sessions and terminals responsive
|
||||||
@@ -16,7 +16,8 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
|||||||
- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore
|
- 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
|
- 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
|
- A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder
|
||||||
- Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
|
- 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
|
- Optional Herdr metadata showing the current task in the agent sidebar
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -57,7 +58,7 @@ After automatic or manual context compaction, the extension sends one short hidd
|
|||||||
| Tool | Parameters | Behavior |
|
| 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`. |
|
| `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`. |
|
||||||
| `update_task` | `{ 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. |
|
||||||
@@ -98,17 +99,18 @@ A task starts timing the first time it enters `in-progress`. Leaving that state
|
|||||||
|
|
||||||
## Pi Widget
|
## Pi Widget
|
||||||
|
|
||||||
The widget appears above Pi's editor while todos exist. Run `/todo` to hide or show it for the current Pi runtime. Its header is the virtual task-tree root. Root tasks render as Roman-numeral categories, their children as numbered items, and leaf sub-items use indentation without another label. Internal task IDs remain unchanged and visible in tool output. In-progress circles rotate through `◐ ◓ ◑ ◒` twice per second; other status symbols remain still. The widget shows at most 12 task rows plus a compact count of hidden tasks, so larger plans do not crowd the editor out of an 80×24 terminal.
|
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
|
```text
|
||||||
Todos 2/6 · !1 ━━━━━────────
|
Todos 2/6 · !1 ━━━━────
|
||||||
I. ◐ Build auth · 12:08
|
├─ I. ◐ Build auth · 12:08
|
||||||
├─ 1. ! Resolve token issue · 03:41
|
│ ├─ 1. ! Resolve token issue · 03:41
|
||||||
│ └─ ○ Inspect provider response
|
│ └─ ○ Inspect provider response
|
||||||
└─ 2. ○ Add middleware
|
└─ II. ✓ Define schema · 04:31
|
||||||
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.
|
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
|
## Herdr Integration
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,31 +1,58 @@
|
|||||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
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 { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts";
|
||||||
import {
|
import {
|
||||||
COMPACTION_REMINDER,
|
COMPACTION_REMINDER,
|
||||||
COMPACTION_REMINDER_TYPE,
|
COMPACTION_REMINDER_TYPE,
|
||||||
hasUnfinishedTasks,
|
hasUnfinishedTasks,
|
||||||
} from "./src/prompt.ts";
|
} from "./src/prompt.ts";
|
||||||
import { TaskState } from "./src/state.ts";
|
import { TASK_STATE_ENTRY_TYPE, TaskState } from "./src/state.ts";
|
||||||
import { registerTaskTools } from "./src/tools.ts";
|
import { registerTaskTools } from "./src/tools.ts";
|
||||||
import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts";
|
import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts";
|
||||||
|
|
||||||
export default function piTodoHerdr(pi: ExtensionAPI): void {
|
export default function piTodoHerdr(pi: ExtensionAPI): void {
|
||||||
const state = new TaskState();
|
const state = new TaskState();
|
||||||
let widgetVisible = true;
|
let widgetVisible = true;
|
||||||
|
let activeContext: ExtensionContext | undefined;
|
||||||
|
|
||||||
const refresh = (ctx: ExtensionContext) => {
|
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);
|
updateTaskWidget(state, ctx, widgetVisible);
|
||||||
void reportCurrentTask(state.list());
|
void reportCurrentTask(state.list());
|
||||||
|
autoClear.sync(restartAutoClear);
|
||||||
};
|
};
|
||||||
|
|
||||||
const restore = (ctx: ExtensionContext) => {
|
const restore = (ctx: ExtensionContext) => {
|
||||||
state.loadFromSession(ctx);
|
state.loadFromSession(ctx);
|
||||||
refresh(ctx);
|
refresh(ctx, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => restore(ctx));
|
pi.on("session_start", async (_event, ctx) => restore(ctx));
|
||||||
pi.on("session_tree", async (_event, ctx) => restore(ctx));
|
pi.on("session_tree", async (_event, ctx) => restore(ctx));
|
||||||
pi.on("session_shutdown", async (event) => {
|
pi.on("session_shutdown", async (event) => {
|
||||||
|
autoClear.cancel();
|
||||||
|
if (activeContext !== undefined) clearTaskWidget(activeContext);
|
||||||
|
activeContext = undefined;
|
||||||
if (event.reason === "quit") await clearCurrentTask();
|
if (event.reason === "quit") await clearCurrentTask();
|
||||||
});
|
});
|
||||||
pi.on("session_compact", () => {
|
pi.on("session_compact", () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
+1
-1
@@ -7,7 +7,7 @@ export const COMPACTION_REMINDER =
|
|||||||
|
|
||||||
export const TASK_PROMPT_GUIDELINES = [
|
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.",
|
"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 update_task when tasks start, block, or finish; keep statuses accurate before the final response.",
|
"Use patch_tasks when tasks start, block, or finish; keep statuses accurate before the final response.",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function formatCompactTaskTree(tasks: Task[]): string {
|
export function formatCompactTaskTree(tasks: Task[]): string {
|
||||||
|
|||||||
+12
-6
@@ -20,7 +20,8 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { TaskValidationError } from "./types.ts";
|
import { TaskValidationError } from "./types.ts";
|
||||||
|
|
||||||
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
|
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 VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||||
const MAX_DATE_MS = 8_640_000_000_000_000;
|
const MAX_DATE_MS = 8_640_000_000_000_000;
|
||||||
|
|
||||||
@@ -661,13 +662,18 @@ export class TaskState {
|
|||||||
this.records = [];
|
this.records = [];
|
||||||
this.nextId = 1;
|
this.nextId = 1;
|
||||||
for (const entry of ctx.sessionManager.getBranch()) {
|
for (const entry of ctx.sessionManager.getBranch()) {
|
||||||
if (entry.type !== "message") continue;
|
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;
|
const message = entry.message;
|
||||||
if (message.role !== "toolResult" || !MUTATION_TOOLS.has(message.toolName)) continue;
|
if (message.role === "toolResult" && MUTATION_TOOLS.has(message.toolName)) {
|
||||||
const details = message.details as TaskToolDetails | undefined;
|
snapshot = (message.details as TaskToolDetails | undefined)?.snapshot;
|
||||||
if (!details?.snapshot) continue;
|
}
|
||||||
|
}
|
||||||
|
if (!snapshot) continue;
|
||||||
try {
|
try {
|
||||||
this.restore(details.snapshot);
|
this.restore(snapshot);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore corrupt historical snapshots and keep the latest valid state.
|
// Ignore corrupt historical snapshots and keep the latest valid state.
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-18
@@ -169,7 +169,7 @@ const TaskPatchSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const UpdateTaskParams = Type.Object(
|
export const PatchTasksParams = Type.Object(
|
||||||
{
|
{
|
||||||
tasks: Type.Array(TaskPatchSchema, {
|
tasks: Type.Array(TaskPatchSchema, {
|
||||||
minItems: 1,
|
minItems: 1,
|
||||||
@@ -255,9 +255,9 @@ function prepareTaskPatch(value: unknown): unknown {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskParams> {
|
function preparePatchTasksArguments(value: unknown): Static<typeof PatchTasksParams> {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return value as Static<typeof UpdateTaskParams>;
|
return value as Static<typeof PatchTasksParams>;
|
||||||
}
|
}
|
||||||
const input = value as Record<string, unknown>;
|
const input = value as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -265,7 +265,7 @@ function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskPar
|
|||||||
tasks: Array.isArray(input.tasks)
|
tasks: Array.isArray(input.tasks)
|
||||||
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
? input.tasks.slice(0, MAX_TASKS_PER_MUTATION + 1).map(prepareTaskPatch)
|
||||||
: input.tasks,
|
: input.tasks,
|
||||||
} as Static<typeof UpdateTaskParams>;
|
} as Static<typeof PatchTasksParams>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetTaskInput {
|
interface SetTaskInput {
|
||||||
@@ -274,7 +274,7 @@ interface SetTaskInput {
|
|||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateTaskInput {
|
interface PatchTasksInput {
|
||||||
tasks: TaskPatch[];
|
tasks: TaskPatch[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ interface RemoveTaskInput extends IdsInput {
|
|||||||
reason: "mistaken" | "duplicate" | "canceled";
|
reason: "mistaken" | "duplicate" | "canceled";
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChangeHandler = (ctx: ExtensionContext) => void;
|
type ChangeHandler = (ctx: ExtensionContext, restartAutoClear?: boolean) => void;
|
||||||
|
|
||||||
function taskIds(tasks: Task[]): string {
|
function taskIds(tasks: Task[]): string {
|
||||||
return tasks.map((task) => task.id).join(", ");
|
return tasks.map((task) => task.id).join(", ");
|
||||||
@@ -323,9 +323,9 @@ function renderCall(title: string, count: number | undefined, theme: Theme): Tex
|
|||||||
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
|
return new Text(theme.fg("toolTitle", theme.bold(title)) + suffix, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void {
|
function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext, restartAutoClear = false): void {
|
||||||
try {
|
try {
|
||||||
onChange(ctx);
|
onChange(ctx, restartAutoClear);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
|
console.error("[pi-todo-herdr] failed to refresh task UI:", error);
|
||||||
}
|
}
|
||||||
@@ -343,7 +343,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
|||||||
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||||
try {
|
try {
|
||||||
const affected = state.set(params.mode, params.tasks, params.force);
|
const affected = state.set(params.mode, params.tasks, params.force);
|
||||||
notifyChange(onChange, ctx);
|
notifyChange(onChange, ctx, params.mode === "replace");
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
||||||
details: details(params.mode, state, affected),
|
details: details(params.mode, state, affected),
|
||||||
@@ -361,14 +361,19 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
|||||||
});
|
});
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "update_task",
|
name: "patch_tasks",
|
||||||
label: "Update Tasks",
|
label: "Patch Tasks",
|
||||||
description:
|
description:
|
||||||
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged.",
|
"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: UpdateTaskParams,
|
parameters: PatchTasksParams,
|
||||||
prepareArguments: prepareUpdateTaskArguments,
|
prepareArguments: preparePatchTasksArguments,
|
||||||
async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params: PatchTasksInput, _signal, _onUpdate, ctx) {
|
||||||
try {
|
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);
|
const affected = state.update(params.tasks);
|
||||||
notifyChange(onChange, ctx);
|
notifyChange(onChange, ctx);
|
||||||
return {
|
return {
|
||||||
@@ -376,11 +381,11 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
|||||||
details: details("update", state, affected),
|
details: details("update", state, affected),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwToolError("Update", error);
|
throwToolError("Patch", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderCall(args: UpdateTaskInput, theme: Theme) {
|
renderCall(args: PatchTasksInput, theme: Theme) {
|
||||||
return renderCall("Update Tasks", args.tasks?.length, theme);
|
return renderCall("Patch Tasks", args.tasks?.length, theme);
|
||||||
},
|
},
|
||||||
renderResult(result, { expanded }, theme) {
|
renderResult(result, { expanded }, theme) {
|
||||||
return renderToolResult(result, expanded, theme);
|
return renderToolResult(result, expanded, theme);
|
||||||
|
|||||||
@@ -48,14 +48,14 @@ function descendantStats(task: Task, byId: Map<number, Task>, memo: Map<number,
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] {
|
function buildTreeRows(tasks: Task[], collapseCompleted: boolean, connectRoots = false): TreeRow[] {
|
||||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||||
const memo = new Map<number, DescendantStats>();
|
const memo = new Map<number, DescendantStats>();
|
||||||
const rows: TreeRow[] = [];
|
const rows: TreeRow[] = [];
|
||||||
|
|
||||||
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
|
const visit = (task: Task, ancestorHasNext: boolean[], isLast: boolean, depth: number, siblingIndex: number) => {
|
||||||
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
const ancestors = ancestorHasNext.map((hasNext) => (hasNext ? "│ " : " ")).join("");
|
||||||
const connector = depth === 0 ? "" : isLast ? "└─ " : "├─ ";
|
const connector = depth === 0 && !connectRoots ? "" : isLast ? "└─ " : "├─ ";
|
||||||
const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
|
const stats = collapseCompleted ? descendantStats(task, byId, memo) : undefined;
|
||||||
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
|
const collapsed = stats && stats.total > 0 && stats.done === stats.total ? stats : undefined;
|
||||||
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) });
|
rows.push({ task, prefix: `${ancestors}${connector}`, depth, siblingIndex, ...(collapsed ? { collapsed } : {}) });
|
||||||
@@ -63,7 +63,7 @@ function buildTreeRows(tasks: Task[], collapseCompleted: boolean): TreeRow[] {
|
|||||||
|
|
||||||
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
|
const children = task.childrenIds.map((id) => byId.get(id)).filter((child): child is Task => child !== undefined);
|
||||||
children.forEach((child, index) => {
|
children.forEach((child, index) => {
|
||||||
const childAncestors = depth === 0 ? [] : [...ancestorHasNext, !isLast];
|
const childAncestors = depth === 0 && !connectRoots ? [] : [...ancestorHasNext, !isLast];
|
||||||
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
|
visit(child, childAncestors, index === children.length - 1, depth + 1, index);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -151,8 +151,32 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
|
|||||||
return renderTaskLineWithSuffix(task, prefix, theme, width);
|
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[] {
|
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||||
return orderedTreeRows(tasks).map(({ task, prefix }) => renderTaskLine(task, `${padding}${prefix}`, theme, width));
|
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 {
|
function toRomanNumeral(value: number): string {
|
||||||
@@ -178,10 +202,27 @@ function toRomanNumeral(value: number): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function widgetOutlineLabel(depth: number, siblingIndex: number): string {
|
function maximumTaskDepth(tasks: Task[]): number {
|
||||||
if (depth === 0) return `${toRomanNumeral(siblingIndex + 1)}.`;
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||||
if (depth === 1) return `${siblingIndex + 1}.`;
|
let maximumDepth = 0;
|
||||||
return "";
|
|
||||||
|
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(
|
export function renderWidgetTree(
|
||||||
@@ -190,17 +231,34 @@ export function renderWidgetTree(
|
|||||||
width: number,
|
width: number,
|
||||||
padding = " ",
|
padding = " ",
|
||||||
inProgressFrame?: number,
|
inProgressFrame?: number,
|
||||||
|
maxBodyRows?: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const rows = buildTreeRows(tasks, true);
|
const rows = buildTreeRows(tasks, true, true);
|
||||||
const visibleRows = rows.slice(0, MAX_WIDGET_TASK_ROWS);
|
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 lines = visibleRows.map(({ task, prefix, depth, siblingIndex, collapsed }) => {
|
||||||
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
const summary = collapsed === undefined ? undefined : `${collapsed.done}/${collapsed.total} done`;
|
||||||
const identifier = widgetOutlineLabel(depth, siblingIndex);
|
const identifier = widgetOutlineLabel(depth, siblingIndex, maximumDepth);
|
||||||
return renderTaskLineWithSuffix(task, `${padding}${prefix}`, theme, width, summary, identifier, true, inProgressFrame);
|
return renderTaskLineWithSuffix(
|
||||||
|
task,
|
||||||
|
indentDeepestLeaf(`${padding}${prefix}`, depth, maximumDepth, width, STATUS_ICON_WIDTH),
|
||||||
|
theme,
|
||||||
|
width,
|
||||||
|
summary,
|
||||||
|
identifier,
|
||||||
|
true,
|
||||||
|
inProgressFrame,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
if (rows.length <= MAX_WIDGET_TASK_ROWS) return lines;
|
if (!hasOverflow) return lines;
|
||||||
|
|
||||||
const hiddenTasks = rows.slice(MAX_WIDGET_TASK_ROWS).reduce(
|
const hiddenTasks = rows.slice(taskRowLimit).reduce(
|
||||||
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
|
(count, row) => count + 1 + (row.collapsed?.total ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
@@ -226,6 +284,35 @@ export function renderProgressHeader(stats: TaskStats, theme: Theme, width: numb
|
|||||||
return truncateToWidth(`${theme.fg("accent", label)} ${bar}`, Math.max(1, width));
|
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 {
|
class TaskResultComponent implements Component {
|
||||||
private readonly build: (width: number) => string[];
|
private readonly build: (width: number) => string[];
|
||||||
|
|
||||||
|
|||||||
+430
-10
@@ -1,11 +1,398 @@
|
|||||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
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 type { TaskState } from "./state.ts";
|
||||||
import { renderProgressHeader, renderWidgetTree } from "./ui.ts";
|
import { renderTodoPanel } from "./ui.ts";
|
||||||
|
|
||||||
const WIDGET_ID = "pi-todo-herdr";
|
const WIDGET_ID = "pi-todo-herdr";
|
||||||
|
const COMBINED_WIDGET_BREAKPOINT = 120;
|
||||||
|
const COMBINED_BODY_ROWS = 10;
|
||||||
export const TODO_ANIMATION_INTERVAL_MS = 500;
|
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 {
|
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||||
|
if (!isCurrentRuntime()) return;
|
||||||
|
nextWidgetGeneration();
|
||||||
|
releaseActiveClaim();
|
||||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,33 +402,66 @@ export function updateTaskWidget(
|
|||||||
visible: boolean,
|
visible: boolean,
|
||||||
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
refreshMs = TODO_ANIMATION_INTERVAL_MS,
|
||||||
): void {
|
): void {
|
||||||
|
if (!isCurrentRuntime()) return;
|
||||||
const tasks = state.list();
|
const tasks = state.list();
|
||||||
if (!visible || tasks.length === 0) {
|
if (!visible || tasks.length === 0) {
|
||||||
clearTaskWidget(ctx);
|
clearTaskWidget(ctx);
|
||||||
return;
|
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);
|
const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined);
|
||||||
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
ctx.ui.setWidget(WIDGET_ID, (tui, theme) => {
|
||||||
let inProgressFrame = 0;
|
let inProgressFrame = 0;
|
||||||
const timer = hasActiveTimer
|
let disposed = false;
|
||||||
? setInterval(() => {
|
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;
|
inProgressFrame += 1;
|
||||||
|
try {
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
}, Math.max(1, refreshMs))
|
} catch {
|
||||||
: undefined;
|
disposed = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
releaseActiveClaim(owner, generation);
|
||||||
|
}
|
||||||
|
}, Math.max(1, refreshMs));
|
||||||
|
}
|
||||||
timer?.unref();
|
timer?.unref();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
|
if (!isActive()) return [];
|
||||||
const currentTasks = state.list();
|
const currentTasks = state.list();
|
||||||
return [
|
return renderCombinedWidget(
|
||||||
renderProgressHeader(state.stats(), theme, width),
|
currentTasks,
|
||||||
...renderWidgetTree(currentTasks, theme, width, " ", inProgressFrame),
|
theme,
|
||||||
];
|
width,
|
||||||
|
inProgressFrame,
|
||||||
|
tui,
|
||||||
|
owner,
|
||||||
|
generation,
|
||||||
|
composeSubagents,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
invalidate(): void {},
|
invalidate(): void {},
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
disposed = true;
|
||||||
|
releaseActiveClaim(owner, generation);
|
||||||
if (timer !== undefined) clearInterval(timer);
|
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);
|
||||||
|
});
|
||||||
+53
-3
@@ -42,6 +42,56 @@ test("registers the conflict-free /todo command", async () => {
|
|||||||
assert.deepEqual(notifications, ["No todos yet."]);
|
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", () => {
|
test("keeps task guidance short with planning after exploration", () => {
|
||||||
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
|
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
|
||||||
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
|
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
|
||||||
@@ -50,7 +100,7 @@ test("keeps task guidance short with planning after exploration", () => {
|
|||||||
assert.match(prompt, /then call set_tasks before implementation/);
|
assert.match(prompt, /then call set_tasks before implementation/);
|
||||||
assert.match(prompt, /otherwise skip tracking/);
|
assert.match(prompt, /otherwise skip tracking/);
|
||||||
assert.match(prompt, /Start one task in-progress/);
|
assert.match(prompt, /Start one task in-progress/);
|
||||||
assert.match(prompt, /update_task when tasks start, block, or finish/);
|
assert.match(prompt, /patch_tasks when tasks start, block, or finish/);
|
||||||
assert.match(prompt, /keep statuses accurate before the final response/);
|
assert.match(prompt, /keep statuses accurate before the final response/);
|
||||||
assert.ok(prompt.split(/\s+/).length < 40);
|
assert.ok(prompt.split(/\s+/).length < 40);
|
||||||
});
|
});
|
||||||
@@ -129,8 +179,8 @@ test("session_compact sends one hidden reminder only for unfinished tasks", asyn
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await tools.get("update_task").execute(
|
await tools.get("patch_tasks").execute(
|
||||||
"update",
|
"patch",
|
||||||
{ tasks: [{ id: 1, status: "done" }] },
|
{ tasks: [{ id: 1, status: "done" }] },
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
+45
-2
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { TaskState } from "../src/state.ts";
|
import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts";
|
||||||
import { TaskValidationError } from "../src/types.ts";
|
import { TaskValidationError } from "../src/types.ts";
|
||||||
|
|
||||||
function names(state: TaskState): string[] {
|
function names(state: TaskState): string[] {
|
||||||
@@ -353,7 +353,7 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
|||||||
type: "message",
|
type: "message",
|
||||||
message: {
|
message: {
|
||||||
role: "toolResult",
|
role: "toolResult",
|
||||||
toolName: index === 0 ? "add_task" : "update_task",
|
toolName: index === 0 ? "add_task" : "patch_tasks",
|
||||||
details: { action: "add", snapshot, affected: [] },
|
details: { action: "add", snapshot, affected: [] },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -362,6 +362,49 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
|
|||||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
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", () => {
|
test("rejects terminal controls and bidirectional overrides without changing state", () => {
|
||||||
const state = new TaskState();
|
const state = new TaskState();
|
||||||
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
|
||||||
|
|||||||
+30
-14
@@ -7,15 +7,17 @@ import { registerTaskTools } from "../src/tools.ts";
|
|||||||
function registeredTools(state = new TaskState()) {
|
function registeredTools(state = new TaskState()) {
|
||||||
const tools = new Map<string, any>();
|
const tools = new Map<string, any>();
|
||||||
let changes = 0;
|
let changes = 0;
|
||||||
|
const autoClearRestarts: boolean[] = [];
|
||||||
const pi = {
|
const pi = {
|
||||||
registerTool(tool: any) {
|
registerTool(tool: any) {
|
||||||
tools.set(tool.name, tool);
|
tools.set(tool.name, tool);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
registerTaskTools(pi as never, state, () => {
|
registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => {
|
||||||
changes += 1;
|
changes += 1;
|
||||||
|
autoClearRestarts.push(restartAutoClear);
|
||||||
});
|
});
|
||||||
return { tools, state, changes: () => changes };
|
return { tools, state, changes: () => changes, autoClearRestarts };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function execute(tool: any, params: unknown) {
|
async function execute(tool: any, params: unknown) {
|
||||||
@@ -26,12 +28,13 @@ async function execute(tool: any, params: unknown) {
|
|||||||
|
|
||||||
test("registers five focused tools without the old add_task alias", () => {
|
test("registers five focused tools without the old add_task alias", () => {
|
||||||
const { tools } = registeredTools();
|
const { tools } = registeredTools();
|
||||||
assert.deepEqual([...tools.keys()], ["set_tasks", "update_task", "rm_task", "list_task", "get_task"]);
|
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("set_tasks").parameters.properties), ["mode", "tasks", "force"]);
|
||||||
assert.deepEqual(Object.keys(tools.get("update_task").parameters.properties), ["tasks"]);
|
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("rm_task").parameters.properties), ["ids", "reason"]);
|
||||||
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
|
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
|
||||||
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
|
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", () => {
|
test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||||
@@ -55,7 +58,7 @@ test("task schemas publish text and batch limits", () => {
|
|||||||
const { tools } = registeredTools();
|
const { tools } = registeredTools();
|
||||||
const setParams = tools.get("set_tasks").parameters;
|
const setParams = tools.get("set_tasks").parameters;
|
||||||
const addTask = setParams.$defs.AddTask;
|
const addTask = setParams.$defs.AddTask;
|
||||||
const updateTask = tools.get("update_task").parameters.properties.tasks.items;
|
const updateTask = tools.get("patch_tasks").parameters.properties.tasks.items;
|
||||||
|
|
||||||
assert.equal(setParams.properties.tasks.maxItems, 50);
|
assert.equal(setParams.properties.tasks.maxItems, 50);
|
||||||
assert.equal(addTask.properties.children.maxItems, 50);
|
assert.equal(addTask.properties.children.maxItems, 50);
|
||||||
@@ -63,7 +66,7 @@ test("task schemas publish text and batch limits", () => {
|
|||||||
assert.match(addTask.properties.name.pattern, /061C/i);
|
assert.match(addTask.properties.name.pattern, /061C/i);
|
||||||
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||||
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
||||||
assert.equal(tools.get("update_task").parameters.properties.tasks.maxItems, 50);
|
assert.equal(tools.get("patch_tasks").parameters.properties.tasks.maxItems, 50);
|
||||||
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
||||||
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
|
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("rm_task").parameters.properties.ids.maxItems, 50);
|
||||||
@@ -106,7 +109,7 @@ test("set_tasks preparation bounds hostile recursive input before validation", (
|
|||||||
/validation failed/i,
|
/validation failed/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTool = tools.get("update_task");
|
const updateTool = tools.get("patch_tasks");
|
||||||
const oversizedUpdate = updateTool.prepareArguments({
|
const oversizedUpdate = updateTool.prepareArguments({
|
||||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
||||||
});
|
});
|
||||||
@@ -120,7 +123,7 @@ test("set_tasks preparation bounds hostile recursive input before validation", (
|
|||||||
test("strict task schemas normalize legacy optional arguments", () => {
|
test("strict task schemas normalize legacy optional arguments", () => {
|
||||||
const { tools } = registeredTools();
|
const { tools } = registeredTools();
|
||||||
const setTool = tools.get("set_tasks");
|
const setTool = tools.get("set_tasks");
|
||||||
const updateTool = tools.get("update_task");
|
const updateTool = tools.get("patch_tasks");
|
||||||
|
|
||||||
assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [
|
assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [
|
||||||
"id",
|
"id",
|
||||||
@@ -169,7 +172,7 @@ test("strict task schemas normalize legacy optional arguments", () => {
|
|||||||
assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0);
|
assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0);
|
||||||
|
|
||||||
const validated = validateToolArguments(updateTool, {
|
const validated = validateToolArguments(updateTool, {
|
||||||
name: "update_task",
|
name: "patch_tasks",
|
||||||
arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }),
|
arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }),
|
||||||
} as never);
|
} as never);
|
||||||
assert.equal(validated.tasks[0].parentId, -1);
|
assert.equal(validated.tasks[0].parentId, -1);
|
||||||
@@ -186,16 +189,27 @@ test("validated update sentinels preserve order and map legacy null parent to ro
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await execute(harness.tools.get("update_task"), { tasks: [{ id: 3, status: "blocked" }] });
|
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 3, status: "blocked" }] });
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
harness.state.list().filter((task) => task.parentId === null).map((task) => task.id),
|
harness.state.list().filter((task) => task.parentId === null).map((task) => task.id),
|
||||||
[1, 3],
|
[1, 3],
|
||||||
);
|
);
|
||||||
|
|
||||||
await execute(harness.tools.get("update_task"), { tasks: [{ id: 2, parentId: null }] });
|
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 2, parentId: null }] });
|
||||||
assert.equal(harness.state.get([2])[0]?.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 () => {
|
test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
|
||||||
const harness = registeredTools();
|
const harness = registeredTools();
|
||||||
const result = await execute(harness.tools.get("set_tasks"), {
|
const result = await execute(harness.tools.get("set_tasks"), {
|
||||||
@@ -209,6 +223,7 @@ test("set_tasks appends optional-description tasks and persists a complete snaps
|
|||||||
assert.equal(result.details.snapshot.tasks[0].description, undefined);
|
assert.equal(result.details.snapshot.tasks[0].description, undefined);
|
||||||
assert.equal(result.details.snapshot.nextId, 3);
|
assert.equal(result.details.snapshot.nextId, 3);
|
||||||
assert.equal(harness.changes(), 1);
|
assert.equal(harness.changes(), 1);
|
||||||
|
assert.deepEqual(harness.autoClearRestarts, [false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
|
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
|
||||||
@@ -232,6 +247,7 @@ test("set_tasks atomically replaces or clears the tree and resets IDs", async ()
|
|||||||
});
|
});
|
||||||
assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1.");
|
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.state.snapshot(), { tasks: [], nextId: 1 });
|
||||||
|
assert.deepEqual(harness.autoClearRestarts, [false, true, true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("tool validation errors preserve atomic state", async () => {
|
test("tool validation errors preserve atomic state", async () => {
|
||||||
@@ -243,8 +259,8 @@ test("tool validation errors preserve atomic state", async () => {
|
|||||||
const before = harness.state.snapshot();
|
const before = harness.state.snapshot();
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
execute(harness.tools.get("update_task"), { tasks: [{ id: 1, parentId: 2 }] }),
|
execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, parentId: 2 }] }),
|
||||||
/Update failed · 1 validation error\n- task [12]: parentId creates a cycle/,
|
/Patch failed · 1 validation error\n- task [12]: parentId creates a cycle/,
|
||||||
);
|
);
|
||||||
assert.deepEqual(harness.state.snapshot(), before);
|
assert.deepEqual(harness.state.snapshot(), before);
|
||||||
assert.equal(harness.changes(), 1);
|
assert.equal(harness.changes(), 1);
|
||||||
@@ -306,7 +322,7 @@ test("get_task exposes cumulative timing while list output stays compact", async
|
|||||||
});
|
});
|
||||||
|
|
||||||
now += 568;
|
now += 568;
|
||||||
const updated = await execute(harness.tools.get("update_task"), {
|
const updated = await execute(harness.tools.get("patch_tasks"), {
|
||||||
tasks: [{ id: 1, status: "blocked" }],
|
tasks: [{ id: 1, status: "blocked" }],
|
||||||
});
|
});
|
||||||
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||||
|
|||||||
+498
-40
@@ -8,11 +8,12 @@ import {
|
|||||||
renderProgressHeader,
|
renderProgressHeader,
|
||||||
renderTaskLine,
|
renderTaskLine,
|
||||||
renderToolResult,
|
renderToolResult,
|
||||||
|
renderTodoPanel,
|
||||||
renderTree,
|
renderTree,
|
||||||
renderWidgetTree,
|
renderWidgetTree,
|
||||||
snapshotStats,
|
snapshotStats,
|
||||||
} from "../src/ui.ts";
|
} from "../src/ui.ts";
|
||||||
import { TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||||
|
|
||||||
const plainTheme = {
|
const plainTheme = {
|
||||||
fg: (_color: string, text: string) => text,
|
fg: (_color: string, text: string) => text,
|
||||||
@@ -20,6 +21,30 @@ const plainTheme = {
|
|||||||
strikethrough: (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 {
|
function stripAnsi(text: string): string {
|
||||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
}
|
}
|
||||||
@@ -92,11 +117,11 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
|
|||||||
|
|
||||||
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||||
assert.deepEqual(widgetLines, [
|
assert.deepEqual(widgetLines, [
|
||||||
" I. ○ Release",
|
" ├─ I. ○ Release",
|
||||||
" ├─ 1. ○ Completed branch · 2/2 done",
|
" │ ├─ 1. ○ Completed branch · 2/2 done",
|
||||||
" └─ 2. ○ Open branch",
|
" │ └─ 2. ○ Open branch",
|
||||||
" └─ ○ Open leaf",
|
" │ └─ ○ Open leaf",
|
||||||
" II. ○ Pending parent · 1/1 done",
|
" └─ II. ○ Pending parent · 1/1 done",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||||
@@ -109,7 +134,26 @@ test("collapses completed widget subtrees while keeping list trees expanded", ()
|
|||||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-items unlabeled", () => {
|
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);
|
const state = new TaskState(() => 0);
|
||||||
state.add([
|
state.add([
|
||||||
{ name: "Category one" },
|
{ name: "Category one" },
|
||||||
@@ -127,14 +171,14 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-
|
|||||||
|
|
||||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||||
assert.deepEqual(lines, [
|
assert.deepEqual(lines, [
|
||||||
" I. ○ Category one",
|
" ├─ I. ○ Category one",
|
||||||
" II. ○ Category two",
|
" ├─ II. ○ Category two",
|
||||||
" III. ○ Category three",
|
" ├─ III. ○ Category three",
|
||||||
" IV. ○ Category four",
|
" ├─ IV. ○ Category four",
|
||||||
" ├─ 1. ○ First item",
|
" │ ├─ 1. ○ First item",
|
||||||
" │ └─ ○ Leaf sub-item",
|
" │ │ └─ ○ Leaf sub-item",
|
||||||
" └─ 2. ○ Second item",
|
" │ └─ 2. ○ Second item",
|
||||||
" V. ○ Category five",
|
" └─ V. ○ Category five",
|
||||||
" └─ 1. ○ Reset item numbering",
|
" └─ 1. ○ Reset item numbering",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -143,8 +187,12 @@ test("uses sibling-local Roman and numeric widget labels while leaving leaf sub-
|
|||||||
|
|
||||||
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
|
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
|
||||||
assert.match(statusPriority[3] ?? "", /○/);
|
assert.match(statusPriority[3] ?? "", /○/);
|
||||||
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 8).map(stripAnsi);
|
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi);
|
||||||
assert.match(childStatusPriority[4] ?? "", /○/);
|
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);
|
const timedState = new TaskState(() => 0);
|
||||||
timedState.add([
|
timedState.add([
|
||||||
@@ -194,11 +242,11 @@ test("renumbers widget labels after task reordering and reparenting", () => {
|
|||||||
|
|
||||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||||
assert.deepEqual(lines, [
|
assert.deepEqual(lines, [
|
||||||
" I. ○ Category B",
|
" ├─ 1. ○ Category B",
|
||||||
" ├─ 1. ○ A2",
|
" │ ├─ ○ A2",
|
||||||
" └─ 2. ○ B1",
|
" │ └─ ○ B1",
|
||||||
" II. ○ Category A",
|
" └─ 2. ○ Category A",
|
||||||
" └─ 1. ○ A1",
|
" └─ ○ A1",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,15 +392,433 @@ test("hides an empty widget and renders a populated one", () => {
|
|||||||
state.add([{ name: "Task", description: "Task" }]);
|
state.add([{ name: "Task", description: "Task" }]);
|
||||||
updateTaskWidget(state, ctx as never, true);
|
updateTaskWidget(state, ctx as never, true);
|
||||||
assert.equal(typeof widget, "function");
|
assert.equal(typeof widget, "function");
|
||||||
const component = (widget as unknown as (tui: unknown, theme: unknown) => { render(width: number): string[] })(
|
const component = mountWidget(widget);
|
||||||
{},
|
const lines = component.render(30).map(stripAnsi);
|
||||||
plainTheme,
|
|
||||||
);
|
|
||||||
const lines = component.render(30);
|
|
||||||
assert.equal(lines.length, 2);
|
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));
|
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", () => {
|
test("animates active todos twice per second", () => {
|
||||||
assert.equal(TODO_ANIMATION_INTERVAL_MS, 500);
|
assert.equal(TODO_ANIMATION_INTERVAL_MS, 500);
|
||||||
});
|
});
|
||||||
@@ -374,32 +840,24 @@ test("live widgets request rerenders and dispose their interval", async () => {
|
|||||||
|
|
||||||
let renderRequests = 0;
|
let renderRequests = 0;
|
||||||
const observedFrames = new Set<string>();
|
const observedFrames = new Set<string>();
|
||||||
let component: { render(width: number): string[]; dispose?(): void };
|
let component: TestWidget;
|
||||||
component = (
|
component = mountWidget(widget, {
|
||||||
widget as unknown as (
|
|
||||||
tui: { requestRender(): void },
|
|
||||||
theme: unknown,
|
|
||||||
) => { render(width: number): string[]; dispose?(): void }
|
|
||||||
)(
|
|
||||||
{
|
|
||||||
requestRender() {
|
requestRender() {
|
||||||
renderRequests += 1;
|
renderRequests += 1;
|
||||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||||
if (frame !== undefined) observedFrames.add(frame);
|
if (frame !== undefined) observedFrames.add(frame);
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
plainTheme,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00$/);
|
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00/);
|
||||||
now = 65_000;
|
now = 65_000;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
assert.ok(renderRequests > 0);
|
assert.ok(renderRequests > 0);
|
||||||
assert.ok(observedFrames.size > 0);
|
assert.ok(observedFrames.size > 0);
|
||||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05$/);
|
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/);
|
||||||
} finally {
|
} finally {
|
||||||
component.dispose?.();
|
component.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestsAfterDispose = renderRequests;
|
const requestsAfterDispose = renderRequests;
|
||||||
|
|||||||
Reference in New Issue
Block a user