Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
262 lines
8.5 KiB
TypeScript
262 lines
8.5 KiB
TypeScript
import type { Terminal as XTerm } from "@xterm/xterm";
|
|
|
|
import { forceSyncRenderAfterResize } from "../terminalHelpers";
|
|
import {
|
|
isTerminalAlternateScreenActive,
|
|
refreshTerminalViewport,
|
|
} from "../terminalHibernateRuntime";
|
|
import {
|
|
flushTerminalWriteCoalescer,
|
|
getTerminalWriteCoalescerPendingBytes,
|
|
} from "./terminalWriteCoalescer";
|
|
import {
|
|
enqueueTerminalWrite,
|
|
flushTerminalWriteQueueBypassingTimers,
|
|
hasPendingTerminalWriteQueueWork,
|
|
} from "./terminalWriteQueue";
|
|
|
|
const UNFOCUSED_REPAINT_DEBOUNCE_MS = 16;
|
|
const UNFOCUSED_FLUSH_DEBOUNCE_MS = 67;
|
|
export const TERMINAL_WRITE_SETTLE_TIMEOUT_MS = 750;
|
|
const TERMINAL_WRITE_SETTLE_POLL_MS = 8;
|
|
const unfocusedRepaintTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
|
|
const unfocusedFlushTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
|
|
|
|
type XTermWithPrivateWriteBuffer = XTerm & {
|
|
_core?: {
|
|
_writeBuffer?: {
|
|
_bufferOffset?: number;
|
|
_pendingData?: number;
|
|
_writeBuffer?: Array<string | Uint8Array>;
|
|
};
|
|
};
|
|
};
|
|
|
|
export function isTerminalWindowUnfocusedButVisible(): boolean {
|
|
if (typeof document === "undefined") return false;
|
|
return document.visibilityState === "visible" && !document.hasFocus();
|
|
}
|
|
|
|
export function isTerminalPageHidden(): boolean {
|
|
if (typeof document === "undefined") return false;
|
|
return document.visibilityState !== "visible";
|
|
}
|
|
|
|
export function shouldFlushTerminalWritesForBackgroundOutput(isPaneVisible: boolean): boolean {
|
|
// Hidden panes should keep their xterm buffer current so tab switches do not
|
|
// reveal a delayed replay of long-running output (#1985).
|
|
if (!isPaneVisible) return true;
|
|
// Fully page-hidden documents throttle rAF hard; force the background drain
|
|
// path so backlog does not sit until the tab is shown again (#1880).
|
|
// Unfocused-but-still-visible windows (Alt+Tab, second monitor) keep normal
|
|
// coalescing + maybeFlush… throttling so alt-screen frames and log batching
|
|
// are not destroyed by a per-chunk force flush.
|
|
return isTerminalPageHidden();
|
|
}
|
|
|
|
function getPendingTerminalWriteBufferBytes(term: XTerm): number {
|
|
const writeBuffer = (term as XTermWithPrivateWriteBuffer)._core?._writeBuffer;
|
|
if (!writeBuffer) return 0;
|
|
|
|
if (
|
|
typeof writeBuffer._pendingData === "number"
|
|
&& Number.isFinite(writeBuffer._pendingData)
|
|
&& writeBuffer._pendingData > 0
|
|
) {
|
|
return writeBuffer._pendingData;
|
|
}
|
|
|
|
const buffer = writeBuffer._writeBuffer;
|
|
if (!Array.isArray(buffer) || buffer.length === 0) return 0;
|
|
const offset = typeof writeBuffer._bufferOffset === "number"
|
|
&& Number.isFinite(writeBuffer._bufferOffset)
|
|
? Math.max(0, writeBuffer._bufferOffset)
|
|
: 0;
|
|
|
|
let bytes = 0;
|
|
for (let index = Math.min(offset, buffer.length); index < buffer.length; index += 1) {
|
|
const chunk = buffer[index];
|
|
if (typeof chunk === "string") {
|
|
bytes += chunk.length;
|
|
} else if (chunk instanceof Uint8Array) {
|
|
bytes += chunk.byteLength;
|
|
}
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
export function hasPendingTerminalWrites(term: XTerm): boolean {
|
|
return (
|
|
getTerminalWriteCoalescerPendingBytes(term) > 0
|
|
|| hasPendingTerminalWriteQueueWork(term)
|
|
|| getPendingTerminalWriteBufferBytes(term) > 0
|
|
);
|
|
}
|
|
|
|
export function forceTerminalRepaintBypassingAnimationFrame(term: XTerm): void {
|
|
if (isTerminalAlternateScreenActive(term)) {
|
|
refreshTerminalViewport(term);
|
|
}
|
|
forceSyncRenderAfterResize(term);
|
|
}
|
|
|
|
type RevealFrameScheduler = (callback: () => void) => void;
|
|
|
|
const scheduleRevealFrame: RevealFrameScheduler | undefined =
|
|
typeof globalThis.requestAnimationFrame === "function"
|
|
? (callback) => { globalThis.requestAnimationFrame(() => callback()); }
|
|
: undefined;
|
|
|
|
export function repaintTerminalAfterReveal(
|
|
term: XTerm,
|
|
shouldRepaint: () => boolean = () => true,
|
|
scheduleFrame: RevealFrameScheduler | undefined = scheduleRevealFrame,
|
|
): void {
|
|
// The layout-effect pass makes the tab feel immediate, but on Windows the
|
|
// compositor can still treat a just-revealed WebGL canvas as hidden and
|
|
// discard this draw. Repeat once in the first visible browser frame so the
|
|
// final rows and cursor are guaranteed to reach the screen (#1985).
|
|
forceTerminalRepaintBypassingAnimationFrame(term);
|
|
scheduleFrame?.(() => {
|
|
if (!shouldRepaint()) return;
|
|
forceTerminalRepaintBypassingAnimationFrame(term);
|
|
});
|
|
}
|
|
|
|
export function scheduleTerminalRepaintWhenUnfocused(term: XTerm): void {
|
|
if (!isTerminalWindowUnfocusedButVisible()) return;
|
|
|
|
if (unfocusedRepaintTimers.has(term)) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
unfocusedRepaintTimers.delete(term);
|
|
if (!isTerminalWindowUnfocusedButVisible()) return;
|
|
forceTerminalRepaintBypassingAnimationFrame(term);
|
|
}, UNFOCUSED_REPAINT_DEBOUNCE_MS);
|
|
unfocusedRepaintTimers.set(term, timer);
|
|
}
|
|
|
|
export function cancelScheduledUnfocusedRepaint(term: XTerm): void {
|
|
const timer = unfocusedRepaintTimers.get(term);
|
|
if (timer !== undefined) {
|
|
clearTimeout(timer);
|
|
unfocusedRepaintTimers.delete(term);
|
|
}
|
|
|
|
const flushTimer = unfocusedFlushTimers.get(term);
|
|
if (flushTimer === undefined) return;
|
|
clearTimeout(flushTimer);
|
|
unfocusedFlushTimers.delete(term);
|
|
}
|
|
|
|
export function flushPendingTerminalWritesOnResume(term: XTerm): void {
|
|
flushTerminalWriteCoalescer(term);
|
|
flushTerminalWriteQueueBypassingTimers(term);
|
|
}
|
|
|
|
export function writeLocalTerminalDataInOrder(
|
|
term: XTerm,
|
|
data: string,
|
|
capture?: (data: string) => void,
|
|
): void {
|
|
if (!data) return;
|
|
// Hidden-pane PTY output may still be waiting in the coalescer. Drain it
|
|
// into the ordered queue before appending local echo. Do not bypass queue
|
|
// yields for every echoed character during a large output burst.
|
|
flushTerminalWriteCoalescer(term);
|
|
enqueueTerminalWrite(term, 0, (done) => {
|
|
capture?.(data);
|
|
term.write(data, done);
|
|
});
|
|
}
|
|
|
|
const waitForTerminalWriteCallbacks = (delayMs: number): Promise<void> =>
|
|
new Promise((resolve) => {
|
|
setTimeout(resolve, delayMs);
|
|
});
|
|
|
|
export async function flushPendingTerminalWritesBeforeHibernate(
|
|
term: XTerm,
|
|
timeoutMs: number = TERMINAL_WRITE_SETTLE_TIMEOUT_MS,
|
|
): Promise<boolean> {
|
|
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
while (true) {
|
|
flushTerminalWriteCoalescer(term);
|
|
flushTerminalWriteQueueBypassingTimers(term);
|
|
|
|
if (!hasPendingTerminalWrites(term)) {
|
|
return true;
|
|
}
|
|
|
|
const remainingMs = deadline - Date.now();
|
|
if (remainingMs <= 0) break;
|
|
await waitForTerminalWriteCallbacks(Math.min(TERMINAL_WRITE_SETTLE_POLL_MS, remainingMs));
|
|
}
|
|
|
|
flushTerminalWriteCoalescer(term);
|
|
flushTerminalWriteQueueBypassingTimers(term);
|
|
return !hasPendingTerminalWrites(term);
|
|
}
|
|
|
|
type TerminalOutputPauseBackend = {
|
|
acquireSessionFlowPauseLease: (sessionId: string) => Promise<{
|
|
release(options?: { keepPaused?: boolean }): void;
|
|
waitForPause(): Promise<unknown>;
|
|
}>;
|
|
};
|
|
|
|
/**
|
|
* Run a synchronous terminal operation only after pending writes have settled,
|
|
* while keeping the backend source paused across the operation.
|
|
*
|
|
* Draining xterm can cross the renderer flow controller's low watermark. The
|
|
* main-process lease keeps that automatic resume from overriding another
|
|
* window's resize, snapshot, or handoff operation.
|
|
*/
|
|
export async function runWithTerminalOutputPausedAfterWritesSettle(
|
|
term: XTerm,
|
|
sessionId: string | null,
|
|
backend: TerminalOutputPauseBackend,
|
|
operation: () => void,
|
|
shouldResumeBackend: () => boolean = () => true,
|
|
): Promise<boolean> {
|
|
const lease = sessionId
|
|
? await backend.acquireSessionFlowPauseLease(sessionId)
|
|
: null;
|
|
try {
|
|
if (lease) {
|
|
try {
|
|
await lease.waitForPause();
|
|
} catch {
|
|
// Acquiring the main-process lease already paused the source. Continue
|
|
// with the local drain if the renderer acknowledgement path is absent.
|
|
}
|
|
}
|
|
|
|
const settled = await flushPendingTerminalWritesBeforeHibernate(term);
|
|
if (!settled) return false;
|
|
|
|
operation();
|
|
return true;
|
|
} finally {
|
|
lease?.release({ keepPaused: !shouldResumeBackend() });
|
|
}
|
|
}
|
|
|
|
export function maybeFlushTerminalWriteCoalescerWhenUnfocused(
|
|
term: XTerm,
|
|
isPaneVisible: boolean,
|
|
): void {
|
|
// Hidden pane / page-hidden use the background drain path in writeSessionData.
|
|
if (!isPaneVisible || isTerminalPageHidden()) return;
|
|
if (!isTerminalWindowUnfocusedButVisible()) return;
|
|
if (unfocusedFlushTimers.has(term)) return;
|
|
|
|
const timer = setTimeout(() => {
|
|
unfocusedFlushTimers.delete(term);
|
|
if (!isTerminalWindowUnfocusedButVisible()) return;
|
|
flushTerminalWriteCoalescer(term);
|
|
}, UNFOCUSED_FLUSH_DEBOUNCE_MS);
|
|
unfocusedFlushTimers.set(term, timer);
|
|
}
|