diff --git a/.specs/features/agents-pane-timer/spec.md b/.specs/features/agents-pane-timer/spec.md new file mode 100644 index 0000000..ab09a57 --- /dev/null +++ b/.specs/features/agents-pane-timer/spec.md @@ -0,0 +1,95 @@ +# Agents pane: live elapsed timer + +## Goal + +Every worker card in the codedeck agents pane (`/band`, `plugin/mods/agents/`) +shows how long that worker has been running, and the number counts up on +screen once per second while the worker is live, the way Claude Code shows the +elapsed time of a running turn. + +Today the detail line reads `Working · 3m`, and that `3m` is the age of the +last update (`updatedAt`), not the run time, and it only moves when a +`tool.call` or `turn.complete` happens to refresh the pane. + +## Ground + +- `codedeck ps --all --json` already returns `createdAt` (ISO string) on every + row. Verified 2026-09-25 against the installed CLI. No daemon or CLI change + is needed. +- `formatPane` already reads `Date.now()` at draw time, so a redraw alone + advances a clock computed from `createdAt`. A tick does not need a + subprocess. +- Unknown and load bearing: whether a hooks module can schedule work on a + clock (`setInterval` / `setTimeout` in the module realm, or an engine timer + API). The realm has no `process` global (docs/mods.md), so timers are not a + safe assumption. Resolved by the probe in T1 before any code is written. + +## Acceptance criteria + +Elapsed text format (`elapsed(ms)`): + +- AC1. WHEN the elapsed time is under 60 seconds THEN the text SHALL be whole + seconds with an `s` suffix, e.g. `0s`, `42s`. +- AC2. WHEN the elapsed time is at least 60 seconds and under 1 hour THEN the + text SHALL be `m s` with seconds zero padded, e.g. `3m 07s`. +- AC3. WHEN the elapsed time is at least 1 hour THEN the text SHALL be + `h m s` with minutes and seconds zero padded, e.g. `1h 02m 07s`. +- AC4. IF the elapsed time is negative (clock skew) THEN the text SHALL be + `0s`. +- AC5. IF the start timestamp is missing or unparseable THEN the card SHALL + draw no elapsed text, and the rest of the card SHALL draw unchanged. + +Card content: + +- AC6. WHILE a worker is live (`working`, `starting`, `needs_input`) its card + detail line SHALL show `elapsed(now - createdAt)` in place of the + last-update age. +- AC7. WHEN a worker kept as a card is finished (any other status) THEN its + detail line SHALL show the frozen duration `elapsed(updatedAt - createdAt)`. +- AC8. The history preview lines SHALL keep their current last-update age + text. Unchanged behavior. +- AC9. `selectPane` SHALL carry `createdAt` from the session row onto the pane + row, and a non-string `createdAt` SHALL become undefined. +- AC10. Every pane line SHALL stay exactly `columns` code units wide with the + timer present, at every width the existing width tests cover. + +Tick: + +- AC11. WHILE the pane is open and the snapshot holds at least one live row, + the module SHALL invalidate `ui.render` once per second. +- AC12. WHEN the pane closes or the snapshot holds no live row THEN the tick + SHALL stop, and no timer SHALL stay scheduled. +- AC13. WHILE ticking, the module SHALL request at most one data `refresh` + (the existing helper, which runs `codedeck ps`) per 5 seconds; the 1 second + redraws in between SHALL NOT refresh. A worker that finishes while the + orchestrator is idle stops counting within about 5 seconds. +- AC14. Starting the tick twice SHALL NOT schedule two timers. +- AC15. A throw inside a tick SHALL be caught; it SHALL NOT escape the module. + +## Decisions (made by the orchestrator, cheap to reverse) + +1. Format with zero padding (`3m 07s`) so the text width stays stable while it + counts, which keeps the card from jittering once a second. +2. The timer replaces the last-update age on cards only. History keeps age. +3. The 5 second refresh while ticking overrides decision 5 of + `orchestrator-agents-band/spec.md` ("no timer") for the ticking window + only. Reason: a live timer on a stale status counts past the worker's end, + which is a wrong number on screen. +4. The button above the prompt is unchanged. + +## Known limitations + +- A finished card's frozen duration is `updatedAt - createdAt`, and the store + also bumps `updatedAt` on non-terminal updates such as `session.rename`. A + worker renamed after it finished shows a duration that includes the idle + time. `ps` exposes no end timestamp; fixing this needs a daemon or CLI + field, which is out of scope here. +- If `codedeck ps` keeps failing while the pane is open, the last snapshot + stays live and the timer keeps counting, with one refresh attempt per 5 s. + +## Out of scope + +- Daemon, store, CLI and `codedeck web` changes. +- Timers in the statusline or the button label. +- Per-turn timing, token counts, or any new card field besides elapsed time. +- Fixing the native `✕` close desync documented in docs/mods.md. diff --git a/.specs/features/agents-pane-timer/tasks.md b/.specs/features/agents-pane-timer/tasks.md new file mode 100644 index 0000000..52205d5 --- /dev/null +++ b/.specs/features/agents-pane-timer/tasks.md @@ -0,0 +1,45 @@ +# Tasks: agents pane live timer + +Source of truth: `spec.md` in this folder. + +## Coverage matrix + +| Layer | Test type | Lives in | Command | +|---|---|---|---| +| `plugin/mods/agents/pane.ts` (elapsed, cards, select) | unit | `tests/mods-agents/pane.test.ts` | `npx vitest run tests/mods-agents` | +| `plugin/mods/agents/types.ts` | none (types only) | n/a | `npx tsc --noEmit -p .` if it covers plugin, else the vitest run | +| `plugin/hooks/pane-ticker.ts` (tick scheduler) | unit, fake timers | `tests/mods-agents/pane-ticker.test.ts` | `npx vitest run tests/mods-agents` | +| `plugin/hooks/register.tsx` | none, wiring only | n/a | live PTY probe (T3) | +| manifest | contract | n/a | `claude plugin validate plugin/` | + +`register.tsx` stays wiring only. Any decision (when to tick, when to stop, +refresh cadence) lives in `pane-ticker.ts` where it is unit tested. + +## T1. Probe: can a hooks module run a clock + +- Status: complete + +- Requirement: spec "Ground", unknown item. Blocks T2 tick work. +- In a live PTY session (tmux + `claude --plugin-dir ` with + `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`), check whether `setInterval` / + `setTimeout` exist and fire inside the hooks module realm, and whether a + timer callback may call `$.ui.invalidate("ui.render")` with a `$` captured + from an earlier hook. +- Tests: none, finding only. Record the result in `docs/mods.md`. +- Gate: a capture showing a value that changed across ticks with no input. + +## T2. Pure layer: elapsed + cards + ticker + +- Status: complete + +- Requirement: AC1 to AC15. +- Tests: in this task, same files as the matrix. +- Gate: `npx vitest run tests/mods-agents` green. + +## T3. Wiring + live proof + +- Status: complete + +- Requirement: AC6, AC11, AC12. +- Gate: two tmux captures of the open pane at least 2 seconds apart, the + elapsed text of a live card advanced between them, no `hook skipped` text. diff --git a/docs/mods.md b/docs/mods.md index 6912f23..645084d 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -230,6 +230,30 @@ PTY session, a `Client` surface that seeded its state during render did not keep state across renders. That was observed. Why is a grounded hypothesis, the render-time seeding, not a proven fact. +### Timer primitives in a hooks module + +Probed on 2026-09-25 in Claude Code 2.1.282, in a live tmux PTY with +`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. Both `setTimeout` and `setInterval` +were functions in the module realm. A one-shot timeout and a one-second +interval changed the rendered button without input. Their callbacks called +`$.ui.invalidate("ui.render")` through `$` captured earlier in `session.start`. +The invalidation count advanced from 12 to 15 over three seconds, with no +caught errors and no `hook skipped` or `refused` text. + +The button labels from the two captures, with terminal padding omitted, were: + +``` +[ 1 working  [probe function/function t1 i11 c12 e0] ] +[ 1 working  [probe function/function t1 i14 c15 e0] ] +``` + +Here `t` counts timeout callbacks, `i` interval callbacks, `c` successful +captured-handle invalidations, and `e` caught errors. This proves timer +callbacks can invalidate a render with a `$` captured by an earlier hook. +The one-second callback invalidates the UI; a separate refresh request runs no +more than once every five seconds and uses the existing refresh helper, which +may execute `codedeck ps`. + ### process.run `$.process.run(argv, init?)`. **Both arguments are positional**, and this is diff --git a/plugin/hooks/pane-ticker.ts b/plugin/hooks/pane-ticker.ts new file mode 100644 index 0000000..3c65289 --- /dev/null +++ b/plugin/hooks/pane-ticker.ts @@ -0,0 +1,102 @@ +import type { PaneSnapshot } from "../mods/agents/types.js"; + +const TICK_INTERVAL_MS = 1000; +const REFRESH_INTERVAL_MS = 5000; +const LIVE_STATUSES = new Set(["working", "starting", "needs_input"]); + +export interface PaneTickerOptions { + now: () => number; + setInterval: (callback: () => void, milliseconds: number) => unknown; + clearInterval: (handle: unknown) => void; + invalidate: () => unknown; + refresh: () => unknown; +} + +export interface PaneTicker { + update(paneOpen: boolean, snapshot: PaneSnapshot | undefined): void; + refreshed(): void; +} + +/** Schedules pane redraws and refresh requests while any worker card is live. */ +export function createPaneTicker(options: PaneTickerOptions): PaneTicker { + let running = false; + let handle: unknown; + let lastRefreshAt = 0; + + const invoke = (action: () => unknown) => { + try { + void Promise.resolve(action()).catch(() => {}); + } catch { + // Hook failures drop the drawing, so timer work must stay contained. + } + }; + + const tick = () => { + if (!running) return; + try { + invoke(options.invalidate); + const now = options.now(); + if (!Number.isFinite(now) || now - lastRefreshAt < REFRESH_INTERVAL_MS) return; + lastRefreshAt = now; + invoke(options.refresh); + } catch { + // A failed timer callback must not escape into the hooks runtime. + } + }; + + const stop = () => { + if (!running) return; + running = false; + const currentHandle = handle; + handle = undefined; + try { + options.clearInterval(currentHandle); + } catch { + // Keep the module alive even if the timer host refuses a clear. + } + }; + + const start = () => { + if (running) return; + try { + const now = options.now(); + if (!Number.isFinite(now)) return; + lastRefreshAt = now; + running = true; + handle = options.setInterval(tick, TICK_INTERVAL_MS); + } catch { + running = false; + handle = undefined; + } + }; + + const hasLiveRow = (snapshot: PaneSnapshot | undefined): boolean => { + try { + return ( + snapshot !== undefined && + Array.isArray(snapshot.rows) && + snapshot.rows.some((row) => row != null && LIVE_STATUSES.has(row.status)) + ); + } catch { + return false; + } + }; + + return { + update(paneOpen, snapshot) { + if (paneOpen && hasLiveRow(snapshot)) { + start(); + return; + } + stop(); + }, + refreshed() { + try { + const now = options.now(); + if (Number.isFinite(now)) lastRefreshAt = now; + } catch { + // Keep the prior cadence if the injected clock fails. + } + }, + }; +} diff --git a/plugin/hooks/register.tsx b/plugin/hooks/register.tsx index ec07da5..e8294c1 100644 --- a/plugin/hooks/register.tsx +++ b/plugin/hooks/register.tsx @@ -7,6 +7,7 @@ import type { Register } from "claude-code"; import { formatPane, paneButtonLabel, selectPane } from "../mods/agents/pane.js"; import { parseRows } from "../mods/agents/parse.js"; import type { PaneSnapshot } from "../mods/agents/types.js"; +import { createPaneTicker, type PaneTicker } from "./pane-ticker.js"; import { togglePane } from "./pane-toggle.js"; // Stable pane id: 1 to 64 letters, digits, "_" or "-". open carries it into @@ -51,14 +52,17 @@ const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise = // Passing $ into a helper is allowed, verified. What the engine refuses is // pulling a namespace off it: `const P = $.process` fails to load the module. -// So refresh takes $ as a parameter and the state register owns travels beside -// it in this object. paneOpen never leaves register, so it stays a bare let. +// refresh takes $ as a parameter; its state and paneOpen stay in this register. type PaneState = { // Last good snapshot; undefined until one refresh has fully succeeded, which // is how the pane draws nothing rather than a guess. snapshot: PaneSnapshot | undefined; inFlight: boolean; lastRefreshEndedAt: number; + ticker: PaneTicker | undefined; + paneOpen: boolean; + tickInvalidate: () => unknown; + tickRefresh: () => unknown; // True once session.start saw a CODEDECK_RUN_ID. Cached because ui.render // fires on every drawing pass and must not await an env read to decide // whether to draw one button. @@ -67,6 +71,13 @@ type PaneState = { const TOOL_REFRESH_GAP_MS = 1500; +function syncTicker($: Engine$, state: PaneState, refreshCompleted = false): void { + state.tickInvalidate = () => $.ui.invalidate("ui.render"); + state.tickRefresh = () => refresh($, state); + if (refreshCompleted) state.ticker?.refreshed(); + state.ticker?.update(state.paneOpen, state.snapshot); +} + const refresh = async ($: Engine$, state: PaneState): Promise => { // Claim the slot before the first await. Guard and set must not straddle a // yield point: with the `$.env.get` read in between, two tool.call firings @@ -103,6 +114,7 @@ const refresh = async ($: Engine$, state: PaneState): Promise => { } finally { state.inFlight = false; state.lastRefreshEndedAt = Date.now(); + syncTicker($, state, true); } }; @@ -112,10 +124,21 @@ export const register: Register = (on) => { inFlight: false, lastRefreshEndedAt: 0, hasRun: false, + ticker: undefined, + paneOpen: false, + tickInvalidate: () => undefined, + tickRefresh: () => undefined, }; - let paneOpen = false; + state.ticker = createPaneTicker({ + now: () => Date.now(), + setInterval: (callback, milliseconds) => setInterval(callback, milliseconds), + clearInterval: (handle) => clearInterval(handle as ReturnType), + invalidate: () => state.tickInvalidate(), + refresh: () => state.tickRefresh(), + }); on("session.start", async ($, e, next) => { + syncTicker($, state); // Not "agents": the engine refuses it with `$.command.register: "/agents" // refused: it is the built-in /agents`. "band", "deck" and // "codedeck-agents" were each verified free. The description is load @@ -145,10 +168,11 @@ export const register: Register = (on) => { // is the one call here not yet verified in a PTY session, paneOpen stays // true and the next /band retries the close, instead of the flag and the // pane desyncing for the rest of the session. - paneOpen = await toggleAgentsPane($, paneOpen); + state.paneOpen = await toggleAgentsPane($, state.paneOpen); + syncTicker($, state); await $.ui.invalidate("ui.render"); - if (paneOpen) void refresh($, state); - return { text: paneOpen ? "agents pane open" : "agents pane closed" }; + if (state.paneOpen) void refresh($, state); + return { text: state.paneOpen ? "agents pane open" : "agents pane closed" }; }); on("turn.complete", async ($, e, next) => { @@ -172,9 +196,10 @@ export const register: Register = (on) => { // A $ captured from a past render does work, verified, but it outlives the // event it came from and nothing promises how long. if ((e as { element?: string }).element === BUTTON_KEY) { - paneOpen = await toggleAgentsPane($, paneOpen); + state.paneOpen = await toggleAgentsPane($, state.paneOpen); + syncTicker($, state); await $.ui.invalidate("ui.render"); - if (paneOpen) void refresh($, state); + if (state.paneOpen) void refresh($, state); } return await next(e); }); diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index e190ea9..f6b6664 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -114,6 +114,7 @@ function toPaneRow(row: SessionRow, workers: ReadonlySet): PaneRow { effort: text(row.effort) || undefined, name: row.name || EMPTY_CELL, updatedAt: iso, + createdAt: typeof row.createdAt === "string" ? row.createdAt : undefined, parentId: parent !== "" && parent !== row.id && workers.has(parent) ? parent : undefined, role: text(row.role) || undefined, }; @@ -221,6 +222,32 @@ function age(iso: unknown, now: number): string { return `${Math.floor(minutes / 60)}h`; } +/** Stable elapsed-time text for a card detail line. */ +export function elapsed(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return "0s"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const paddedSeconds = String(seconds % 60).padStart(2, "0"); + if (minutes < 60) return `${minutes}m ${paddedSeconds}s`; + return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m ${paddedSeconds}s`; +} + +function cardElapsed(row: Partial, now: number): string { + if (typeof row.createdAt !== "string") return ""; + const createdAt = Date.parse(row.createdAt); + if (!Number.isFinite(createdAt)) return ""; + + const live = LIVE.has(row.status ?? "") || WAIT.has(row.status ?? ""); + const endAt = live + ? now + : typeof row.updatedAt === "string" + ? Date.parse(row.updatedAt) + : Number.NaN; + if (!Number.isFinite(endAt)) return ""; + return elapsed(endAt - createdAt); +} + function count(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0; } @@ -348,7 +375,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined return [ ` ${glyph(row.agent)} ${cell(row.id)} ${role ? `${role} · ` : ""}${harnessLabel(row.agent)}`, ` ${cell(row.name)}`, - detailLine(row.model, row.effort, statusWord(status), age(row.updatedAt, now), inner), + detailLine(row.model, row.effort, statusWord(status), cardElapsed(row, now), inner), ]; }; diff --git a/plugin/mods/agents/types.ts b/plugin/mods/agents/types.ts index fc0a2d7..82fbe14 100644 --- a/plugin/mods/agents/types.ts +++ b/plugin/mods/agents/types.ts @@ -20,6 +20,7 @@ export interface SessionRow { effort?: string; status?: string; updatedAt?: string; + createdAt?: string; } /** @@ -35,6 +36,7 @@ export interface PaneRow { effort?: string; name: string; updatedAt?: string; + createdAt?: string; /** * Id of the card this one hangs from, or undefined when it hangs from the * run root. A parent outside the run (or a legacy row with none) is folded diff --git a/tests/mods-agents/pane-ticker.test.ts b/tests/mods-agents/pane-ticker.test.ts new file mode 100644 index 0000000..25244a7 --- /dev/null +++ b/tests/mods-agents/pane-ticker.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createPaneTicker } from "../../plugin/hooks/pane-ticker.js"; +import type { PaneSnapshot } from "../../plugin/mods/agents/types.js"; + +afterEach(() => vi.useRealTimers()); + +function snapshot(status: string): PaneSnapshot { + return { + runId: "f5fd", + orchestrator: undefined, + rows: [{ id: "worker", status, agent: "claude", name: "worker" }], + hidden: 0, + total: 1, + }; +} + +function harness(overrides: Partial[0]> = {}) { + const setInterval = vi.fn((callback: () => void, delay: number) => + globalThis.setInterval(callback, delay), + ); + const clearInterval = vi.fn((handle: ReturnType) => + globalThis.clearInterval(handle), + ); + const invalidate = vi.fn(); + const refresh = vi.fn(); + const ticker = createPaneTicker({ + now: () => Date.now(), + setInterval, + clearInterval, + invalidate, + refresh, + ...overrides, + }); + return { ticker, setInterval, clearInterval, invalidate, refresh }; +} + +describe("createPaneTicker", () => { + it("starts one timer only for an open pane with a live row and stops it on close or completion", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + + h.ticker.update(false, snapshot("working")); + h.ticker.update(true, snapshot("completed")); + expect(h.setInterval).not.toHaveBeenCalled(); + + h.ticker.update(true, snapshot("needs_input")); + h.ticker.update(true, snapshot("working")); + expect(h.setInterval).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1000); + expect(h.invalidate).toHaveBeenCalledTimes(1); + + h.ticker.update(true, snapshot("completed")); + expect(h.clearInterval).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + h.ticker.update(true, snapshot("working")); + expect(h.setInterval).toHaveBeenCalledTimes(2); + h.ticker.update(false, snapshot("working")); + expect(h.clearInterval).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + + await vi.advanceTimersByTimeAsync(2000); + expect(h.invalidate).toHaveBeenCalledTimes(1); + }); + + it("invalidates every second and requests refresh no more than every five seconds", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + h.ticker.update(true, snapshot("starting")); + + await vi.advanceTimersByTimeAsync(4999); + expect(h.invalidate).toHaveBeenCalledTimes(4); + expect(h.refresh).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(h.invalidate).toHaveBeenCalledTimes(5); + expect(h.refresh).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(4999); + expect(h.invalidate).toHaveBeenCalledTimes(9); + expect(h.refresh).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(1); + expect(h.invalidate).toHaveBeenCalledTimes(10); + expect(h.refresh).toHaveBeenCalledTimes(2); + }); + + it("waits five seconds after another refresh completes before requesting data again", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const h = harness(); + h.ticker.update(true, snapshot("working")); + + await vi.advanceTimersByTimeAsync(4000); + h.ticker.refreshed(); + await vi.advanceTimersByTimeAsync(4999); + expect(h.refresh).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(h.refresh).toHaveBeenCalledTimes(1); + }); + + it("catches synchronous tick errors and rejected refreshes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:00.000Z")); + const invalidate = vi.fn(() => { + throw new Error("invalidate failed"); + }); + const refresh = vi.fn(() => Promise.reject(new Error("refresh failed"))); + const h = harness({ invalidate, refresh }); + h.ticker.update(true, snapshot("working")); + + await vi.advanceTimersByTimeAsync(5000); + expect(invalidate).toHaveBeenCalledTimes(5); + expect(refresh).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/mods-agents/pane.test.ts b/tests/mods-agents/pane.test.ts index 487d28f..ba01261 100644 --- a/tests/mods-agents/pane.test.ts +++ b/tests/mods-agents/pane.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { MAX_CARD_COLUMNS, MIN_COLUMNS, + elapsed, formatPane, paneButtonLabel, selectPane, @@ -37,6 +38,8 @@ const snapshot = (overrides: Partial = {}): PaneSnapshot => ({ const ids = (snap: PaneSnapshot) => snap.rows.map((row) => row.id); +afterEach(() => vi.useRealTimers()); + // The stem connector line at 89 columns: the card box caps at 56, so the // stem sits at column 31 inside a frame row padded to the full width. Exact // literal, so a stray or duplicated stem is caught by string identity. @@ -53,6 +56,23 @@ function expectCleanWidth(lines: string[], columns: number): void { } } +describe("elapsed", () => { + it("formats seconds, minutes, and hours with stable zero padding", () => { + expect(elapsed(0)).toBe("0s"); + expect(elapsed(42_999)).toBe("42s"); + expect(elapsed(59_999)).toBe("59s"); + expect(elapsed(60_000)).toBe("1m 00s"); + expect(elapsed(3 * 60_000 + 7_000)).toBe("3m 07s"); + expect(elapsed(60 * 60_000 + 2 * 60_000 + 7_000)).toBe("1h 02m 07s"); + }); + + it("clamps negative and non-finite durations to zero", () => { + expect(elapsed(-1)).toBe("0s"); + expect(elapsed(Number.NaN)).toBe("0s"); + expect(elapsed(Number.POSITIVE_INFINITY)).toBe("0s"); + }); +}); + // The real-screen fixture from the bug report: a run of 19 workers, one // working and 18 finished, in a pane 89 columns wide and 44 rows tall. Built // through selectPane so the snapshot is what the daemon pipeline produces. @@ -143,6 +163,20 @@ describe("selectPane", () => { expect(none.hidden).toBe(2); }); + it("carries string createdAt values and drops non-strings", () => { + const snap = selectPane( + [ + session({ id: "valid", createdAt: "2026-09-19T12:00:00.000Z" }), + session({ id: "invalid", createdAt: 123 as unknown as string }), + ], + RUN, + ); + expect(snap.rows.map(({ id, createdAt }) => [id, createdAt])).toEqual([ + ["invalid", undefined], + ["valid", "2026-09-19T12:00:00.000Z"], + ]); + }); + it("resolves a missing agent, name or status to a placeholder (rule 13)", () => { const snap = selectPane( [session({ id: "bare", status: undefined, agent: "", name: undefined })], @@ -159,6 +193,86 @@ describe("selectPane", () => { }); describe("formatPane", () => { + it("uses elapsed time on cards and keeps last-update age in history", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const lines = formatPane( + selectPane( + [ + session({ + id: "finished-parent", + status: "completed", + createdAt: "2026-09-19T11:59:40.000Z", + updatedAt: "2026-09-19T12:00:00.000Z", + name: "finished", + }), + session({ + id: "live", + parentId: "finished-parent", + status: "working", + createdAt: "2026-09-19T12:00:00.000Z", + updatedAt: "2026-09-19T12:00:41.000Z", + name: "worker", + }), + session({ + id: "history", + status: "completed", + createdAt: "2026-09-19T11:00:00.000Z", + updatedAt: "2026-09-19T11:59:42.000Z", + name: "history", + }), + ], + RUN, + ), + 89, + ); + const joined = lines.join("\n"); + expect(joined).toContain("Completed · 20s"); + expect(joined).toContain("Working · 42s"); + expect(joined).toContain("history · 1m"); + expect(joined).not.toContain("history · 1h"); + }); + + it("leaves cards without a valid createdAt timestamp otherwise unchanged", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const lines = formatPane( + snapshot({ + rows: [ + { id: "missing", status: "working", agent: "claude", name: "missing", updatedAt: "2026-09-19T12:00:00.000Z" }, + { id: "unparseable", status: "needs_input", agent: "claude", name: "bad", createdAt: "yesterday-ish" }, + ], + }), + 89, + ); + const joined = lines.join("\n"); + expect(joined).toContain("missing"); + expect(joined).toContain("unparseable"); + expect(joined).toContain("Working"); + expect(joined).toContain("Waiting for you"); + expect(joined).not.toMatch(/(?:Working|Waiting for you) · (?:\d+s|\d+m)/); + }); + + it("keeps every line at the requested width when a card has elapsed text", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-19T12:00:42.000Z")); + const snap = snapshot({ + rows: [ + { + id: "timed", + status: "working", + agent: "claude", + name: "timer worker", + createdAt: "2026-09-15T08:00:42.000Z", + }, + ], + }); + expect(formatPane(snap, 120).join("\n")).toContain("Working · 100h 00m 00s"); + for (const columns of [MIN_COLUMNS, 40, 89, 120]) { + expectCleanWidth(formatPane(snap, columns), columns); + } + }); + it("never exceeds the given rows, at every width and every input (rule 1)", () => { const snaps = [snapshot(), workerFixture(), snapshot({ rows: [], hidden: 7, total: 7 })]; for (const snap of snaps) {