fix(terminal): preserve selection highlight by buffering after selection exists

Buffer incoming terminal.write() only after onSelectionChange reports
a non-empty selection — NOT during mousedown. This lets xterm.js
render the selection highlight normally, then prevents incoming data
from clearing it. The buffer flushes when the user clears the
selection (click/keypress) or after a 5s safety timeout.

Previous attempts broke because they buffered during mousedown which
prevented xterm.js from rendering the highlight in the first place.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-03-02 01:08:24 +05:30
parent fb5e0c9646
commit 09e2c2b540
1 changed files with 34 additions and 6 deletions

View File

@ -189,13 +189,36 @@ export function DirectTerminal({
const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801";
const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`;
// Auto-copy selection to clipboard whenever the user selects text.
// xterm.js clears the selection on incoming terminal.write(), so we
// copy eagerly — the text is in the clipboard even if the highlight
// disappears before the user presses Cmd+C.
// ── Preserve selection while terminal receives output ────────
// xterm.js clears the selection on every terminal.write(). We
// buffer incoming data while a selection is active so the
// highlight stays visible for Cmd+C. The buffer is flushed
// when the selection is cleared (click, keypress, etc.).
const writeBuffer: string[] = [];
let selectionActive = false;
let safetyTimer: ReturnType<typeof setTimeout> | null = null;
const flushWriteBuffer = () => {
if (safetyTimer) { clearTimeout(safetyTimer); safetyTimer = null; }
if (writeBuffer.length > 0) {
terminal.write(writeBuffer.join(""));
writeBuffer.length = 0;
}
};
const selectionDisposable = terminal.onSelectionChange(() => {
if (terminal.hasSelection()) {
navigator.clipboard?.writeText(terminal.getSelection()).catch(() => {});
selectionActive = true;
// Safety: flush after 5s to prevent unbounded buffering
if (!safetyTimer) {
safetyTimer = setTimeout(() => {
selectionActive = false;
flushWriteBuffer();
}, 5_000);
}
} else {
selectionActive = false;
flushWriteBuffer();
}
});
@ -292,7 +315,11 @@ export function DirectTerminal({
websocket.onmessage = (event) => {
const data =
typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
terminal.write(data);
if (selectionActive) {
writeBuffer.push(data);
} else {
terminal.write(data);
}
};
websocket.onerror = (event) => {
@ -330,6 +357,7 @@ export function DirectTerminal({
// Store cleanup function to be called from useEffect cleanup
cleanup = () => {
selectionDisposable.dispose();
if (safetyTimer) clearTimeout(safetyTimer);
termEl.removeEventListener("paste", handlePaste as EventListener);
window.removeEventListener("resize", handleResize);
inputDisposable?.dispose();