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
5 changes: 5 additions & 0 deletions .changeset/trace-viewer-geometry.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 17 additions & 28 deletions packages/eve/src/cli/dev/tui/traces/trace-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand All @@ -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));
Expand Down Expand Up @@ -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 {
Expand Down
19 changes: 11 additions & 8 deletions packages/eve/src/cli/dev/tui/traces/trace-viewer-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
95 changes: 50 additions & 45 deletions packages/eve/src/cli/dev/tui/traces/trace-viewer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,36 @@ 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,
scrollRow: 0,
panelOpen: false,
panelFocus: false,
panelScroll: 0,
textSelection: undefined,
};
}

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

/**
Expand Down Expand Up @@ -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(
Expand All @@ -422,18 +416,35 @@ 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,
): number {
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.
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
Loading