diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index 72e17e077..d9888752c 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -1000,6 +1000,32 @@ 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 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, + 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 3c9a0b1b9..faf1e39f2 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,16 @@ 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) + // 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, + }) + listTraces(page.traces, page, tracesDir) return } @@ -168,7 +196,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 0f6100a0d..533516432 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 }> = [] @@ -61,7 +70,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) @@ -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 0ffe9929b..acd76ebe0 100644 --- a/packages/opencode/test/altimate/tracing.test.ts +++ b/packages/opencode/test/altimate/tracing.test.ts @@ -737,6 +737,123 @@ describe("Recap — static helpers", () => { }) }) +// --------------------------------------------------------------------------- +// listTracesPaginated — pagination boundary math +// --------------------------------------------------------------------------- + +describe("Recap.listTracesPaginated", () => { + // 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 { + 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}`, + }) + 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 // ---------------------------------------------------------------------------