Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/opencode/src/altimate/observability/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TraceFile | null> {
const tracesDir = dir ?? DEFAULT_TRACES_DIR
try {
Expand Down
42 changes: 35 additions & 7 deletions packages/opencode/src/cli/cmd/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 <session-id>" + UI.Style.TEXT_NORMAL)
// altimate_change end
}
Expand Down Expand Up @@ -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)",
Expand All @@ -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
}

Expand All @@ -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)
}

Expand Down
25 changes: 22 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <For>, 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 }> = []

Expand All @@ -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)
Expand Down Expand Up @@ -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
})

Expand All @@ -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
}
Expand Down
117 changes: 117 additions & 0 deletions packages/opencode/test/altimate/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<string>()
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
// ---------------------------------------------------------------------------
Expand Down
Loading