diff --git a/changelog/unreleased/2026-07-28-duration-and-byte-formatting.md b/changelog/unreleased/2026-07-28-duration-and-byte-formatting.md new file mode 100644 index 0000000..50603f4 --- /dev/null +++ b/changelog/unreleased/2026-07-28-duration-and-byte-formatting.md @@ -0,0 +1,11 @@ +# Web App and CLI: duration and byte abbreviations carry into the next unit + +Two display helpers chose a unit — or split a value into minutes and seconds — from the raw input while printing a rounded one, so a duration could read `1m60s` and a file size `1024KB`. Both now round before deciding what to print. + +## Durations no longer print a 60-second remainder + +`humanizeDuration` floored the minutes but rounded the seconds remainder, two opposite rounding directions applied to one split. A remainder in `[59.5, 60)` therefore rounded up to 60 without the minute following it: 119.7s rendered as `1m60s` instead of `2m0s`, and 3599.7s as `59m60s`. Compact mode had the same mismatch one unit down, picking the sub-minute branch from the unrounded value and then printing `Math.round(59.6)` as `60s` rather than `1m0s`. Every whole-second form now rounds the total first and splits that integer, so the minutes and the remainder always come from the same number; sub-minute values outside compact mode keep their tenths and are unaffected. The fix lands in both copies — `packages/web/src/lib/format.ts` and the CLI's `packages/cli/src/render.ts`, whose abbreviations are deliberately identical — so the Web App and the `[stats]` line cannot drift apart. Visible wherever a settled duration is shown: benchmark evaluation, case and run timings, Trace turn durations, tool-call cards, the task statistics footer, and the agent topology view. + +## Byte counts no longer print an out-of-range unit + +`formatBytes` selected the unit by comparing the raw byte count against the magnitude thresholds, but rendered the value through the shared one-decimal rounding. A count just under a boundary was rounded past it after the unit had already been fixed, so 1048570 bytes printed as `1024KB` instead of `1MB`, and the MB/GB boundary behaved the same way. The unit is now chosen from the rounded value. Visible on Trace file sizes and in the workspace file browser. diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md index afe1310..733427e 100644 --- a/changelog/unreleased/README.md +++ b/changelog/unreleased/README.md @@ -2,4 +2,5 @@ Changes since v0.1.4. The version number is assigned at release, when this folder is renamed. +- [2026-07-28] Web App and CLI: duration and byte abbreviations now carry into the next unit instead of printing `1m60s` or `1024KB` — both helpers rounded the value they displayed but chose the unit (or the minute/second split) from the raw input. ([details](2026-07-28-duration-and-byte-formatting.md)) - [2026-07-29] Tooling: Agent-spawned commands no longer inherit PenguinHarness-owned server variables such as `PORT` / `HOST`, while the development backend moves off the installed server's default port and the Web dev proxy follows that backend. ([details](2026-07-29-harness-env-and-dev-ports.md)) diff --git a/packages/cli/src/render.ts b/packages/cli/src/render.ts index 66715c2..9057778 100644 --- a/packages/cli/src/render.ts +++ b/packages/cli/src/render.ts @@ -134,8 +134,10 @@ function humanizeDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; if (s < 60) return `${trimZero(s)}s`; - const m = Math.floor(s / 60); - return `${m}m${Math.round(s % 60)}s`; + // The minute form rounds the total before splitting it: rounding the remainder while + // flooring the minutes lets 119.7s print as `1m60s` instead of `2m0s`. + const whole = Math.round(s); + return `${Math.floor(whole / 60)}m${whole % 60}s`; } export function formatAbort(p: AbortPayload, t: Messages): string { diff --git a/packages/cli/test/render.test.ts b/packages/cli/test/render.test.ts index 07c7092..a35dbd6 100644 --- a/packages/cli/test/render.test.ts +++ b/packages/cli/test/render.test.ts @@ -396,6 +396,23 @@ describe("StreamRenderer", () => { expect(last).toBe("[stats] context 7k (+3k) · tokens 11k (+7k) · 5s (+3s)"); }); + it("an elapsed remainder that rounds to 60s carries into the minute", () => { + const { stream, text } = collector(); + const r = new StreamRenderer(stream, t); + r.handle( + tokenUsage( + { cache_read: 0, cache_write: 0, output: 0, total: 4000 }, + { cache_read: 0, cache_write: 0, output: 0, total: 4000 }, + ), + ); + // 119.7s: rounding the remainder against floored minutes would read 1m60s. + r.endTask(119_700); + const lines = stripAnsi(text()).trim().split("\n"); + expect(lines[lines.length - 1]).toBe( + "[stats] context 4k (+4k) · tokens 4k (+4k) · 2m0s (+2m0s)", + ); + }); + it("context delta goes negative after compaction shrinks the context (no clamping)", () => { const { stream, text } = collector(); const r = new StreamRenderer(stream, t); diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index 176c41b..6bef1e9 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -31,9 +31,12 @@ export function humanizeTokens(n: number): string { export function humanizeDuration(ms: number, opts?: { compact?: boolean }): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; - if (s < 60) return opts?.compact && s >= 10 ? `${Math.round(s)}s` : `${trimZero(s)}s`; - const m = Math.floor(s / 60); - return `${m}m${Math.round(s % 60)}s`; + if (s < 60 && !(opts?.compact && s >= 10)) return `${trimZero(s)}s`; + // Every whole-second form rounds the total before splitting it: rounding the remainder + // while flooring the minutes lets 119.7s print as `1m60s` instead of `2m0s`. + const whole = Math.round(s); + if (whole < 60) return `${whole}s`; + return `${Math.floor(whole / 60)}m${whole % 60}s`; } /** @@ -128,12 +131,21 @@ export function formatScore(n: number): string { return Number.isInteger(n) ? `${n}` : trimZero(n); } +/** True while the one-decimal rendering of `v` still fits its unit: 1023.9 does, 1023.99 does not. */ +function fitsUnit(v: number): boolean { + return Number(v.toFixed(1)) < 1024; +} + /** Abbreviate a byte count: `812B`, `3.4KB`, `1.2MB`. */ export function formatBytes(n: number): string { if (n < 1024) return `${n}B`; - if (n < 1024 * 1024) return `${trimZero(n / 1024)}KB`; - if (n < 1024 * 1024 * 1024) return `${trimZero(n / (1024 * 1024))}MB`; - return `${trimZero(n / (1024 * 1024 * 1024))}GB`; + // The unit is picked from the rounded value, not the raw one: 1048570 renders as `1024.0` + // KB at one decimal, which has to carry into `1MB` rather than print an out-of-range unit. + const kb = n / 1024; + if (fitsUnit(kb)) return `${trimZero(kb)}KB`; + const mb = kb / 1024; + if (fitsUnit(mb)) return `${trimZero(mb)}MB`; + return `${trimZero(mb / 1024)}GB`; } function pad2(n: number): string { diff --git a/packages/web/test/format.test.ts b/packages/web/test/format.test.ts index 2e36513..a79d14d 100644 --- a/packages/web/test/format.test.ts +++ b/packages/web/test/format.test.ts @@ -57,6 +57,22 @@ describe("humanizeDuration", () => { expect(humanizeDuration(820, { compact: true })).toBe("820ms"); expect(humanizeDuration(63000, { compact: true })).toBe("1m3s"); }); + + it("a seconds remainder that rounds to 60 carries into the minute", () => { + // Rounding the remainder against floored minutes would read 1m60s / 59m60s. + expect(humanizeDuration(119_500)).toBe("2m0s"); + expect(humanizeDuration(119_700)).toBe("2m0s"); + expect(humanizeDuration(179_600)).toBe("3m0s"); + expect(humanizeDuration(3_599_700)).toBe("60m0s"); + // Below the carry the remainder still rounds normally. + expect(humanizeDuration(119_400)).toBe("1m59s"); + }); + + it("compact promotes a sub-minute value that rounds to 60s into the minute form", () => { + expect(humanizeDuration(59_600, { compact: true })).toBe("1m0s"); + // Without compact the tenths are kept, so there is nothing to carry. + expect(humanizeDuration(59_600)).toBe("59.6s"); + }); }); describe("signedDelta", () => { @@ -139,6 +155,14 @@ describe("formatBytes", () => { expect(formatBytes(3481)).toBe("3.4KB"); expect(formatBytes(2 * 1024 * 1024)).toBe("2MB"); }); + + it("a value that rounds up to 1024 carries into the next unit", () => { + // Picking the unit from the raw value would print 1024KB / 1024MB. + expect(formatBytes(1024 * 1024 - 6)).toBe("1MB"); + expect(formatBytes(1024 * 1024 * 1024 - 800)).toBe("1GB"); + // Just below the carry the unit is unchanged. + expect(formatBytes(1024 * 1024 - 60)).toBe("1023.9KB"); + }); }); describe("computeTps", () => {