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/quick-lines-wrap.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/eve/src/cli/ui/terminal-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
58 changes: 46 additions & 12 deletions packages/eve/src/cli/ui/terminal-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
Loading