Skip to content

Commit b66d9f5

Browse files
VJ-yadavVijay Yadavanandgupta42
authored
fix: add pagination support for recap trace list (#596)
* 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 <vijay@studentsucceed.com> * 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 <vijay@studentsucceed.com> * fix: cap TUI trace dialog at 500 items + add pagination boundary tests 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 <For>, 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. * 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. --------- Co-authored-by: Vijay Yadav <vijay@studentsucceed.com> Co-authored-by: anandgupta42 <93243293+anandgupta42@users.noreply.github.com>
1 parent 29e4267 commit b66d9f5

File tree

4 files changed

+200
-10
lines changed

4 files changed

+200
-10
lines changed

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,6 +1000,32 @@ export class Trace {
10001000
}
10011001
}
10021002

1003+
/**
1004+
* List traces with pagination support.
1005+
* Returns a page of traces plus total count for building pagination UI.
1006+
*/
1007+
static async listTracesPaginated(
1008+
dir?: string,
1009+
options?: { offset?: number; limit?: number },
1010+
): Promise<{
1011+
traces: Array<{ sessionId: string; file: string; trace: TraceFile }>
1012+
total: number
1013+
offset: number
1014+
limit: number
1015+
}> {
1016+
const all = await Trace.listTraces(dir)
1017+
const rawOffset = options?.offset ?? 0
1018+
const rawLimit = options?.limit ?? 20
1019+
const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.trunc(rawOffset)) : 0
1020+
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.trunc(rawLimit)) : 20
1021+
return {
1022+
traces: all.slice(offset, offset + limit),
1023+
total: all.length,
1024+
offset,
1025+
limit,
1026+
}
1027+
}
1028+
10031029
static async loadTrace(sessionId: string, dir?: string): Promise<TraceFile | null> {
10041030
const tracesDir = dir ?? DEFAULT_TRACES_DIR
10051031
try {

packages/opencode/src/cli/cmd/trace.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,23 @@ function truncate(str: string, len: number): string {
4949
}
5050

5151
// altimate_change start — trace: list session traces (recordings/recaps of agent sessions)
52-
function listTraces(traces: Array<{ sessionId: string; trace: TraceFile }>, tracesDir?: string) {
53-
if (traces.length === 0) {
52+
function listTraces(
53+
traces: Array<{ sessionId: string; trace: TraceFile }>,
54+
pagination: { total: number; offset: number; limit: number },
55+
tracesDir?: string,
56+
) {
57+
if (traces.length === 0 && pagination.total === 0) {
5458
UI.println("No traces found. Run a command with tracing enabled:")
5559
UI.println(" altimate-code run \"your prompt here\"")
5660
return
5761
}
5862

63+
if (traces.length === 0 && pagination.total > 0) {
64+
UI.println(`No traces on this page (offset ${pagination.offset} past end of ${pagination.total} traces).`)
65+
UI.println(UI.Style.TEXT_DIM + `Try: altimate-code trace list --offset 0 --limit ${pagination.limit}` + UI.Style.TEXT_NORMAL)
66+
return
67+
}
68+
5969
// Header
6070
const header = [
6171
"DATE".padEnd(13),
@@ -97,8 +107,13 @@ function listTraces(traces: Array<{ sessionId: string; trace: TraceFile }>, trac
97107
}
98108

99109
UI.empty()
100-
// altimate_change start — trace: session trace messages
101-
UI.println(UI.Style.TEXT_DIM + `${traces.length} trace(s) in ${Trace.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL)
110+
// altimate_change start — trace: session trace messages with pagination footer
111+
const rangeStart = pagination.offset + 1
112+
const rangeEnd = pagination.offset + traces.length
113+
UI.println(UI.Style.TEXT_DIM + `Showing ${rangeStart}-${rangeEnd} of ${pagination.total} trace(s) in ${Trace.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL)
114+
if (rangeEnd < pagination.total) {
115+
UI.println(UI.Style.TEXT_DIM + `Next page: altimate-code trace list --offset ${rangeEnd} --limit ${pagination.limit}` + UI.Style.TEXT_NORMAL)
116+
}
102117
UI.println(UI.Style.TEXT_DIM + "View a trace: altimate-code trace view <session-id>" + UI.Style.TEXT_NORMAL)
103118
// altimate_change end
104119
}
@@ -134,6 +149,11 @@ export const TraceCommand = cmd({
134149
describe: "number of traces to show",
135150
default: 20,
136151
})
152+
.option("offset", {
153+
type: "number",
154+
describe: "number of traces to skip (for pagination)",
155+
default: 0,
156+
})
137157
.option("live", {
138158
type: "boolean",
139159
describe: "auto-refresh the viewer as the trace updates (for in-progress sessions)",
@@ -148,8 +168,16 @@ export const TraceCommand = cmd({
148168
const tracesDir = (cfg as any).tracing?.dir as string | undefined
149169

150170
if (action === "list") {
151-
const traces = await Trace.listTraces(tracesDir)
152-
listTraces(traces.slice(0, args.limit || 20), tracesDir)
171+
// Use nullish coalescing so an explicit 0 is preserved and reaches
172+
// listTracesPaginated() for clamping. `args.offset || 0` would
173+
// treat `--offset 0` as unset (no semantic change, harmless), but
174+
// `args.limit || 20` would promote `--limit 0` to 20 instead of
175+
// letting the API clamp it to 1.
176+
const page = await Trace.listTracesPaginated(tracesDir, {
177+
offset: args.offset ?? 0,
178+
limit: args.limit ?? 20,
179+
})
180+
listTraces(page.traces, page, tracesDir)
153181
return
154182
}
155183

@@ -168,7 +196,7 @@ export const TraceCommand = cmd({
168196
if (!match) {
169197
UI.error(`Trace not found: ${args.id}`)
170198
UI.println("Available traces:")
171-
listTraces(traces.slice(0, 10), tracesDir)
199+
listTraces(traces.slice(0, 10), { total: traces.length, offset: 0, limit: 10 }, tracesDir)
172200
process.exit(1)
173201
}
174202

packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,16 @@ export function DialogTraceList(props: {
4747
}
4848
// altimate_change end
4949

50-
const items = traces() ?? []
50+
// Cap rendered items for TUI perf — DialogSelect creates reactive
51+
// nodes per item via <For>, so very large trace directories
52+
// (thousands of entries) can cause noticeable lag. Users with more
53+
// than MAX_TUI_ITEMS traces should use `altimate-code trace list
54+
// --offset N` from the CLI to navigate the full set.
55+
const MAX_TUI_ITEMS = 500
56+
const allItems = traces() ?? []
57+
const items =
58+
allItems.length > MAX_TUI_ITEMS ? allItems.slice(0, MAX_TUI_ITEMS) : allItems
59+
const truncated = allItems.length > MAX_TUI_ITEMS
5160
const today = new Date().toDateString()
5261
const result: Array<{ title: string; value: string; category: string; footer: string }> = []
5362

@@ -61,7 +70,7 @@ export function DialogTraceList(props: {
6170
})
6271
}
6372

64-
result.push(...items.slice(0, 50).map((item) => {
73+
result.push(...items.map((item) => {
6574
const rawStartedAt = item.trace.startedAt
6675
const parsedDate = typeof rawStartedAt === "string" || typeof rawStartedAt === "number"
6776
? new Date(rawStartedAt)
@@ -96,6 +105,16 @@ export function DialogTraceList(props: {
96105
}
97106
}))
98107

108+
// Append truncation hint if we capped the list
109+
if (truncated) {
110+
result.push({
111+
title: `... ${allItems.length - MAX_TUI_ITEMS} more not shown`,
112+
value: "__truncated__",
113+
category: "Older",
114+
footer: `Showing ${MAX_TUI_ITEMS} of ${allItems.length} — use CLI --offset to navigate`,
115+
})
116+
}
117+
99118
return result
100119
})
101120

@@ -113,7 +132,7 @@ export function DialogTraceList(props: {
113132
options={options()}
114133
current={props.currentSessionID}
115134
onSelect={(option) => {
116-
if (option.value === "__error__") {
135+
if (option.value === "__error__" || option.value === "__truncated__") {
117136
dialog.clear()
118137
return
119138
}

packages/opencode/test/altimate/tracing.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,123 @@ describe("Recap — static helpers", () => {
737737
})
738738
})
739739

740+
// ---------------------------------------------------------------------------
741+
// listTracesPaginated — pagination boundary math
742+
// ---------------------------------------------------------------------------
743+
744+
describe("Recap.listTracesPaginated", () => {
745+
// Seed a known set of traces in a fresh directory for each test.
746+
// A fresh tracer is created per iteration so spans don't accumulate
747+
// across startTrace/endTrace cycles — matches the pattern used
748+
// elsewhere in this file (see the maxFiles test above).
749+
async function seedTraces(count: number, dir: string): Promise<void> {
750+
for (let i = 0; i < count; i++) {
751+
const tracer = Recap.withExporters([new FileExporter(dir)])
752+
tracer.startTrace(`session-${String(i).padStart(4, "0")}`, {
753+
prompt: `prompt-${i}`,
754+
})
755+
await tracer.endTrace()
756+
}
757+
}
758+
759+
test("returns empty page when directory has no traces", async () => {
760+
const result = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 10 })
761+
expect(result.traces).toEqual([])
762+
expect(result.total).toBe(0)
763+
expect(result.offset).toBe(0)
764+
expect(result.limit).toBe(10)
765+
})
766+
767+
test("returns a bounded page of the requested size", async () => {
768+
await seedTraces(15, tmpDir)
769+
const result = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 5 })
770+
expect(result.traces).toHaveLength(5)
771+
expect(result.total).toBe(15)
772+
expect(result.offset).toBe(0)
773+
expect(result.limit).toBe(5)
774+
})
775+
776+
test("applies offset to return later pages", async () => {
777+
await seedTraces(10, tmpDir)
778+
const page1 = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 4 })
779+
const page2 = await Recap.listTracesPaginated(tmpDir, { offset: 4, limit: 4 })
780+
const page3 = await Recap.listTracesPaginated(tmpDir, { offset: 8, limit: 4 })
781+
expect(page1.traces).toHaveLength(4)
782+
expect(page2.traces).toHaveLength(4)
783+
expect(page3.traces).toHaveLength(2) // only 2 left on the tail
784+
// Pages must not overlap
785+
const ids = new Set<string>()
786+
for (const p of [page1, page2, page3]) {
787+
for (const t of p.traces) {
788+
expect(ids.has(t.sessionId)).toBe(false)
789+
ids.add(t.sessionId)
790+
}
791+
}
792+
expect(ids.size).toBe(10)
793+
})
794+
795+
test("returns empty traces array when offset equals total", async () => {
796+
await seedTraces(5, tmpDir)
797+
const result = await Recap.listTracesPaginated(tmpDir, { offset: 5, limit: 10 })
798+
expect(result.traces).toEqual([])
799+
expect(result.total).toBe(5)
800+
expect(result.offset).toBe(5)
801+
})
802+
803+
test("returns empty traces array when offset exceeds total", async () => {
804+
await seedTraces(3, tmpDir)
805+
const result = await Recap.listTracesPaginated(tmpDir, { offset: 99, limit: 10 })
806+
expect(result.traces).toEqual([])
807+
expect(result.total).toBe(3)
808+
expect(result.offset).toBe(99)
809+
})
810+
811+
test("clamps negative offset to 0", async () => {
812+
await seedTraces(5, tmpDir)
813+
const result = await Recap.listTracesPaginated(tmpDir, { offset: -10, limit: 3 })
814+
expect(result.offset).toBe(0)
815+
expect(result.traces).toHaveLength(3)
816+
})
817+
818+
test("clamps non-positive limit to 1", async () => {
819+
await seedTraces(5, tmpDir)
820+
const zero = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: 0 })
821+
expect(zero.limit).toBe(1)
822+
expect(zero.traces).toHaveLength(1)
823+
const neg = await Recap.listTracesPaginated(tmpDir, { offset: 0, limit: -5 })
824+
expect(neg.limit).toBe(1)
825+
expect(neg.traces).toHaveLength(1)
826+
})
827+
828+
test("truncates fractional offset and limit to integers", async () => {
829+
await seedTraces(10, tmpDir)
830+
const result = await Recap.listTracesPaginated(tmpDir, { offset: 2.9, limit: 3.7 })
831+
expect(result.offset).toBe(2)
832+
expect(result.limit).toBe(3)
833+
expect(result.traces).toHaveLength(3)
834+
})
835+
836+
test("clamps NaN offset and limit to defaults", async () => {
837+
await seedTraces(5, tmpDir)
838+
const result = await Recap.listTracesPaginated(tmpDir, {
839+
offset: NaN,
840+
limit: NaN,
841+
})
842+
expect(result.offset).toBe(0)
843+
expect(result.limit).toBe(20) // default
844+
expect(result.traces).toHaveLength(5) // all 5 fit in default page
845+
})
846+
847+
test("uses defaults when no options provided", async () => {
848+
await seedTraces(3, tmpDir)
849+
const result = await Recap.listTracesPaginated(tmpDir)
850+
expect(result.offset).toBe(0)
851+
expect(result.limit).toBe(20)
852+
expect(result.total).toBe(3)
853+
expect(result.traces).toHaveLength(3)
854+
})
855+
})
856+
740857
// ---------------------------------------------------------------------------
741858
// Edge cases — schema integrity
742859
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)