diff --git a/README.md b/README.md index f103952..12a54b1 100644 --- a/README.md +++ b/README.md @@ -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 - Atomic batch mutations: every requested change succeeds or none of it is applied - Four states: `pending`, `in-progress`, `blocked`, and `done` +- Cumulative active-time tracking that pauses outside `in-progress` and survives session restore - Session-branch persistence across reload, resume, fork, tree navigation, and context compaction - A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder - Compact 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. | | `rm_task` | `{ ids: [...], reason }` | Removes only mistaken, duplicate, or canceled tasks. Removing a parent cascades through its descendants. | | `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. | -| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent and child IDs. | +| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. | 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`. +A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601. + ## Pi Widget The widget appears above Pi's editor while tasks exist. Run `/tasks` to hide or show it for the current Pi runtime. ```text Tasks 2/6 · !1 ━━━━━──────── - ◉ 1. Build auth - ├─ ! 3. Resolve token issue + ◉ 1. Build auth · 12:08 + ├─ ! 3. Resolve token issue · 03:41 └─ ○ 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. diff --git a/package-lock.json b/package-lock.json index 3c12c5e..18578f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-todo-herdr", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-todo-herdr", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@earendil-works/pi-ai": "^0.81.1", diff --git a/package.json b/package.json index f66424b..9380e95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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.", "type": "module", "main": "./index.ts", diff --git a/src/state.ts b/src/state.ts index f748f4a..d4f0d39 100644 --- a/src/state.ts +++ b/src/state.ts @@ -6,12 +6,14 @@ import type { TaskSnapshot, TaskStats, TaskStatus, + TaskTimingSnapshot, TaskToolDetails, } from "./types.ts"; import { TaskValidationError } from "./types.ts"; const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]); const VALID_STATUSES = new Set(["pending", "in-progress", "blocked", "done"]); +const MAX_DATE_MS = 8_640_000_000_000_000; interface TaskRecord { id: number; @@ -19,6 +21,10 @@ interface TaskRecord { description?: string; status: TaskStatus; parentId: number | null; + startedAt?: number; + activeStartedAt?: number; + stoppedAt?: number; + accumulatedMs: number; } 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); } -function publicTasks(records: TaskRecord[]): Task[] { +function snapshotTasks(records: TaskRecord[]): 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), })); } +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[] { const result: number[] = []; const visit = (id: number) => { @@ -67,6 +134,38 @@ function validateGraph(records: TaskRecord[]): string[] { 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 (!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)) { errors.push(`task ${task.id}: parent ${task.parentId} does not exist`); } @@ -153,8 +252,17 @@ export class TaskState { private records: TaskRecord[] = []; 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[] { - 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[] { @@ -163,7 +271,12 @@ export class TaskState { } 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 { @@ -183,6 +296,7 @@ export class TaskState { const working = cloneRecords(this.records); const existingIds = new Set(working.map((task) => task.id)); + const now = this.currentTime(); let nextId = this.nextId; const addedIds: number[] = []; @@ -212,13 +326,16 @@ export class TaskState { } const id = nextId++; + const status = draft.status ?? "pending"; addedIds.push(id); working.push({ id, name: typeof draft.name === "string" ? draft.name.trim() : "", ...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}), - status: draft.status ?? "pending", + status, parentId, + accumulatedMs: 0, + ...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}), }); for (const [index, child] of (draft.children ?? []).entries()) { @@ -252,7 +369,7 @@ export class TaskState { }); 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); this.restore(replacement.snapshot()); return affected; @@ -268,6 +385,7 @@ export class TaskState { if (duplicateIds.length > 0) errors.push(`duplicate task patches: ${[...new Set(duplicateIds)].join(", ")}`); const working = cloneRecords(this.records); + const now = this.currentTime(); for (const [index, patch] of patches.entries()) { const path = `tasks[${index}]`; const task = working.find((candidate) => candidate.id === patch.id); @@ -296,7 +414,11 @@ export class TaskState { if (patch.name !== undefined) task.name = patch.name.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))) { task.parentId = patch.parentId; moveToSiblingEnd(working, task.id); @@ -325,7 +447,11 @@ export class TaskState { removedIds.add(id); 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)); return removed; } @@ -334,13 +460,56 @@ export class TaskState { if (!snapshot || !Array.isArray(snapshot.tasks) || !Number.isInteger(snapshot.nextId)) { 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 }) => ({ id, name, description, status, parentId, + accumulatedMs: 0, })); + const byId = indexRecords(records); + const timingErrors: string[] = []; + const timedIds = new Set(); + + 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 maxId = records.reduce((max, task) => Math.max(max, task.id), 0); if (snapshot.nextId <= maxId) errors.push(`nextId must be greater than ${maxId}`); diff --git a/src/types.ts b/src/types.ts index 33afc86..3609818 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,11 +7,23 @@ export interface Task { status: TaskStatus; parentId: number | null; childrenIds: number[]; + startedAt?: string; + stoppedAt?: string; + durationMs?: number; +} + +export interface TaskTimingSnapshot { + taskId: number; + startedAt: number; + activeStartedAt?: number; + stoppedAt?: number; + accumulatedMs: number; } export interface TaskSnapshot { tasks: Task[]; nextId: number; + timings?: TaskTimingSnapshot[]; } export interface AddTaskDraft { diff --git a/src/ui.ts b/src/ui.ts index 79c4189..df354bb 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -49,6 +49,16 @@ function styleStatusIcon(task: Task, theme: Theme): string { 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 { const line = theme.fg("dim", prefix) + @@ -57,7 +67,13 @@ export function renderTaskLine(task: Task, prefix: string, theme: Theme, width: theme.fg("accent", `${task.id}.`) + " " + 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[] { @@ -182,7 +198,7 @@ export function renderToolResult( return new TaskResultComponent((width) => { const lines = [theme.fg("success", "✓ ") + theme.fg("muted", summaryFor(details))]; if (!expanded || details.affected.length === 0) return lines; - if (details.action === "list") return [...lines, ...renderTree(details.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)]; return [...lines, ...renderFlatTasks(details.affected, theme, width)]; }); diff --git a/src/widget.ts b/src/widget.ts index 51b6c89..68bf5bb 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -3,23 +3,40 @@ import type { TaskState } from "./state.ts"; import { renderProgressHeader, renderTree } from "./ui.ts"; const WIDGET_ID = "pi-todo-herdr"; +const TIMER_REFRESH_MS = 1000; export function clearTaskWidget(ctx: ExtensionContext): void { 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(); if (!visible || tasks.length === 0) { clearTaskWidget(ctx); return; } - const stats = state.stats(); - ctx.ui.setWidget(WIDGET_ID, (_tui, theme) => ({ - render(width: number): string[] { - return [renderProgressHeader(stats, theme, width), ...renderTree(tasks, theme, width)]; - }, - invalidate(): void {}, - })); + const hasActiveTimer = tasks.some((task) => task.status === "in-progress" && task.startedAt !== undefined); + ctx.ui.setWidget(WIDGET_ID, (tui, theme) => { + const timer = hasActiveTimer + ? setInterval(() => tui.requestRender(), Math.max(1, refreshMs)) + : undefined; + 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); + }, + }; + }); } diff --git a/tests/state.test.ts b/tests/state.test.ts index c701fe4..10d797e 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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"]); }); +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", () => { const state = new TaskState(); state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index f487cf1..9c5a9c8 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -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].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); +}); diff --git a/tests/ui.test.ts b/tests/ui.test.ts index 5c2a1a5..11a5dec 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -4,6 +4,7 @@ import { visibleWidth } from "@earendil-works/pi-tui"; import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts"; import { TaskState } from "../src/state.ts"; import { + formatDuration, renderProgressHeader, renderTaskLine, renderToolResult, @@ -23,7 +24,7 @@ function stripAnsi(text: string): string { } function fixture(): TaskState { - const state = new TaskState(); + const state = new TaskState(() => 0); state.add([ { 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", () => { const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi); assert.deepEqual(output, [ - " ◉ 1. Build auth", + " ◉ 1. Build auth · 00:00", " ├─ ! 2. Resolve token issue", " │ └─ ○ 3. Inspect provider response", " └─ ○ 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", () => { const lines = renderTree(fixture().list(), plainTheme as never, 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)); }); +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", () => { const state = fixture(); const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };