feat: add task duration tracking
This commit is contained in:
@@ -10,6 +10,7 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
|||||||
- 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
|
||||||
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
||||||
|
- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore
|
||||||
- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction
|
- 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 Pi widget with hierarchy, status colors, and an adaptive progress bar
|
||||||
@@ -56,7 +57,7 @@ After automatic or manual context compaction, the extension sends one short hidd
|
|||||||
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. `parentId: null` moves to the root; `position` is a zero-based sibling index. |
|
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. `parentId: null` moves to the root; `position` is a zero-based sibling index. |
|
||||||
| `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 and child IDs. |
|
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. |
|
||||||
|
|
||||||
Example append call:
|
Example append call:
|
||||||
|
|
||||||
@@ -81,16 +82,18 @@ Example append call:
|
|||||||
|
|
||||||
Descriptions are optional, but useful scope, constraints, and done criteria improve recovery through `get_task`. IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
Descriptions are optional, but useful scope, constraints, and done criteria improve recovery through `get_task`. IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
|
||||||
|
|
||||||
|
A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601.
|
||||||
|
|
||||||
## Pi Widget
|
## Pi Widget
|
||||||
|
|
||||||
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime.
|
The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Tasks 2/6 · !1 ━━━━━────────
|
Tasks 2/6 · !1 ━━━━━────────
|
||||||
◉ 1. Build auth
|
◉ 1. Build auth · 12:08
|
||||||
├─ ! 3. Resolve token issue
|
├─ ! 3. Resolve token issue · 03:41
|
||||||
└─ ○ 4. Add middleware
|
└─ ○ 4. Add middleware
|
||||||
✓ 2. Define schema
|
✓ 2. Define schema · 04:31
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-todo-herdr",
|
"name": "pi-todo-herdr",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-todo-herdr",
|
"name": "pi-todo-herdr",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.1",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-todo-herdr",
|
"name": "pi-todo-herdr",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
|
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
|
|||||||
+177
-8
@@ -6,12 +6,14 @@ import type {
|
|||||||
TaskSnapshot,
|
TaskSnapshot,
|
||||||
TaskStats,
|
TaskStats,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
|
TaskTimingSnapshot,
|
||||||
TaskToolDetails,
|
TaskToolDetails,
|
||||||
} 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", "rm_task"]);
|
||||||
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;
|
||||||
|
|
||||||
interface TaskRecord {
|
interface TaskRecord {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,6 +21,10 @@ interface TaskRecord {
|
|||||||
description?: string;
|
description?: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
|
startedAt?: number;
|
||||||
|
activeStartedAt?: number;
|
||||||
|
stoppedAt?: number;
|
||||||
|
accumulatedMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
function cloneRecords(records: TaskRecord[]): TaskRecord[] {
|
||||||
@@ -33,13 +39,74 @@ function childIds(records: TaskRecord[], parentId: number | null): number[] {
|
|||||||
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
return records.filter((task) => task.parentId === parentId).map((task) => task.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicTasks(records: TaskRecord[]): Task[] {
|
function snapshotTasks(records: TaskRecord[]): Task[] {
|
||||||
return records.map((task) => ({
|
return records.map((task) => ({
|
||||||
...task,
|
id: task.id,
|
||||||
|
name: task.name,
|
||||||
|
...(task.description !== undefined ? { description: task.description } : {}),
|
||||||
|
status: task.status,
|
||||||
|
parentId: task.parentId,
|
||||||
childrenIds: childIds(records, task.id),
|
childrenIds: childIds(records, task.id),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function durationMs(task: TaskRecord, now: number): number {
|
||||||
|
const activeMs = task.activeStartedAt === undefined ? 0 : Math.max(0, now - task.activeStartedAt);
|
||||||
|
return task.accumulatedMs + activeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicTasks(records: TaskRecord[], now: number): Task[] {
|
||||||
|
const byId = indexRecords(records);
|
||||||
|
return snapshotTasks(records).map((task) => {
|
||||||
|
const record = byId.get(task.id);
|
||||||
|
if (!record || record.startedAt === undefined) return task;
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
startedAt: new Date(record.startedAt).toISOString(),
|
||||||
|
...(record.stoppedAt !== undefined ? { stoppedAt: new Date(record.stoppedAt).toISOString() } : {}),
|
||||||
|
durationMs: durationMs(record, now),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSnapshots(records: TaskRecord[]): TaskTimingSnapshot[] {
|
||||||
|
const timings: TaskTimingSnapshot[] = [];
|
||||||
|
for (const task of records) {
|
||||||
|
if (task.startedAt === undefined) continue;
|
||||||
|
timings.push({
|
||||||
|
taskId: task.id,
|
||||||
|
startedAt: task.startedAt,
|
||||||
|
...(task.activeStartedAt !== undefined ? { activeStartedAt: task.activeStartedAt } : {}),
|
||||||
|
...(task.stoppedAt !== undefined ? { stoppedAt: task.stoppedAt } : {}),
|
||||||
|
accumulatedMs: task.accumulatedMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return timings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer(task: TaskRecord, now: number): void {
|
||||||
|
const startedAt = Math.max(now, task.stoppedAt ?? task.startedAt ?? 0);
|
||||||
|
task.startedAt ??= startedAt;
|
||||||
|
task.activeStartedAt = startedAt;
|
||||||
|
task.stoppedAt = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTimer(task: TaskRecord, now: number): void {
|
||||||
|
if (task.activeStartedAt === undefined) return;
|
||||||
|
const stoppedAt = Math.max(now, task.activeStartedAt);
|
||||||
|
task.accumulatedMs += stoppedAt - task.activeStartedAt;
|
||||||
|
task.activeStartedAt = undefined;
|
||||||
|
task.stoppedAt = stoppedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNonNegativeSafeInteger(value: unknown): value is number {
|
||||||
|
return Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTimestamp(value: unknown): value is number {
|
||||||
|
return isNonNegativeSafeInteger(value) && value <= MAX_DATE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
function collectDescendantIds(records: TaskRecord[], rootId: number): number[] {
|
||||||
const result: number[] = [];
|
const result: number[] = [];
|
||||||
const visit = (id: number) => {
|
const visit = (id: number) => {
|
||||||
@@ -67,6 +134,38 @@ function validateGraph(records: TaskRecord[]): string[] {
|
|||||||
errors.push(`task ${task.id}: description must not be empty`);
|
errors.push(`task ${task.id}: description must not be empty`);
|
||||||
}
|
}
|
||||||
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||||
|
if (!isNonNegativeSafeInteger(task.accumulatedMs)) {
|
||||||
|
errors.push(`task ${task.id}: accumulatedMs must be a non-negative safe integer`);
|
||||||
|
}
|
||||||
|
if (task.startedAt === undefined) {
|
||||||
|
if (task.activeStartedAt !== undefined || task.stoppedAt !== undefined || task.accumulatedMs !== 0) {
|
||||||
|
errors.push(`task ${task.id}: timer fields require startedAt`);
|
||||||
|
}
|
||||||
|
if (task.status === "in-progress") errors.push(`task ${task.id}: in-progress task is missing timer data`);
|
||||||
|
} else {
|
||||||
|
if (!isValidTimestamp(task.startedAt)) {
|
||||||
|
errors.push(`task ${task.id}: startedAt must be a valid non-negative timestamp`);
|
||||||
|
}
|
||||||
|
if (task.activeStartedAt !== undefined && !isValidTimestamp(task.activeStartedAt)) {
|
||||||
|
errors.push(`task ${task.id}: activeStartedAt must be a valid non-negative timestamp`);
|
||||||
|
}
|
||||||
|
if (task.stoppedAt !== undefined && !isValidTimestamp(task.stoppedAt)) {
|
||||||
|
errors.push(`task ${task.id}: stoppedAt must be a valid non-negative timestamp`);
|
||||||
|
}
|
||||||
|
if (task.activeStartedAt !== undefined && task.activeStartedAt < task.startedAt) {
|
||||||
|
errors.push(`task ${task.id}: activeStartedAt must not precede startedAt`);
|
||||||
|
}
|
||||||
|
if (task.stoppedAt !== undefined && task.stoppedAt < task.startedAt) {
|
||||||
|
errors.push(`task ${task.id}: stoppedAt must not precede startedAt`);
|
||||||
|
}
|
||||||
|
if (task.status === "in-progress") {
|
||||||
|
if (task.activeStartedAt === undefined) errors.push(`task ${task.id}: active timer is missing its segment start`);
|
||||||
|
if (task.stoppedAt !== undefined) errors.push(`task ${task.id}: active timer must not have stoppedAt`);
|
||||||
|
} else {
|
||||||
|
if (task.activeStartedAt !== undefined) errors.push(`task ${task.id}: inactive task has an active timer segment`);
|
||||||
|
if (task.stoppedAt === undefined) errors.push(`task ${task.id}: paused timer is missing stoppedAt`);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
||||||
}
|
}
|
||||||
@@ -153,8 +252,17 @@ export class TaskState {
|
|||||||
private records: TaskRecord[] = [];
|
private records: TaskRecord[] = [];
|
||||||
private nextId = 1;
|
private nextId = 1;
|
||||||
|
|
||||||
|
constructor(private readonly now: () => number = Date.now) {}
|
||||||
|
|
||||||
|
private currentTime(): number {
|
||||||
|
const now = Math.floor(this.now());
|
||||||
|
if (!isValidTimestamp(now)) throw new Error("task clock must return a valid non-negative timestamp");
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
list(): Task[] {
|
list(): Task[] {
|
||||||
return publicTasks(this.records);
|
const now = this.records.some((task) => task.startedAt !== undefined) ? this.currentTime() : 0;
|
||||||
|
return publicTasks(this.records, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(ids: number[]): Task[] {
|
get(ids: number[]): Task[] {
|
||||||
@@ -163,7 +271,12 @@ export class TaskState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snapshot(): TaskSnapshot {
|
snapshot(): TaskSnapshot {
|
||||||
return { tasks: this.list(), nextId: this.nextId };
|
const timings = timingSnapshots(this.records);
|
||||||
|
return {
|
||||||
|
tasks: snapshotTasks(this.records),
|
||||||
|
nextId: this.nextId,
|
||||||
|
...(timings.length > 0 ? { timings } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
stats(): TaskStats {
|
stats(): TaskStats {
|
||||||
@@ -183,6 +296,7 @@ export class TaskState {
|
|||||||
|
|
||||||
const working = cloneRecords(this.records);
|
const working = cloneRecords(this.records);
|
||||||
const existingIds = new Set(working.map((task) => task.id));
|
const existingIds = new Set(working.map((task) => task.id));
|
||||||
|
const now = this.currentTime();
|
||||||
let nextId = this.nextId;
|
let nextId = this.nextId;
|
||||||
const addedIds: number[] = [];
|
const addedIds: number[] = [];
|
||||||
|
|
||||||
@@ -212,13 +326,16 @@ export class TaskState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
|
const status = draft.status ?? "pending";
|
||||||
addedIds.push(id);
|
addedIds.push(id);
|
||||||
working.push({
|
working.push({
|
||||||
id,
|
id,
|
||||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
||||||
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
|
||||||
status: draft.status ?? "pending",
|
status,
|
||||||
parentId,
|
parentId,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [index, child] of (draft.children ?? []).entries()) {
|
for (const [index, child] of (draft.children ?? []).entries()) {
|
||||||
@@ -252,7 +369,7 @@ export class TaskState {
|
|||||||
});
|
});
|
||||||
if (errors.length > 0) throw new TaskValidationError(errors);
|
if (errors.length > 0) throw new TaskValidationError(errors);
|
||||||
|
|
||||||
const replacement = new TaskState();
|
const replacement = new TaskState(this.now);
|
||||||
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
|
||||||
this.restore(replacement.snapshot());
|
this.restore(replacement.snapshot());
|
||||||
return affected;
|
return affected;
|
||||||
@@ -268,6 +385,7 @@ export class TaskState {
|
|||||||
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`);
|
||||||
|
|
||||||
const working = cloneRecords(this.records);
|
const working = cloneRecords(this.records);
|
||||||
|
const now = this.currentTime();
|
||||||
for (const [index, patch] of patches.entries()) {
|
for (const [index, patch] of patches.entries()) {
|
||||||
const path = `tasks[${index}]`;
|
const path = `tasks[${index}]`;
|
||||||
const task = working.find((candidate) => candidate.id === patch.id);
|
const task = working.find((candidate) => candidate.id === patch.id);
|
||||||
@@ -296,7 +414,11 @@ export class TaskState {
|
|||||||
|
|
||||||
if (patch.name !== undefined) task.name = patch.name.trim();
|
if (patch.name !== undefined) task.name = patch.name.trim();
|
||||||
if (patch.description !== undefined) task.description = patch.description.trim();
|
if (patch.description !== undefined) task.description = patch.description.trim();
|
||||||
if (patch.status !== undefined) task.status = patch.status;
|
if (patch.status !== undefined && patch.status !== task.status) {
|
||||||
|
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
|
||||||
|
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
|
||||||
|
task.status = patch.status;
|
||||||
|
}
|
||||||
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
|
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
|
||||||
task.parentId = patch.parentId;
|
task.parentId = patch.parentId;
|
||||||
moveToSiblingEnd(working, task.id);
|
moveToSiblingEnd(working, task.id);
|
||||||
@@ -325,7 +447,11 @@ export class TaskState {
|
|||||||
removedIds.add(id);
|
removedIds.add(id);
|
||||||
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
for (const descendantId of collectDescendantIds(this.records, id)) removedIds.add(descendantId);
|
||||||
}
|
}
|
||||||
const removed = this.list().filter((task) => removedIds.has(task.id));
|
const now = this.currentTime();
|
||||||
|
for (const task of this.records) {
|
||||||
|
if (removedIds.has(task.id) && task.status === "in-progress") stopTimer(task, now);
|
||||||
|
}
|
||||||
|
const removed = publicTasks(this.records, now).filter((task) => removedIds.has(task.id));
|
||||||
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
this.records = this.records.filter((task) => !removedIds.has(task.id));
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
@@ -334,13 +460,56 @@ export class TaskState {
|
|||||||
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) {
|
||||||
throw new TaskValidationError(["invalid task snapshot"]);
|
throw new TaskValidationError(["invalid task snapshot"]);
|
||||||
}
|
}
|
||||||
|
if (snapshot.timings !== undefined && !Array.isArray(snapshot.timings)) {
|
||||||
|
throw new TaskValidationError(["invalid task snapshot timings"]);
|
||||||
|
}
|
||||||
|
|
||||||
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
const records = snapshot.tasks.map(({ id, name, description, status, parentId }) => ({
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
parentId,
|
parentId,
|
||||||
|
accumulatedMs: 0,
|
||||||
}));
|
}));
|
||||||
|
const byId = indexRecords(records);
|
||||||
|
const timingErrors: string[] = [];
|
||||||
|
const timedIds = new Set<number>();
|
||||||
|
|
||||||
|
for (const [index, timing] of (snapshot.timings ?? []).entries()) {
|
||||||
|
const path = `timings[${index}]`;
|
||||||
|
if (!timing || typeof timing !== "object") {
|
||||||
|
timingErrors.push(`${path} must be an object`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(timing.taskId) || timing.taskId < 1) {
|
||||||
|
timingErrors.push(`${path}.taskId must be a positive integer`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (timedIds.has(timing.taskId)) {
|
||||||
|
timingErrors.push(`${path}.taskId ${timing.taskId} is duplicated`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
timedIds.add(timing.taskId);
|
||||||
|
const task = byId.get(timing.taskId);
|
||||||
|
if (!task) {
|
||||||
|
timingErrors.push(`${path}.taskId ${timing.taskId} does not exist`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
task.startedAt = timing.startedAt;
|
||||||
|
task.activeStartedAt = timing.activeStartedAt;
|
||||||
|
task.stoppedAt = timing.stoppedAt;
|
||||||
|
task.accumulatedMs = timing.accumulatedMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timingErrors.length > 0) throw new TaskValidationError(timingErrors);
|
||||||
|
if (snapshot.timings === undefined && records.some((task) => task.status === "in-progress")) {
|
||||||
|
const now = this.currentTime();
|
||||||
|
for (const task of records) {
|
||||||
|
if (task.status === "in-progress") startTimer(task, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const errors = validateGraph(records);
|
const errors = validateGraph(records);
|
||||||
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
const maxId = records.reduce((max, task) => Math.max(max, task.id), 0);
|
||||||
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`);
|
||||||
|
|||||||
@@ -7,11 +7,23 @@ export interface Task {
|
|||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
childrenIds: number[];
|
childrenIds: number[];
|
||||||
|
startedAt?: string;
|
||||||
|
stoppedAt?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskTimingSnapshot {
|
||||||
|
taskId: number;
|
||||||
|
startedAt: number;
|
||||||
|
activeStartedAt?: number;
|
||||||
|
stoppedAt?: number;
|
||||||
|
accumulatedMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskSnapshot {
|
export interface TaskSnapshot {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
nextId: number;
|
nextId: number;
|
||||||
|
timings?: TaskTimingSnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddTaskDraft {
|
export interface AddTaskDraft {
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ function styleStatusIcon(task: Task, theme: Theme): string {
|
|||||||
return theme.fg("dim", icon);
|
return theme.fg("dim", icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDuration(durationMs: number): string {
|
||||||
|
const safeMs = Number.isFinite(durationMs) && durationMs >= 0 ? durationMs : 0;
|
||||||
|
const totalSeconds = Math.floor(safeMs / 1000);
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
const minuteClock = [minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
|
||||||
|
return hours === 0 ? minuteClock : `${String(hours).padStart(2, "0")}:${minuteClock}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: number): string {
|
||||||
const line =
|
const line =
|
||||||
theme.fg("dim", prefix) +
|
theme.fg("dim", prefix) +
|
||||||
@@ -57,7 +67,13 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width:
|
|||||||
theme.fg("accent", `${task.id}.`) +
|
theme.fg("accent", `${task.id}.`) +
|
||||||
" " +
|
" " +
|
||||||
styleTaskName(task, theme);
|
styleTaskName(task, theme);
|
||||||
return truncateToWidth(line, Math.max(1, width));
|
if (task.durationMs === undefined) return truncateToWidth(line, Math.max(1, width));
|
||||||
|
|
||||||
|
const duration = theme.fg("dim", ` · ${formatDuration(task.durationMs)}`);
|
||||||
|
const durationWidth = visibleWidth(duration);
|
||||||
|
if (width <= durationWidth + 6) return truncateToWidth(line, Math.max(1, width));
|
||||||
|
const taskWidth = Math.max(1, width - durationWidth);
|
||||||
|
return truncateToWidth(line, taskWidth) + duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
export function renderTree(tasks: Task[], theme: Theme, width: number, padding = " "): string[] {
|
||||||
@@ -182,7 +198,7 @@ export function renderToolResult(
|
|||||||
return new TaskResultComponent((width) => {
|
return new TaskResultComponent((width) => {
|
||||||
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))];
|
||||||
if (!expanded || details.affected.length === 0) return lines;
|
if (!expanded || details.affected.length === 0) return lines;
|
||||||
if (details.action === "list") return [...lines, ...renderTree(details.snapshot.tasks, theme, width)];
|
if (details.action === "list") return [...lines, ...renderTree(details.affected, theme, width)];
|
||||||
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
if (details.action === "get") return [...lines, ...renderTaskDetails(details.affected, theme, width)];
|
||||||
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
return [...lines, ...renderFlatTasks(details.affected, theme, width)];
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-8
@@ -3,23 +3,40 @@ import type { TaskState } from "./state.ts";
|
|||||||
import { renderProgressHeader, renderTree } from "./ui.ts";
|
import { renderProgressHeader, renderTree } from "./ui.ts";
|
||||||
|
|
||||||
const WIDGET_ID = "pi-todo-herdr";
|
const WIDGET_ID = "pi-todo-herdr";
|
||||||
|
const TIMER_REFRESH_MS = 1000;
|
||||||
|
|
||||||
export function clearTaskWidget(ctx: ExtensionContext): void {
|
export function clearTaskWidget(ctx: ExtensionContext): void {
|
||||||
ctx.ui.setWidget(WIDGET_ID, undefined);
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTaskWidget(state: TaskState, ctx: ExtensionContext, visible: boolean): void {
|
export function updateTaskWidget(
|
||||||
|
state: TaskState,
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
visible: boolean,
|
||||||
|
refreshMs = TIMER_REFRESH_MS,
|
||||||
|
): void {
|
||||||
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 stats = state.stats();
|
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) => {
|
||||||
render(width: number): string[] {
|
const timer = hasActiveTimer
|
||||||
return [renderProgressHeader(stats, theme, width), ...renderTree(tasks, theme, width)];
|
? setInterval(() => tui.requestRender(), Math.max(1, refreshMs))
|
||||||
},
|
: undefined;
|
||||||
invalidate(): void {},
|
timer?.unref();
|
||||||
}));
|
|
||||||
|
return {
|
||||||
|
render(width: number): string[] {
|
||||||
|
const currentTasks = state.list();
|
||||||
|
return [renderProgressHeader(state.stats(), theme, width), ...renderTree(currentTasks, theme, width)];
|
||||||
|
},
|
||||||
|
invalidate(): void {},
|
||||||
|
dispose(): void {
|
||||||
|
if (timer !== undefined) clearInterval(timer);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,144 @@ test("enforces done descendants against the final atomic update state", () => {
|
|||||||
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("preserves and accumulates timing across repeated updates and status changes", () => {
|
||||||
|
const startedAt = Date.UTC(2026, 0, 1);
|
||||||
|
let now = startedAt;
|
||||||
|
const state = new TaskState(() => now);
|
||||||
|
|
||||||
|
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
|
||||||
|
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
|
||||||
|
assert.equal(started?.stoppedAt, undefined);
|
||||||
|
assert.equal(started?.durationMs, 0);
|
||||||
|
|
||||||
|
now += 5_000;
|
||||||
|
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
|
||||||
|
assert.equal(repeated?.startedAt, started?.startedAt);
|
||||||
|
assert.equal(repeated?.stoppedAt, undefined);
|
||||||
|
assert.equal(repeated?.durationMs, 5_000);
|
||||||
|
|
||||||
|
now += 2_000;
|
||||||
|
const [paused] = state.update([{ id: 1, status: "blocked" }]);
|
||||||
|
assert.equal(paused?.startedAt, started?.startedAt);
|
||||||
|
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
|
||||||
|
assert.equal(paused?.durationMs, 7_000);
|
||||||
|
|
||||||
|
now += 10_000;
|
||||||
|
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||||
|
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
|
||||||
|
assert.equal(resumed?.startedAt, started?.startedAt);
|
||||||
|
assert.equal(resumed?.stoppedAt, undefined);
|
||||||
|
assert.equal(resumed?.durationMs, 7_000);
|
||||||
|
|
||||||
|
now += 3_000;
|
||||||
|
const [done] = state.update([{ id: 1, status: "done" }]);
|
||||||
|
assert.equal(done?.startedAt, started?.startedAt);
|
||||||
|
assert.equal(done?.stoppedAt, new Date(now).toISOString());
|
||||||
|
assert.equal(done?.durationMs, 10_000);
|
||||||
|
assert.deepEqual(state.snapshot().timings, [
|
||||||
|
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not stop a timer when an atomic update fails", () => {
|
||||||
|
const startedAt = Date.UTC(2026, 0, 1);
|
||||||
|
let now = startedAt;
|
||||||
|
const state = new TaskState(() => now);
|
||||||
|
state.add([
|
||||||
|
{
|
||||||
|
name: "Parent",
|
||||||
|
status: "in-progress",
|
||||||
|
children: [{ name: "Unfinished child" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
now += 5_000;
|
||||||
|
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||||
|
const afterFailure = state.get([1])[0];
|
||||||
|
assert.equal(afterFailure?.stoppedAt, undefined);
|
||||||
|
assert.equal(afterFailure?.durationMs, 5_000);
|
||||||
|
|
||||||
|
now += 2_000;
|
||||||
|
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restores timers and starts legacy in-progress snapshots at load time", () => {
|
||||||
|
const startedAt = Date.UTC(2026, 0, 1);
|
||||||
|
let now = startedAt;
|
||||||
|
const source = new TaskState(() => now);
|
||||||
|
source.add([{ name: "Persisted", status: "in-progress" }]);
|
||||||
|
now += 2_500;
|
||||||
|
source.update([{ id: 1, status: "pending" }]);
|
||||||
|
|
||||||
|
now += 20_000;
|
||||||
|
const restored = new TaskState(() => now);
|
||||||
|
restored.restore(source.snapshot());
|
||||||
|
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
|
||||||
|
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
|
||||||
|
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
|
||||||
|
|
||||||
|
const legacy = new TaskState(() => now);
|
||||||
|
legacy.restore({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Legacy active",
|
||||||
|
status: "in-progress",
|
||||||
|
parentId: null,
|
||||||
|
childrenIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextId: 2,
|
||||||
|
});
|
||||||
|
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
|
||||||
|
assert.equal(legacy.get([1])[0]?.durationMs, 0);
|
||||||
|
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||||
|
const state = new TaskState(() => 0);
|
||||||
|
state.add([{ name: "Keep" }]);
|
||||||
|
const before = state.snapshot();
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
state.restore({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Corrupt timer",
|
||||||
|
status: "in-progress",
|
||||||
|
parentId: null,
|
||||||
|
childrenIds: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nextId: 2,
|
||||||
|
timings: [
|
||||||
|
{
|
||||||
|
taskId: 1,
|
||||||
|
startedAt: Number.MAX_SAFE_INTEGER,
|
||||||
|
activeStartedAt: Number.MAX_SAFE_INTEGER,
|
||||||
|
accumulatedMs: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/startedAt must be a valid non-negative timestamp/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(state.snapshot(), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finalizes an active timer in removed task details", () => {
|
||||||
|
let now = Date.UTC(2026, 0, 1);
|
||||||
|
const state = new TaskState(() => now);
|
||||||
|
state.add([{ name: "Canceled", status: "in-progress" }]);
|
||||||
|
now += 4_000;
|
||||||
|
|
||||||
|
const [removed] = state.remove([1]);
|
||||||
|
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
|
||||||
|
assert.equal(removed?.durationMs, 4_000);
|
||||||
|
assert.deepEqual(state.list(), []);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects cycles and duplicate patches without partial changes", () => {
|
test("rejects cycles and duplicate patches without partial changes", () => {
|
||||||
const state = new TaskState();
|
const state = new TaskState();
|
||||||
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
||||||
|
|||||||
@@ -113,3 +113,42 @@ test("list is compact and get returns available details for multiple IDs", async
|
|||||||
assert.equal(parsed[1].description, undefined);
|
assert.equal(parsed[1].description, undefined);
|
||||||
assert.equal(parsed[1].parentId, 1);
|
assert.equal(parsed[1].parentId, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("get_task exposes cumulative timing while list output stays compact", async () => {
|
||||||
|
const startedAt = Date.UTC(2026, 0, 1);
|
||||||
|
let now = startedAt;
|
||||||
|
const harness = registeredTools(new TaskState(() => now));
|
||||||
|
const added = await execute(harness.tools.get("set_tasks"), {
|
||||||
|
mode: "append",
|
||||||
|
tasks: [{ name: "Timed", status: "in-progress" }],
|
||||||
|
});
|
||||||
|
assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done.");
|
||||||
|
assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt);
|
||||||
|
|
||||||
|
now += 65_432;
|
||||||
|
const list = await execute(harness.tools.get("list_task"), {});
|
||||||
|
assert.equal(list.content[0].text, "1 [in-progress] Timed");
|
||||||
|
|
||||||
|
const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||||
|
assert.deepEqual(active[0], {
|
||||||
|
id: 1,
|
||||||
|
name: "Timed",
|
||||||
|
status: "in-progress",
|
||||||
|
parentId: null,
|
||||||
|
childrenIds: [],
|
||||||
|
startedAt: new Date(startedAt).toISOString(),
|
||||||
|
durationMs: 65_432,
|
||||||
|
});
|
||||||
|
|
||||||
|
now += 568;
|
||||||
|
const updated = await execute(harness.tools.get("update_task"), {
|
||||||
|
tasks: [{ id: 1, status: "blocked" }],
|
||||||
|
});
|
||||||
|
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||||
|
|
||||||
|
now += 10_000;
|
||||||
|
const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||||
|
assert.equal(paused[0].startedAt, new Date(startedAt).toISOString());
|
||||||
|
assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString());
|
||||||
|
assert.equal(paused[0].durationMs, 66_000);
|
||||||
|
});
|
||||||
|
|||||||
+63
-2
@@ -4,6 +4,7 @@ import { visibleWidth } from "@earendil-works/pi-tui";
|
|||||||
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
||||||
import { TaskState } from "../src/state.ts";
|
import { TaskState } from "../src/state.ts";
|
||||||
import {
|
import {
|
||||||
|
formatDuration,
|
||||||
renderProgressHeader,
|
renderProgressHeader,
|
||||||
renderTaskLine,
|
renderTaskLine,
|
||||||
renderToolResult,
|
renderToolResult,
|
||||||
@@ -23,7 +24,7 @@ function stripAnsi(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fixture(): TaskState {
|
function fixture(): TaskState {
|
||||||
const state = new TaskState();
|
const state = new TaskState(() => 0);
|
||||||
state.add([
|
state.add([
|
||||||
{
|
{
|
||||||
name: "Build auth",
|
name: "Build auth",
|
||||||
@@ -58,7 +59,7 @@ test("renders an adaptive progress bar and stays within width", () => {
|
|||||||
test("renders lightweight connectors without a detached root continuation", () => {
|
test("renders lightweight connectors without a detached root continuation", () => {
|
||||||
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
||||||
assert.deepEqual(output, [
|
assert.deepEqual(output, [
|
||||||
" ◉ 1. Build auth",
|
" ◉ 1. Build auth · 00:00",
|
||||||
" ├─ ! 2. Resolve token issue",
|
" ├─ ! 2. Resolve token issue",
|
||||||
" │ └─ ○ 3. Inspect provider response",
|
" │ └─ ○ 3. Inspect provider response",
|
||||||
" └─ ○ 4. Add middleware",
|
" └─ ○ 4. Add middleware",
|
||||||
@@ -66,6 +67,21 @@ test("renders lightweight connectors without a detached root continuation", () =
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("formats compact timers and adds hours only when needed", () => {
|
||||||
|
assert.equal(formatDuration(0), "00:00");
|
||||||
|
assert.equal(formatDuration(65_432), "01:05");
|
||||||
|
assert.equal(formatDuration(3_599_999), "59:59");
|
||||||
|
assert.equal(formatDuration(3_600_000), "01:00:00");
|
||||||
|
|
||||||
|
let now = 0;
|
||||||
|
const state = new TaskState(() => now);
|
||||||
|
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||||
|
now = 65_432;
|
||||||
|
const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24));
|
||||||
|
assert.match(line, / · 01:05$/);
|
||||||
|
assert.ok(visibleWidth(line) <= 24);
|
||||||
|
});
|
||||||
|
|
||||||
test("truncates every task row at narrow widths", () => {
|
test("truncates every task row at narrow widths", () => {
|
||||||
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
||||||
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
||||||
@@ -147,6 +163,51 @@ test("hides an empty widget and renders a populated one", () => {
|
|||||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("live widgets request rerenders and dispose their interval", async () => {
|
||||||
|
let now = 0;
|
||||||
|
const state = new TaskState(() => now);
|
||||||
|
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||||
|
|
||||||
|
let widget: unknown;
|
||||||
|
const ctx = {
|
||||||
|
ui: {
|
||||||
|
setWidget: (_id: string, content: unknown) => {
|
||||||
|
widget = content;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateTaskWidget(state, ctx as never, true, 5);
|
||||||
|
|
||||||
|
let renderRequests = 0;
|
||||||
|
const component = (
|
||||||
|
widget as unknown as (
|
||||||
|
tui: { requestRender(): void },
|
||||||
|
theme: unknown,
|
||||||
|
) => { render(width: number): string[]; dispose?(): void }
|
||||||
|
)(
|
||||||
|
{
|
||||||
|
requestRender() {
|
||||||
|
renderRequests += 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plainTheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 00:00$/);
|
||||||
|
now = 65_000;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
assert.ok(renderRequests > 0);
|
||||||
|
assert.match(stripAnsi(component.render(40)[1] ?? ""), / · 01:05$/);
|
||||||
|
} finally {
|
||||||
|
component.dispose?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestsAfterDispose = renderRequests;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||||
|
assert.equal(renderRequests, requestsAfterDispose);
|
||||||
|
});
|
||||||
|
|
||||||
test("tool results collapse summaries and expand themed details", () => {
|
test("tool results collapse summaries and expand themed details", () => {
|
||||||
const state = fixture();
|
const state = fixture();
|
||||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||||
|
|||||||
Reference in New Issue
Block a user