diff --git a/.changeset/trace-viewer-geometry.md b/.changeset/trace-viewer-geometry.md new file mode 100644 index 000000000..7e20648d6 --- /dev/null +++ b/.changeset/trace-viewer-geometry.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Derive the `/traces` conversation viewer's line geometry from one prefix-sum helper so scroll, click, and wheel math cannot disagree, and reset the view through a single factory when the viewed trace changes. diff --git a/packages/eve/src/cli/dev/tui/traces/trace-view.ts b/packages/eve/src/cli/dev/tui/traces/trace-view.ts index 83abd9d06..d7431c437 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-view.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-view.ts @@ -75,9 +75,9 @@ export function renderTraceViewer( const rows: string[] = []; rows.push(...renderHeader(state, options)); if (state.traces.length === 0) { - rows.push(...padBody(renderEmptyState(options, theme, bodyRows), bodyRows)); + rows.push(...renderEmptyState(options, theme, bodyRows)); } else if (state.trace === undefined) { - rows.push(...padBody(center(bodyRows, theme.colors.dim("Loading trace…")), bodyRows)); + rows.push(...center(bodyRows, theme.colors.dim("Loading trace…"))); } else { const body = renderConversation(state, options, timelineWidth, bodyRows); for (let index = 0; index < bodyRows; index += 1) { @@ -86,8 +86,6 @@ export function renderTraceViewer( rows.push(left); continue; } - // No divider: the base canvas separates the cards and drawer by itself. - const separator = " "; let panelLine = detailLines[state.panelScroll + index] ?? ""; if (state.textSelection !== undefined && state.textSelection.region === "panel") { panelLine = highlightSelection( @@ -97,7 +95,7 @@ export function renderTraceViewer( panelWidth, ); } - rows.push(joinPanels(left, timelineWidth, separator, panelLine, width)); + rows.push(joinPanels(left, timelineWidth, panelLine, width)); } } rows.push(...renderFooter(state, width, theme)); @@ -435,35 +433,26 @@ function renderEmptyState( options.tracingDisabled === true ? ["Tracing is disabled (EVE_TRACES=off).", "Re-enable it and spans stream in live."] : ["No local traces yet.", "Chat with your agent — spans stream in live."]; - const lines = Array.from({ length: bodyRows }, () => ""); - const top = Math.max(0, Math.floor(bodyRows / 2) - 1); - if (top < bodyRows) lines[top] = theme.colors.bold(first); - if (top + 1 < bodyRows) lines[top + 1] = theme.colors.dim(second); - return lines; + return center(bodyRows, theme.colors.bold(first), theme.colors.dim(second)); } -function center(bodyRows: number, line: string): string[] { +/** Fills a `bodyRows`-tall body, centering `lines` as a block within it. */ +function center(bodyRows: number, ...lines: string[]): string[] { + const rows = Array.from({ length: bodyRows }, () => ""); const top = Math.max(0, Math.floor(bodyRows / 2) - 1); - const lines = Array.from({ length: bodyRows }, () => ""); - if (top < lines.length) lines[top] = line; - return lines; -} - -function padBody(lines: readonly string[], bodyRows: number): string[] { - const padded = [...lines]; - while (padded.length < bodyRows) padded.push(""); - return padded.slice(0, bodyRows); + lines.forEach((line, index) => { + if (top + index < bodyRows) rows[top + index] = line; + }); + return rows; } -function joinPanels( - left: string, - leftWidth: number, - separator: string, - right: string, - width: number, -): string { +/** + * Joins one conversation row to its drawer row. The single separating column + * carries no divider: the base canvas already separates the two. + */ +function joinPanels(left: string, leftWidth: number, right: string, width: number): string { const rightWidth = Math.max(0, width - leftWidth - 1); - return `${padTo(left, leftWidth)}${separator}${clipVisible(right, rightWidth)}`; + return `${padTo(left, leftWidth)} ${clipVisible(right, rightWidth)}`; } function joinRight(left: string, right: string, width: number): string { diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts index 506fef434..a036af712 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts @@ -11,7 +11,11 @@ import type { TraceStore, TraceStoreEntry } from "./trace-store.js"; import { createTraceStore } from "./trace-store.js"; import { conversationItemLineCount } from "./trace-conversation.js"; import { conversationSelectionText, panelSelectionText, renderTraceViewer } from "./trace-view.js"; -import type { TextSelectionRange, TraceViewerState } from "./trace-viewer-state.js"; +import type { + TextSelectionRange, + TraceViewerKeyEnvironment, + TraceViewerState, +} from "./trace-viewer-state.js"; import { applyLoadedTrace, applyTraceList, @@ -68,13 +72,12 @@ export class TraceViewerSession { #disposed = false; #refreshing = false; #refreshAgain = false; - #metrics: { - timelineViewportRows: number; - panelViewportRows: number; - panelTotalRows: number; - contentWidth: number; - conversationLineCounts?: readonly number[]; - } = { timelineViewportRows: 0, panelViewportRows: 0, panelTotalRows: 0, contentWidth: 0 }; + #metrics: TraceViewerKeyEnvironment = { + timelineViewportRows: 0, + panelViewportRows: 0, + panelTotalRows: 0, + contentWidth: 0, + }; constructor(options: TraceViewerSessionOptions) { this.#options = options; diff --git a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts index 6a20a6ca0..a90836965 100644 --- a/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts +++ b/packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts @@ -106,9 +106,28 @@ export interface TraceViewerKeyResult { } export function createTraceViewerState(): TraceViewerState { + return { ...clearedTraceView(), traces: [], traceIndex: 0 }; +} + +/** + * The view fields a different trace resets: nothing loaded, nothing expanded, + * selected, scrolled, or open. A fresh set each call, since each state owns + * its own expansion set. + */ +function clearedTraceView(): Pick< + TraceViewerState, + | "conversationItems" + | "expandedItems" + | "panelFocus" + | "panelOpen" + | "panelScroll" + | "scrollRow" + | "selectedRow" + | "textSelection" + | "trace" +> { return { - traces: [], - traceIndex: 0, + trace: undefined, conversationItems: [], expandedItems: new Set(), selectedRow: 0, @@ -116,6 +135,7 @@ export function createTraceViewerState(): TraceViewerState { panelOpen: false, panelFocus: false, panelScroll: 0, + textSelection: undefined, }; } @@ -160,21 +180,7 @@ export function applyTraceList( ? undefined : state.notice; if (!switched) return { ...state, traces: entries, traceIndex, notice }; - return { - ...state, - traces: entries, - traceIndex, - notice, - trace: undefined, - conversationItems: [], - expandedItems: new Set(), - selectedRow: 0, - scrollRow: 0, - panelOpen: false, - panelFocus: false, - panelScroll: 0, - textSelection: undefined, - }; + return { ...state, ...clearedTraceView(), traces: entries, traceIndex, notice }; } /** @@ -390,19 +396,7 @@ function switchTrace(state: TraceViewerState, delta: number): TraceViewerState { if (state.traces.length === 0) return state; const traceIndex = Math.min(Math.max(0, state.traceIndex + delta), state.traces.length - 1); if (traceIndex === state.traceIndex) return state; - return { - ...state, - traceIndex, - trace: undefined, - conversationItems: [], - expandedItems: new Set(), - selectedRow: 0, - scrollRow: 0, - panelOpen: false, - panelFocus: false, - panelScroll: 0, - textSelection: undefined, - }; + return { ...state, ...clearedTraceView(), traceIndex }; } function moveSelection( @@ -422,6 +416,26 @@ function moveSelection( } /** First line offset that keeps the selected card's last line visible. */ +/** + * First body line of each card, plus the conversation's total line count as a + * final entry. Every card is followed by one separator row, and a card with no + * measured height counts as one line, so a frame measured before a repaint + * still maps rows to cards. + */ +function cardLineOffsets(state: TraceViewerState, counts: readonly number[]): number[] { + const offsets = [0]; + for (let index = 0; index < state.conversationItems.length; index += 1) { + offsets.push(offsets[index]! + (counts[index] ?? 1) + 1); + } + return offsets; +} + +/** Lines the whole conversation paints, separators included. */ +function conversationLineTotal(state: TraceViewerState, counts: readonly number[]): number { + const offsets = cardLineOffsets(state, counts); + return offsets[offsets.length - 1]!; +} + function conversationScrollRow( state: TraceViewerState, environment: TraceViewerKeyEnvironment, @@ -429,11 +443,8 @@ function conversationScrollRow( const counts = environment.conversationLineCounts; if (counts === undefined) return state.scrollRow; const viewportRows = environment.timelineViewportRows; - // Line range of the selected card (card lines + separator after each). - let cardStart = 0; - for (let index = 0; index < state.selectedRow; index += 1) { - cardStart += (counts[index] ?? 1) + 1; - } + const offsets = cardLineOffsets(state, counts); + const cardStart = offsets[state.selectedRow] ?? 0; const cardHeight = counts[state.selectedRow] ?? 1; const cardEnd = cardStart + cardHeight; // Already visible? Keep the current offset. @@ -464,8 +475,7 @@ function conversationCellAt( if (bodyIndex < 0 || bodyIndex >= environment.timelineViewportRows) return undefined; const column = x - 1; if (column < 0 || column >= environment.contentWidth) return undefined; - let totalLines = 0; - for (const count of counts) totalLines += count + 1; + const totalLines = conversationLineTotal(state, counts); const line = state.scrollRow + bodyIndex; if (line >= totalLines) return undefined; return { column, line }; @@ -517,11 +527,9 @@ function conversationItemAtBodyRow( const counts = environment.conversationLineCounts; if (counts === undefined) return undefined; const absoluteLine = state.scrollRow + bodyRow; - let line = 0; + const offsets = cardLineOffsets(state, counts); for (let index = 0; index < state.conversationItems.length; index += 1) { - const cardHeight = (counts[index] ?? 1) + 1; // card lines + separator - if (absoluteLine < line + cardHeight) return index; - line += cardHeight; + if (absoluteLine < offsets[index + 1]!) return index; } return undefined; } @@ -535,10 +543,7 @@ function scrollConversation( const counts = environment.conversationLineCounts; if (counts === undefined || state.conversationItems.length === 0) return state; const viewportRows = environment.timelineViewportRows; - // Total lines including separators. - let totalLines = 0; - for (const count of counts) totalLines += count + 1; - const maxScroll = Math.max(0, totalLines - viewportRows); + const maxScroll = Math.max(0, conversationLineTotal(state, counts) - viewportRows); const scrollRow = Math.min(maxScroll, Math.max(0, state.scrollRow + delta)); if (scrollRow === state.scrollRow) return state; // The selection stays on the same card even when it scrolls out of view;