From 3528f481fda9049c3fb6f95da31be8d78cdb499f Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Mon, 30 Mar 2026 18:27:49 -0400 Subject: [PATCH 1/4] fix: add pagination support for recap trace list (#418) - Add `listTracesPaginated()` method to Trace class returning page of traces with total count, offset, and limit metadata - Add `--offset` CLI option to `trace list` command for navigating past the first page of results - Wire CLI handler to use `listTracesPaginated()` instead of manual array slicing - Remove hardcoded `.slice(0, 50)` cap in TUI dialog so all traces are accessible via built-in scrolling - Show "Showing X-Y of N" pagination footer with next-page hint - Distinguish empty results (no traces exist) from offset-past-end (offset exceeds total count) with clear messaging Closes #418 Co-Authored-By: Vijay Yadav --- .../src/altimate/observability/tracing.ts | 24 ++++++++++++ packages/opencode/src/cli/cmd/trace.ts | 37 +++++++++++++++---- .../cmd/tui/component/dialog-trace-list.tsx | 2 +- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 72e17e0772..06ef50b902 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -1000,6 +1000,30 @@ export class Trace { } } + /** + * List traces with pagination support. + * Returns a page of traces plus total count for building pagination UI. + */ + static async listTracesPaginated( + dir?: string, + options?: { offset?: number; limit?: number }, + ): Promise<{ + traces: Array<{ sessionId: string; file: string; trace: TraceFile }> + total: number + offset: number + limit: number + }> { + const all = await Trace.listTraces(dir) + const offset = Math.max(0, options?.offset ?? 0) + const limit = Math.max(1, options?.limit ?? 20) + return { + traces: all.slice(offset, offset + limit), + total: all.length, + offset, + limit, + } + } + static async loadTrace(sessionId: string, dir?: string): Promise { const tracesDir = dir ?? DEFAULT_TRACES_DIR try { diff --git a/packages/opencode/src/cli/cmd/trace.ts b/packages/opencode/src/cli/cmd/trace.ts index 3c9a0b1b9b..cf35ee71c8 100644 --- a/packages/opencode/src/cli/cmd/trace.ts +++ b/packages/opencode/src/cli/cmd/trace.ts @@ -49,13 +49,23 @@ function truncate(str: string, len: number): string { } // altimate_change start — trace: list session traces (recordings/recaps of agent sessions) -function listTraces(traces: Array<{ sessionId: string; trace: TraceFile }>, tracesDir?: string) { - if (traces.length === 0) { +function listTraces( + traces: Array<{ sessionId: string; trace: TraceFile }>, + pagination: { total: number; offset: number; limit: number }, + tracesDir?: string, +) { + if (traces.length === 0 && pagination.total === 0) { UI.println("No traces found. Run a command with tracing enabled:") UI.println(" altimate-code run \"your prompt here\"") return } + if (traces.length === 0 && pagination.total > 0) { + UI.println(`No traces on this page (offset ${pagination.offset} past end of ${pagination.total} traces).`) + UI.println(UI.Style.TEXT_DIM + `Try: altimate-code trace list --offset 0 --limit ${pagination.limit}` + UI.Style.TEXT_NORMAL) + return + } + // Header const header = [ "DATE".padEnd(13), @@ -97,8 +107,13 @@ function listTraces(traces: Array<{ sessionId: string; trace: TraceFile }>, trac } UI.empty() - // altimate_change start — trace: session trace messages - UI.println(UI.Style.TEXT_DIM + `${traces.length} trace(s) in ${Trace.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL) + // altimate_change start — trace: session trace messages with pagination footer + const rangeStart = pagination.offset + 1 + const rangeEnd = pagination.offset + traces.length + UI.println(UI.Style.TEXT_DIM + `Showing ${rangeStart}-${rangeEnd} of ${pagination.total} trace(s) in ${Trace.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL) + if (rangeEnd < pagination.total) { + UI.println(UI.Style.TEXT_DIM + `Next page: altimate-code trace list --offset ${rangeEnd} --limit ${pagination.limit}` + UI.Style.TEXT_NORMAL) + } UI.println(UI.Style.TEXT_DIM + "View a trace: altimate-code trace view " + UI.Style.TEXT_NORMAL) // altimate_change end } @@ -134,6 +149,11 @@ export const TraceCommand = cmd({ describe: "number of traces to show", default: 20, }) + .option("offset", { + type: "number", + describe: "number of traces to skip (for pagination)", + default: 0, + }) .option("live", { type: "boolean", describe: "auto-refresh the viewer as the trace updates (for in-progress sessions)", @@ -148,8 +168,11 @@ export const TraceCommand = cmd({ const tracesDir = (cfg as any).tracing?.dir as string | undefined if (action === "list") { - const traces = await Trace.listTraces(tracesDir) - listTraces(traces.slice(0, args.limit || 20), tracesDir) + const page = await Trace.listTracesPaginated(tracesDir, { + offset: args.offset || 0, + limit: args.limit || 20, + }) + listTraces(page.traces, page, tracesDir) return } @@ -168,7 +191,7 @@ export const TraceCommand = cmd({ if (!match) { UI.error(`Trace not found: ${args.id}`) UI.println("Available traces:") - listTraces(traces.slice(0, 10), tracesDir) + listTraces(traces.slice(0, 10), { total: traces.length, offset: 0, limit: 10 }, tracesDir) process.exit(1) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx index 0f6100a0d7..cfb07db9c5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx @@ -61,7 +61,7 @@ export function DialogTraceList(props: { }) } - result.push(...items.slice(0, 50).map((item) => { + result.push(...items.map((item) => { const rawStartedAt = item.trace.startedAt const parsedDate = typeof rawStartedAt === "string" || typeof rawStartedAt === "number" ? new Date(rawStartedAt) From b30e7d48236fcc1d4b3b4adbce1fc59cb9ec997a Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Mon, 30 Mar 2026 18:53:02 -0400 Subject: [PATCH 2/4] fix: normalize pagination inputs to finite integers Coerce offset/limit to finite integers via Number.isFinite and Math.trunc before using them for slice and returning in metadata. Prevents NaN, fractional, or infinite values from producing invalid display ranges. Co-Authored-By: Vijay Yadav --- packages/opencode/src/altimate/observability/tracing.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 06ef50b902..d9888752c3 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -1014,8 +1014,10 @@ export class Trace { limit: number }> { const all = await Trace.listTraces(dir) - const offset = Math.max(0, options?.offset ?? 0) - const limit = Math.max(1, options?.limit ?? 20) + const rawOffset = options?.offset ?? 0 + const rawLimit = options?.limit ?? 20 + const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0 + const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.trunc(rawLimit)) : 20 return { traces: all.slice(offset, offset + limit), total: all.length, From 9cf3d547156a3d525277eb485408f4fb5a4992bf Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Sun, 5 Apr 2026 07:16:07 -0700 Subject: [PATCH 3/4] fix: cap TUI trace dialog at 500 items + add pagination boundary tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review (GPT + Gemini) flagged two follow-ups: 1. TUI freeze risk after removing .slice(0, 50) — DialogSelect creates reactive nodes per item via Solid's , so unbounded rendering can lag noticeably on trace directories with thousands of entries. 2. listTracesPaginated had no unit tests for boundary math. Fixes: - Cap dialog-trace-list at MAX_TUI_ITEMS=500 with an explicit '... N more not shown' footer row pointing users to 'altimate-code trace list --offset N' for the full set. 500 is 10× the old cap and covers the vast majority of real usage, while leaving TUI rendering bounded. The onSelect handler ignores the truncation marker row. - Add 10 listTracesPaginated regression tests covering: empty dir, bounded page size, multi-page traversal, offset === total, offset > total, negative offset clamping, non-positive limit clamping, fractional truncation, NaN → default fallback, and default options. The I/O-layer optimization (slice filenames before JSON.parse) called out by Gemini is a separate perf concern that requires embedding timestamps in trace filenames; tracked as a follow-up. --- .../cmd/tui/component/dialog-trace-list.tsx | 23 +++- .../opencode/test/altimate/tracing.test.ts | 116 ++++++++++++++++++ 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx index cfb07db9c5..533516432a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx @@ -47,7 +47,16 @@ export function DialogTraceList(props: { } // altimate_change end - const items = traces() ?? [] + // Cap rendered items for TUI perf — DialogSelect creates reactive + // nodes per item via , so very large trace directories + // (thousands of entries) can cause noticeable lag. Users with more + // than MAX_TUI_ITEMS traces should use `altimate-code trace list + // --offset N` from the CLI to navigate the full set. + const MAX_TUI_ITEMS = 500 + const allItems = traces() ?? [] + const items = + allItems.length > MAX_TUI_ITEMS ? allItems.slice(0, MAX_TUI_ITEMS) : allItems + const truncated = allItems.length > MAX_TUI_ITEMS const today = new Date().toDateString() const result: Array<{ title: string; value: string; category: string; footer: string }> = [] @@ -96,6 +105,16 @@ export function DialogTraceList(props: { } })) + // Append truncation hint if we capped the list + if (truncated) { + result.push({ + title: `... ${allItems.length - MAX_TUI_ITEMS} more not shown`, + value: "__truncated__", + category: "Older", + footer: `Showing ${MAX_TUI_ITEMS} of ${allItems.length} — use CLI --offset to navigate`, + }) + } + return result }) @@ -113,7 +132,7 @@ export function DialogTraceList(props: { options={options()} current={props.currentSessionID} onSelect={(option) => { - if (option.value === "__error__") { + if (option.value === "__error__" || option.value === "__truncated__") { dialog.clear() return } diff --git a/packages/opencode/test/altimate/tracing.test.ts b/packages/opencode/test/altimate/tracing.test.ts index 0ffe9929b1..69a95fd232 100644 --- a/packages/opencode/test/altimate/tracing.test.ts +++ b/packages/opencode/test/altimate/tracing.test.ts @@ -737,6 +737,122 @@ describe("Recap — static helpers", () => { }) }) +// --------------------------------------------------------------------------- +// listTracesPaginated — pagination boundary math +// --------------------------------------------------------------------------- + +describe("Recap.listTracesPaginated", () => { + // Seed a known set of traces in a fresh directory for each test + async function seedTraces(count: number, dir: string): Promise { + const exporter = new FileExporter(dir) + const tracer = Recap.withExporters([exporter]) + for (let i = 0; i < count; i++) { + tracer.startTrace(`session-${String(i).padStart(4, "0")}`, { + prompt: `prompt-${i}`, + }) + // Tiny delay to guarantee distinct mtimes where relevant + await tracer.endTrace() + } + } + + test("returns empty page when directory has no traces", async () => { + const result = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 10 }) + expect(result.traces).toEqual([]) + expect(result.total).toBe(0) + expect(result.offset).toBe(0) + expect(result.limit).toBe(10) + }) + + test("returns a bounded page of the requested size", async () => { + await seedTraces(15, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 5 }) + expect(result.traces).toHaveLength(5) + expect(result.total).toBe(15) + expect(result.offset).toBe(0) + expect(result.limit).toBe(5) + }) + + test("applies offset to return later pages", async () => { + await seedTraces(10, tmpDir) + const page1 = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 4 }) + const page2 = await Recap.listTracesPaginated(tmpDir, { offset: 4, limit: 4 }) + const page3 = await Recap.listTracesPaginated(tmpDir, { offset: 8, limit: 4 }) + expect(page1.traces).toHaveLength(4) + expect(page2.traces).toHaveLength(4) + expect(page3.traces).toHaveLength(2) // only 2 left on the tail + // Pages must not overlap + const ids = new Set() + for (const p of [page1, page2, page3]) { + for (const t of p.traces) { + expect(ids.has(t.sessionId)).toBe(false) + ids.add(t.sessionId) + } + } + expect(ids.size).toBe(10) + }) + + test("returns empty traces array when offset equals total", async () => { + await seedTraces(5, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { offset: 5, limit: 10 }) + expect(result.traces).toEqual([]) + expect(result.total).toBe(5) + expect(result.offset).toBe(5) + }) + + test("returns empty traces array when offset exceeds total", async () => { + await seedTraces(3, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { offset: 99, limit: 10 }) + expect(result.traces).toEqual([]) + expect(result.total).toBe(3) + expect(result.offset).toBe(99) + }) + + test("clamps negative offset to 0", async () => { + await seedTraces(5, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { offset: -10, limit: 3 }) + expect(result.offset).toBe(0) + expect(result.traces).toHaveLength(3) + }) + + test("clamps non-positive limit to 1", async () => { + await seedTraces(5, tmpDir) + const zero = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 0 }) + expect(zero.limit).toBe(1) + expect(zero.traces).toHaveLength(1) + const neg = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: -5 }) + expect(neg.limit).toBe(1) + expect(neg.traces).toHaveLength(1) + }) + + test("truncates fractional offset and limit to integers", async () => { + await seedTraces(10, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { offset: 2.9, limit: 3.7 }) + expect(result.offset).toBe(2) + expect(result.limit).toBe(3) + expect(result.traces).toHaveLength(3) + }) + + test("clamps NaN offset and limit to defaults", async () => { + await seedTraces(5, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir, { + offset: NaN, + limit: NaN, + }) + expect(result.offset).toBe(0) + expect(result.limit).toBe(20) // default + expect(result.traces).toHaveLength(5) // all 5 fit in default page + }) + + test("uses defaults when no options provided", async () => { + await seedTraces(3, tmpDir) + const result = await Recap.listTracesPaginated(tmpDir) + expect(result.offset).toBe(0) + expect(result.limit).toBe(20) + expect(result.total).toBe(3) + expect(result.traces).toHaveLength(3) + }) +}) + // --------------------------------------------------------------------------- // Edge cases — schema integrity // --------------------------------------------------------------------------- From 87562bf40ff8414df4024d7c22e6ffe4874a6955 Mon Sep 17 00:00:00 2001 From: Vijay Yadav Date: Sun, 5 Apr 2026 07:29:13 -0700 Subject: [PATCH 4/4] fix: address coderabbit + cubic review comments - trace.ts:172-173 (coderabbit): switch pagination fallbacks from || to ?? so an explicit --offset 0 / --limit 0 reaches listTracesPaginated() for clamping, rather than being swallowed as falsy and replaced with defaults. The API already clamps limit<1 to 1, so --limit 0 now routes through that clamp path correctly. - tracing.test.ts:746 (cubic): create a fresh Trace instance per iteration inside seedTraces() rather than reusing a single tracer. Reusing a tracer across startTrace/endTrace cycles accumulates spans in memory across iterations. Matches the pattern used by the maxFiles test. --- packages/opencode/src/cli/cmd/trace.ts | 9 +++++++-- packages/opencode/test/altimate/tracing.test.ts | 9 +++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/trace.ts b/packages/opencode/src/cli/cmd/trace.ts index cf35ee71c8..faf1e39f24 100644 --- a/packages/opencode/src/cli/cmd/trace.ts +++ b/packages/opencode/src/cli/cmd/trace.ts @@ -168,9 +168,14 @@ export const TraceCommand = cmd({ const tracesDir = (cfg as any).tracing?.dir as string | undefined if (action === "list") { + // Use nullish coalescing so an explicit 0 is preserved and reaches + // listTracesPaginated() for clamping. `args.offset || 0` would + // treat `--offset 0` as unset (no semantic change, harmless), but + // `args.limit || 20` would promote `--limit 0` to 20 instead of + // letting the API clamp it to 1. const page = await Trace.listTracesPaginated(tracesDir, { - offset: args.offset || 0, - limit: args.limit || 20, + offset: args.offset ?? 0, + limit: args.limit ?? 20, }) listTraces(page.traces, page, tracesDir) return diff --git a/packages/opencode/test/altimate/tracing.test.ts b/packages/opencode/test/altimate/tracing.test.ts index 69a95fd232..acd76ebe0f 100644 --- a/packages/opencode/test/altimate/tracing.test.ts +++ b/packages/opencode/test/altimate/tracing.test.ts @@ -742,15 +742,16 @@ describe("Recap — static helpers", () => { // --------------------------------------------------------------------------- describe("Recap.listTracesPaginated", () => { - // Seed a known set of traces in a fresh directory for each test + // Seed a known set of traces in a fresh directory for each test. + // A fresh tracer is created per iteration so spans don't accumulate + // across startTrace/endTrace cycles — matches the pattern used + // elsewhere in this file (see the maxFiles test above). async function seedTraces(count: number, dir: string): Promise { - const exporter = new FileExporter(dir) - const tracer = Recap.withExporters([exporter]) for (let i = 0; i < count; i++) { + const tracer = Recap.withExporters([new FileExporter(dir)]) tracer.startTrace(`session-${String(i).padStart(4, "0")}`, { prompt: `prompt-${i}`, }) - // Tiny delay to guarantee distinct mtimes where relevant await tracer.endTrace() } }