diff --git a/.gitignore b/.gitignore index 975a5a1..b1a6aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ tmp/ .DS_Store coverage/ .acp-*.json +worktrees/ diff --git a/README.md b/README.md index 8f3d5f9..31ebd68 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,32 @@ describes what the model originally saw. Nudge/pressure *decisions* are unaffected: they recount live text every turn. If you need the legacy live-recomputed tags, use `renderVisibleRefs` directly. +#### `buildStatusReport` drilldown — reaching the newest rows + +The per-message drilldown (`scope:"uncompressed"` + `view:"messages"`) sorts +`time` ascending by default and shows only the first `limit` rows, so the +newest messages are unreachable once the conversation outgrows the limit. +Two optional navigation options fix that: `reverse` (default `false`) flips +the sorted order after sorting — `sort:"time"` + `reverse:true` lists +newest-first, `sort:"size"` + `reverse:true` lists smallest-first — and +`offset` (default `0`) skips the first N rows, paginating with `limit`. + +```ts +buildStatusReport(state, messages, countTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, // newest message first — reach the tail without a ref tag + limit: 30, +}); +// pagination: offset: 30 → the next page +``` + +`reverse` and `offset` are optional, so existing call sites keep working +unchanged. The "N of M shown." footer stays for `offset: 0`; when +`offset > 0` it reads "Showing A–B of M.", and an offset past the end reports +"Offset N past end" instead of a silent empty page. + ### Standalone modules | Module | Purpose | diff --git a/src/report.ts b/src/report.ts index cc53e23..68726c8 100644 --- a/src/report.ts +++ b/src/report.ts @@ -95,6 +95,10 @@ export interface StatusReportOptions { tool?: string; sort?: "size" | "time" | "tool" | "age"; limit?: number; + /** Flip the sorted order (newest/largest first). Default: false. */ + reverse?: boolean; + /** Skip the first N rows of the sorted order (pagination). Default: 0. */ + offset?: number; } export function buildStatusReport( @@ -108,20 +112,22 @@ export function buildStatusReport( const toolFilter = options.tool; const sort = options.sort ?? "size"; const limit = options.limit ?? 30; + const reverse = options.reverse ?? false; + const offset = Math.max(0, options.offset ?? 0); const activeBlocks = state.blocks .filter((b) => b.active) .sort((a, b) => numericPart(a.blockId) - numericPart(b.blockId)); if (scope === "compressed") { - return renderCompressedDrilldown(activeBlocks, state, sort, limit, countTokens); + return renderCompressedDrilldown(activeBlocks, state, sort, limit, countTokens, reverse, offset); } const { visible, summaryTokens } = collectVisible(messages, state, countTokens); if (scope === "uncompressed") { if (view === "messages") { - return renderMessageDrilldown(visible, toolFilter, sort, limit); + return renderMessageDrilldown(visible, toolFilter, sort, limit, reverse, offset); } return renderUncompressedRanges(visible); } @@ -196,7 +202,7 @@ function renderOverview( lines.push(""); lines.push( - `Tip: buildStatusReport({scope:"uncompressed", view:"messages", tool:"${topTool ?? "bash"}"}) for per-message listing`, + `Tip: buildStatusReport({scope:"uncompressed", view:"messages", sort:"time", reverse:true, tool:"${topTool ?? "bash"}"}) for newest-first per-message listing`, ); return lines.join("\n"); } @@ -240,11 +246,26 @@ function renderUncompressedRanges(visible: VisibleMessageInfo[]): string { return lines.join("\n"); } +function paginationNote(total: number, offset: number, shownCount: number): string[] { + if (total <= shownCount) return []; + const lines: string[] = [""]; + if (shownCount === 0) { + lines.push(`Offset ${offset} past end (${total} total).`); + } else if (offset > 0) { + lines.push(`Showing ${offset + 1}–${offset + shownCount} of ${total}.`); + } else { + lines.push(`${shownCount} of ${total} shown.`); + } + return lines; +} + function renderMessageDrilldown( visible: VisibleMessageInfo[], toolFilter: string | undefined, sort: string, limit: number, + reverse: boolean, + offset: number, ): string { let filtered = visible; if (toolFilter) filtered = filtered.filter((m) => m.tool === toolFilter); @@ -253,20 +274,19 @@ function renderMessageDrilldown( else if (sort === "tool") filtered.sort((a, b) => a.tool.localeCompare(b.tool) || b.tokens - a.tokens); else filtered.sort((a, b) => b.tokens - a.tokens); + if (reverse) filtered.reverse(); + const totalTokens = filtered.reduce((s, m) => s + m.tokens, 0); const allTokens = visible.reduce((s, m) => s + m.tokens, 0); const header = toolFilter ? `UNCOMPRESSED — ${toolFilter}: ${formatTokens(totalTokens)} | ${filtered.length} msgs | ${pct(totalTokens, allTokens)}% of visible` : `UNCOMPRESSED — ${formatTokens(totalTokens)} | ${filtered.length} msgs`; - const lines = [header, `Sorted by ${sort}`, ""]; - const shown = filtered.slice(0, limit); + const lines = [header, `Sorted by ${sort}${reverse ? " (reverse)" : ""}`, ""]; + const shown = filtered.slice(offset, offset + limit); for (const message of shown) { lines.push(` ${message.ref} (${formatTokens(message.tokens)}) ${message.tool}`); } - if (filtered.length > shown.length) { - lines.push(""); - lines.push(`${shown.length} of ${filtered.length} shown.`); - } + lines.push(...paginationNote(filtered.length, offset, shown.length)); return lines.join("\n"); } @@ -276,6 +296,8 @@ function renderCompressedDrilldown( sort: string, limit: number, countTokens: (t: string) => number, + reverse: boolean, + offset: number, ): string { let sorted = [...blocks]; if (sort === "time") sorted.sort((a, b) => a.createdAt - b.createdAt); @@ -288,6 +310,8 @@ function renderCompressedDrilldown( b.createdAt - a.createdAt, ); + if (reverse) sorted.reverse(); + const totalSummary = sorted.reduce((s, b) => s + summaryTokensOf(b, countTokens), 0); const totalEffective = sorted.reduce( (s, b) => s + effectiveCompressedTokens(b, state, countTokens), @@ -299,7 +323,7 @@ function renderCompressedDrilldown( const breakdown = tierBreakdown(sorted, countTokens); if (breakdown) lines.push(`Tier usage: ${breakdown}`); lines.push(""); - const shown = sorted.slice(0, limit); + const shown = sorted.slice(offset, offset + limit); for (const block of shown) { const nested = block.directBlockIds.length > 0 ? ` nested=[${block.directBlockIds.join(",")}]` : ""; const topic = block.topic ?? "(no topic)"; @@ -309,10 +333,7 @@ function renderCompressedDrilldown( ); lines.push(` "${topic}"`); } - if (sorted.length > shown.length) { - lines.push(""); - lines.push(`${shown.length} of ${sorted.length} shown.`); - } + lines.push(...paginationNote(sorted.length, offset, shown.length)); return lines.join("\n"); } diff --git a/tests/report-drilldown.test.ts b/tests/report-drilldown.test.ts new file mode 100644 index 0000000..fce9756 --- /dev/null +++ b/tests/report-drilldown.test.ts @@ -0,0 +1,215 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildStatusReport } from "../src/report.js"; +import { createInitialState } from "../src/state.js"; +import { defaultCountTokens } from "../src/tokenize.js"; +import type { CompressionBlock, CompressionState, CoreMessage } from "../src/types.js"; + +function block(overrides: Partial): CompressionBlock { + return { + blockId: "b0", + runId: "r0", + tier: 1, + summary: "summary", + directMessageIds: [], + effectiveMessageIds: [], + directBlockIds: [], + createdAt: 1000, + survivedCount: 0, + generation: "young", + active: true, + ...overrides, + }; +} + +// messages m0..m9 (oldest..newest), each with a distinct token size so the +// fixture doubles for both sort:"time" and sort:"size" ordering assertions. +function fixtureMessages(): CoreMessage[] { + const messages: CoreMessage[] = []; + for (let i = 0; i < 10; i++) { + messages.push({ + id: `m${i}`, + role: "assistant", + contentType: "tool-result", + toolName: i % 2 === 0 ? "bash" : "read", + text: "x".repeat(i * 4 + 4), + }); + } + return messages; +} + +function fixtureState(messages: CoreMessage[]): CompressionState { + const byRaw: Record = {}; + const byRef: Record = {}; + messages.forEach((m, i) => { + const ref = `m${String(i).padStart(5, "0")}`; + byRaw[m.id] = ref; + byRef[ref] = m.id; + }); + return { + ...createInitialState(), + messageRefs: { byRaw, byRef }, + blocks: [], + }; +} + +test("drilldown sort:time is oldest-first and head-only by default (status quo pinned)", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + }); + assert.ok(report.includes(" m00000 (1) bash")); + assert.ok(report.includes(" m00001 (2) read")); + assert.ok(report.includes(" m00002 (3) bash")); + assert.ok(!report.includes("m00009"), "newest message must not appear with head-only default"); + assert.ok(report.includes("3 of 10 shown.")); +}); + +test("drilldown sort:time + reverse:true reaches the newest messages", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, + limit: 3, + }); + assert.ok(report.includes("Sorted by time (reverse)")); + assert.ok(report.includes(" m00009 (10) read")); + assert.ok(report.includes(" m00008 (9) bash")); + assert.ok(report.includes(" m00007 (8) read")); + assert.ok(!report.includes("m00000"), "oldest message must not appear when reversed"); +}); + +test("drilldown offset paginates past the head of any sort", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + // time, ascending, page 3 of 4 (rows 7-9) + const forward = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + offset: 6, + }); + assert.ok(forward.includes(" m00006 (7) bash")); + assert.ok(forward.includes(" m00008 (9) bash")); + assert.ok(forward.includes("Showing 7–9 of 10.")); + assert.ok(!forward.includes("m00009"), "offset window ends before the last message"); + + // time, descending, page 3 of 4 — the tail is now on page 1, later pages + // walk back toward the head + const backward = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + reverse: true, + limit: 3, + offset: 6, + }); + assert.ok(backward.includes(" m00003 (4) read")); + assert.ok(backward.includes(" m00002 (3) bash")); + assert.ok(backward.includes(" m00001 (2) read")); + assert.ok(backward.includes("Showing 7–9 of 10.")); +}); + +test("drilldown offset past the end reports the miss instead of an empty page", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "time", + limit: 3, + offset: 10, + }); + assert.ok(report.includes("Offset 10 past end (10 total).")); + // line-level check: no message row (two-space indent + ref) may be rendered + const rowLines = report.split("\n").filter((line) => /^ m\d/.test(line)); + assert.deepEqual(rowLines, [], "no message rows rendered past the end"); +}); + +test("drilldown sort:size reverse flips largest-first to smallest-first", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const largest = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "size", + limit: 2, + }); + assert.ok(largest.includes(" m00009 (10) read")); + assert.ok(largest.includes(" m00008 (9) bash")); + + const smallest = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + sort: "size", + reverse: true, + limit: 2, + }); + assert.ok(smallest.includes(" m00000 (1) bash")); + assert.ok(smallest.includes(" m00001 (2) read")); +}); + +test("drilldown tool filter combines with reverse:time to reach the newest rows of one tool", () => { + const messages = fixtureMessages(); + const state = fixtureState(messages); + const report = buildStatusReport(state, messages, defaultCountTokens, { + scope: "uncompressed", + view: "messages", + tool: "bash", + sort: "time", + reverse: true, + limit: 2, + }); + // newest bash rows are m8 (9), m6 (7) + assert.ok(report.includes(" m00008 (9) bash")); + assert.ok(report.includes(" m00006 (7) bash")); + assert.ok(!report.includes("read"), "tool filter must exclude other tools"); +}); + +test("drilldown compressed scope: reverse:time shows newest blocks first", () => { + const state: CompressionState = { + ...createInitialState(), + blocks: [ + block({ blockId: "b1", createdAt: 1000, summary: "old", topic: "one" }), + block({ blockId: "b2", createdAt: 2000, summary: "mid", topic: "two" }), + block({ blockId: "b3", createdAt: 3000, summary: "new", topic: "three" }), + ], + }; + const forward = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + limit: 2, + }); + assert.ok(forward.includes("b1")); + assert.ok(forward.includes("b2")); + assert.ok(!forward.includes("b3"), "newest block must not appear with head-only default"); + assert.ok(forward.includes("2 of 3 shown.")); + + const backward = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + reverse: true, + limit: 2, + }); + assert.ok(backward.includes("b3")); + assert.ok(backward.includes("b2")); + assert.ok(!backward.includes("b1"), "oldest block must not appear when reversed"); + + const paged = buildStatusReport(state, [], defaultCountTokens, { + scope: "compressed", + sort: "time", + limit: 1, + offset: 2, + }); + assert.ok(paged.includes("b3")); + assert.ok(!paged.includes("b1")); + assert.ok(paged.includes("Showing 3–3 of 3.")); +});