feat(tui): treat paste markers as atomic segments in editor (#2111)

* feat(tui): treat paste markers as atomic segments in editor

Paste markers like `[paste #1 +123 lines]` are now treated as single
atomic units for cursor movement, word navigation, deletion, and
wrapping. Only markers with valid paste IDs (present in the editor's
pastes Map) are treated atomically.

* fix(tui): word-wrap oversized atomic segments in editor
This commit is contained in:
xu0o0
2026-03-14 00:42:59 +08:00
committed by GitHub
parent adce496f63
commit bd2c3ab67e
2 changed files with 554 additions and 22 deletions

View File

@@ -892,6 +892,128 @@ describe("Editor component", () => {
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
it("splits oversized atomic segment across multiple chunks", () => {
// Simulate a paste marker wider than maxWidth by passing pre-segmented data
const marker = "[paste #1 +20 lines]"; // 21 chars
const line = `A${marker}B`;
const segments: Intl.SegmentData[] = [
{ segment: "A", index: 0, input: line },
{ segment: marker, index: 1, input: line },
{ segment: "B", index: 1 + marker.length, input: line },
];
const chunks = wordWrapLine(line, 10, segments);
// Every chunk must fit within maxWidth
for (const chunk of chunks) {
assert.ok(
visibleWidth(chunk.text) <= 10,
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
);
}
// Verify no content is lost
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
it("splits oversized atomic segment at start of line", () => {
const marker = "[paste #1 +20 lines]"; // 21 chars
const line = `${marker}B`;
const segments: Intl.SegmentData[] = [
{ segment: marker, index: 0, input: line },
{ segment: "B", index: marker.length, input: line },
];
const chunks = wordWrapLine(line, 10, segments);
for (const chunk of chunks) {
assert.ok(visibleWidth(chunk.text) <= 10);
}
// "B" ends up on the last line (either alone or with the marker tail)
assert.strictEqual(chunks[chunks.length - 1]!.text.includes("B"), true);
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
it("splits oversized atomic segment at end of line", () => {
const marker = "[paste #1 +20 lines]"; // 21 chars
const line = `A${marker}`;
const segments: Intl.SegmentData[] = [
{ segment: "A", index: 0, input: line },
{ segment: marker, index: 1, input: line },
];
const chunks = wordWrapLine(line, 10, segments);
for (const chunk of chunks) {
assert.ok(visibleWidth(chunk.text) <= 10);
}
assert.strictEqual(chunks[0]!.text, "A");
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
it("splits consecutive oversized atomic segments", () => {
const m1 = "[paste #1 +20 lines]"; // 21 chars
const m2 = "[paste #2 +30 lines]"; // 21 chars
const line = `${m1}${m2}`;
const segments: Intl.SegmentData[] = [
{ segment: m1, index: 0, input: line },
{ segment: m2, index: m1.length, input: line },
];
const chunks = wordWrapLine(line, 10, segments);
for (const chunk of chunks) {
assert.ok(
visibleWidth(chunk.text) <= 10,
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
);
}
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
it("wraps normally after oversized atomic segment", () => {
const marker = "[paste #1 +20 lines]"; // 21 chars
const line = `${marker} hello world`;
const segments: Intl.SegmentData[] = [
{ segment: marker, index: 0, input: line },
{ segment: " ", index: marker.length, input: line },
{ segment: "h", index: marker.length + 1, input: line },
{ segment: "e", index: marker.length + 2, input: line },
{ segment: "l", index: marker.length + 3, input: line },
{ segment: "l", index: marker.length + 4, input: line },
{ segment: "o", index: marker.length + 5, input: line },
{ segment: " ", index: marker.length + 6, input: line },
{ segment: "w", index: marker.length + 7, input: line },
{ segment: "o", index: marker.length + 8, input: line },
{ segment: "r", index: marker.length + 9, input: line },
{ segment: "l", index: marker.length + 10, input: line },
{ segment: "d", index: marker.length + 11, input: line },
];
const chunks = wordWrapLine(line, 10, segments);
// All chunks must fit
for (const chunk of chunks) {
assert.ok(
visibleWidth(chunk.text) <= 10,
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
);
}
// Last chunk should contain "world" (normal wrapping resumes)
assert.strictEqual(chunks[chunks.length - 1]!.text, "world");
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
assert.strictEqual(reconstructed, line);
});
});
describe("Kill ring", () => {
@@ -2989,4 +3111,282 @@ describe("Editor component", () => {
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 });
});
});
describe("Paste marker atomic behavior", () => {
/** Helper: simulate a large paste that creates a marker */
function pasteWithMarker(editor: Editor): string {
const bigContent = "line\n".repeat(20).trimEnd(); // 20 lines
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
// The editor replaces large pastes with a marker like "[paste #1 +20 lines]"
return editor.getText();
}
it("creates a paste marker for large pastes", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
const text = pasteWithMarker(editor);
assert.match(text, /\[paste #\d+ \+\d+ lines\]/);
});
it("treats paste marker as single unit for right arrow", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("A");
pasteWithMarker(editor);
editor.handleInput("B");
// Text: "A[paste #1 +20 lines]B", cursor at end
// Go to start
editor.handleInput("\x01"); // Ctrl+A
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
// Right arrow: should move past "A"
editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
// Right arrow: should skip the entire marker
editor.handleInput("\x1b[C");
const marker = editor.getText().match(/\[paste #\d+ \+\d+ lines\]/)![0];
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
// Right arrow: should move past "B"
editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length + 1 });
});
it("treats paste marker as single unit for left arrow", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("A");
pasteWithMarker(editor);
editor.handleInput("B");
// Cursor at end
// Left arrow: past "B"
editor.handleInput("\x1b[D");
const text = editor.getText();
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
// Left arrow: skip the entire marker
editor.handleInput("\x1b[D");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
// Left arrow: past "A"
editor.handleInput("\x1b[D");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
});
it("treats paste marker as single unit for backspace", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("A");
pasteWithMarker(editor);
editor.handleInput("B");
const text = editor.getText();
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
// Position cursor right after the marker (before "B")
editor.handleInput("\x01"); // Ctrl+A
// Move past "A" and the marker
editor.handleInput("\x1b[C"); // past "A"
editor.handleInput("\x1b[C"); // past marker
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
// Backspace: should delete the entire marker at once
editor.handleInput("\x7f");
assert.strictEqual(editor.getText(), "AB");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
});
it("treats paste marker as single unit for forward delete", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("A");
pasteWithMarker(editor);
editor.handleInput("B");
// Position cursor on "A" (col 0) then move right once to be just before marker
editor.handleInput("\x01"); // Ctrl+A
editor.handleInput("\x1b[C"); // past "A", now at col 1 (start of marker)
// Forward delete: should delete the entire marker at once
editor.handleInput("\x1b[3~"); // Delete key
assert.strictEqual(editor.getText(), "AB");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
});
it("treats paste marker as single unit for word movement", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("X");
editor.handleInput(" ");
pasteWithMarker(editor);
editor.handleInput(" ");
editor.handleInput("Y");
// Text: "X [paste #1 +20 lines] Y"
const text = editor.getText();
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
// Go to start
editor.handleInput("\x01"); // Ctrl+A
// Ctrl+Right: skip "X"
editor.handleInput("\x1b[1;5C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
// Ctrl+Right: skip whitespace + marker (marker treated as single non-ws, non-punct unit)
editor.handleInput("\x1b[1;5C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 + marker.length });
});
it("undo restores marker after backspace deletion", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
editor.handleInput("A");
pasteWithMarker(editor);
editor.handleInput("B");
const textBefore = editor.getText();
// Position after marker
editor.handleInput("\x01");
editor.handleInput("\x1b[C"); // past A
editor.handleInput("\x1b[C"); // past marker
// Delete marker
editor.handleInput("\x7f");
assert.strictEqual(editor.getText(), "AB");
// Undo
editor.handleInput("\x1b[45;5u");
assert.strictEqual(editor.getText(), textBefore);
});
it("handles multiple paste markers in same line", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
pasteWithMarker(editor);
editor.handleInput(" ");
pasteWithMarker(editor);
const text = editor.getText();
const markers = [...text.matchAll(/\[paste #\d+ \+\d+ lines\]/g)];
assert.strictEqual(markers.length, 2);
// Go to start
editor.handleInput("\x01");
// Right arrow: should skip first marker atomically
editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length });
// Right arrow: past space
editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length + 1 });
// Right arrow: should skip second marker atomically
editor.handleInput("\x1b[C");
assert.deepStrictEqual(editor.getCursor(), {
line: 0,
col: markers[0]![0].length + 1 + markers[1]![0].length,
});
});
it("does not treat manually typed marker-like text as atomic (no valid paste ID)", () => {
const editor = new Editor(createTestTUI(), defaultEditorTheme);
// Type text that matches the pattern but was typed manually (no paste entry)
const fakeMarker = "[paste #99 +5 lines]";
for (const ch of fakeMarker) editor.handleInput(ch);
assert.strictEqual(editor.getText(), fakeMarker);
// No paste with ID 99 exists, so the marker is NOT treated atomically.
// Right arrow should move one grapheme at a time.
editor.handleInput("\x01"); // Ctrl+A
editor.handleInput("\x1b[C"); // Right
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); // Just past "["
});
it("does not crash when paste marker is wider than terminal width", () => {
// Reproduce: terminal width 8, paste marker "[paste #1 +47 lines]" (21 chars)
const tui = createTestTUI();
const editor = new Editor(tui, defaultEditorTheme);
const bigContent = "line\n".repeat(47).trimEnd();
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
const text = editor.getText();
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/);
assert.ok(marker, "paste marker should be created");
assert.ok(visibleWidth(marker[0]) > 8, "marker should be wider than render width");
// Render at very narrow width - should not throw
const lines = editor.render(8);
// Every rendered line must fit within the width (marker is split)
for (const line of lines) {
assert.ok(
visibleWidth(line) <= 8,
`line exceeds width 8: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
);
}
});
it("does not crash when text + paste marker exceeds terminal width with cursor on marker", () => {
// Reproduce: terminal width 54, text "b".repeat(35) + "[paste #1 +27 lines]" + "bbbb"
// Cursor lands on the paste marker after word-wrap, causing the rendered line
// to be 55 visible chars (1 over the width).
const tui = createTestTUI();
const editor = new Editor(tui, defaultEditorTheme);
// Type 35 'b' characters
for (let i = 0; i < 35; i++) editor.handleInput("b");
// Paste 27 lines
const bigContent = "line\n".repeat(27).trimEnd();
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
// Type a few more characters
for (let i = 0; i < 4; i++) editor.handleInput("b");
// Move cursor left to land on the paste marker
editor.handleInput("\x1b[D"); // past last 'b'
editor.handleInput("\x1b[D"); // past last 'b'
editor.handleInput("\x1b[D"); // past last 'b'
editor.handleInput("\x1b[D"); // past last 'b'
editor.handleInput("\x1b[D"); // now on the paste marker
// Render at width 54 - should not throw
const renderWidth = 54;
const lines = editor.render(renderWidth);
for (const line of lines) {
assert.ok(
visibleWidth(line) <= renderWidth,
`line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
);
}
});
it("wordWrapLine re-checks overflow after backtracking to wrap opportunity", () => {
// Reproduce crash #2: " " + "b".repeat(35) + atomic_marker(20 chars) + "bbbb"
// layoutWidth=53. After wrapping at the space, the remaining 35 b's + marker = 55
// must trigger a second force-break instead of silently overflowing.
const tui = createTestTUI();
const editor = new Editor(tui, defaultEditorTheme);
// Type a space, then 35 b's
editor.handleInput(" ");
for (let i = 0; i < 35; i++) editor.handleInput("b");
// Paste 27 lines to create marker
const bigContent = "line\n".repeat(27).trimEnd();
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
// Type trailing chars
for (let i = 0; i < 4; i++) editor.handleInput("b");
// Render at width 54 (contentWidth=54, layoutWidth=53 with paddingX=0)
const renderWidth = 54;
const lines = editor.render(renderWidth);
for (const line of lines) {
assert.ok(
visibleWidth(line) <= renderWidth,
`line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
);
}
});
});
});