From 9a0067ffe90a39d55164fbfabde46d771b095eb3 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Thu, 30 Jul 2026 22:47:11 -0400 Subject: [PATCH] Wrap long lines in linear time in the trace viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrapVisibleLine re-measured the whole remaining string for every emitted row, so one 32KiB unbroken tool payload cost ~330ms per card render — every keystroke in /traces re-renders every card, making large-payload traces feel frozen. Tokenize once and walk the units with a cursor (~40x faster on real websearch traces). Signed-off-by: Chad Hietala --- .changeset/quick-lines-wrap.md | 5 ++ packages/eve/src/cli/ui/terminal-text.test.ts | 15 +++++ packages/eve/src/cli/ui/terminal-text.ts | 58 +++++++++++++++---- 3 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 .changeset/quick-lines-wrap.md diff --git a/.changeset/quick-lines-wrap.md b/.changeset/quick-lines-wrap.md new file mode 100644 index 000000000..c3ddc6132 --- /dev/null +++ b/.changeset/quick-lines-wrap.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Terminal text wrapping in the dev TUI is now linear-time, so views rendering large single-line payloads (long tool results, big JSON) no longer stall on every repaint. diff --git a/packages/eve/src/cli/ui/terminal-text.test.ts b/packages/eve/src/cli/ui/terminal-text.test.ts index cbcd4668c..12f938f94 100644 --- a/packages/eve/src/cli/ui/terminal-text.test.ts +++ b/packages/eve/src/cli/ui/terminal-text.test.ts @@ -61,6 +61,21 @@ describe("editable input geometry", () => { expect(wrapVisibleLine("\x1b[31m🇺🇸\x1b[0m", 1)).toEqual(["\x1b[31m🇺🇸\x1b[0m"]); }); + it("word-wraps on the last space that fits and collapses the break whitespace", () => { + expect(wrapVisibleLine("alpha beta gamma", 10)).toEqual(["alpha beta", "gamma"]); + expect(wrapVisibleLine("alpha beta gamma", 6)).toEqual(["alpha", "beta", "gamma"]); + expect(wrapVisibleLine("abcdefghij", 4)).toEqual(["abcd", "efgh", "ij"]); + }); + + it("wraps a long unbreakable payload in linear time", () => { + const line = JSON.stringify({ excerpts: "x".repeat(64 * 1024) }); + const start = performance.now(); + const lines = wrapVisibleLine(line, 120); + expect(performance.now() - start).toBeLessThan(500); + expect(lines.length).toBeGreaterThan(500); + expect(lines.join("")).toBe(line); + }); + it("maps terminal columns to grapheme boundaries", () => { expect(offsetAtVisibleColumn("界x", 2)).toBe(1); expect(offsetAtVisibleColumn("e\u0301x", 1)).toBe(2); diff --git a/packages/eve/src/cli/ui/terminal-text.ts b/packages/eve/src/cli/ui/terminal-text.ts index 9369b1c73..9acdfb12d 100644 --- a/packages/eve/src/cli/ui/terminal-text.ts +++ b/packages/eve/src/cli/ui/terminal-text.ts @@ -43,14 +43,17 @@ export function visibleLength(input: string): number { } export function sliceVisible(input: string, width: number): string { + return sliceUnits(terminalTextUnits(input), 0, width); +} + +function sliceUnits(units: readonly TerminalTextUnit[], from: number, width: number): string { if (width <= 0) { return ""; } let output = ""; let visible = 0; - const units = terminalTextUnits(input); - let index = 0; + let index = from; while (index < units.length && visible < width) { const unit = units[index]!; if (unit.width > 0 && visible + unit.width > width) break; @@ -169,6 +172,10 @@ function terminalTextUnits(input: string): TerminalTextUnit[] { * Word-wraps a single logical line to `width` visible columns, preserving * ANSI styling and never splitting inside an escape sequence. Breaks on the * last space that fits; falls back to a hard cut for unbreakable runs. + * + * Tokenizes once and walks the units with a cursor, so a long unbroken + * payload wraps in linear time instead of re-measuring the remainder on + * every emitted row. */ export function wrapVisibleLine(line: string, width: number): string[] { if (width <= 0) { @@ -179,33 +186,60 @@ export function wrapVisibleLine(line: string, width: number): string[] { return [""]; } - const lines: string[] = []; - let remaining = line; + const units = terminalTextUnits(line); + let remainingWidth = 0; + for (const unit of units) remainingWidth += unit.width; - while (visibleLength(remaining) > width) { - const breakAt = findVisibleBreakPoint(remaining, width); - lines.push(remaining.slice(0, breakAt).trimEnd()); - remaining = remaining.slice(breakAt).trimStart(); + const lines: string[] = []; + let unitIndex = 0; + let charOffset = 0; + + const advanceTo = (targetOffset: number): void => { + while (charOffset < targetOffset && unitIndex < units.length) { + const unit = units[unitIndex]!; + charOffset += unit.text.length; + remainingWidth -= unit.width; + unitIndex += 1; + } + }; + + while (remainingWidth > width) { + const breakAt = findVisibleBreakPoint(units, unitIndex, width); + lines.push(line.slice(charOffset, charOffset + breakAt).trimEnd()); + advanceTo(charOffset + breakAt); + while (unitIndex < units.length) { + const unit = units[unitIndex]!; + if (unit.ansi || unit.text.trimStart().length > 0) break; + charOffset += unit.text.length; + remainingWidth -= unit.width; + unitIndex += 1; + } } + const remaining = line.slice(charOffset); if (remaining.length > 0 || lines.length === 0) lines.push(remaining); return lines; } -function findVisibleBreakPoint(input: string, width: number): number { - const slice = sliceVisible(input, width + 1); +function findVisibleBreakPoint( + units: readonly TerminalTextUnit[], + from: number, + width: number, +): number { + const slice = sliceUnits(units, from, width + 1); const lastSpace = slice.lastIndexOf(" "); if (lastSpace > 0) { return lastSpace; } - const hardSlice = sliceVisible(input, width); + const hardSlice = sliceUnits(units, from, width); if (visibleLength(hardSlice) > 0) return hardSlice.length; let offset = 0; let foundVisibleUnit = false; - for (const unit of terminalTextUnits(input)) { + for (let index = from; index < units.length; index += 1) { + const unit = units[index]!; if (foundVisibleUnit && unit.width > 0) return offset; offset += unit.text.length; if (unit.width > 0) foundVisibleUnit = true;