Skip to content
Open
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
78 changes: 70 additions & 8 deletions src/web/public/terminal-ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,7 +1151,7 @@ Object.assign(CodemanApp.prototype, {

if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
// content between 2026h/2026l and renders atomically. No need for
// client-side incomplete-block detection; just flush every frame.
Expand All @@ -1176,7 +1176,7 @@ Object.assign(CodemanApp.prototype, {
// Trigger a normal flush
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
Expand Down Expand Up @@ -1264,7 +1264,7 @@ Object.assign(CodemanApp.prototype, {
deferred = true;
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this._safeYield(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
Expand Down Expand Up @@ -1336,9 +1336,70 @@ Object.assign(CodemanApp.prototype, {
}
},

/**
* Schedule cb via THREE racing primitives so data-pacing makes progress
* regardless of which scheduling primitive Chrome is throttling:
* 1. requestAnimationFrame — primary, fires at compositor rate
* (may be 0Hz when window is occluded / on backgrounded monitor).
* 2. setTimeout(50) — fallback for occluded-but-visible windows
* (clamped to 1Hz by Chrome's intensive wake-up throttling
* after ~5 min of no user interaction).
* 3. Worker postMessage — bypasses intensive throttling entirely;
* Workers are not subject to background-tab / idle-tab throttling
* (the React Scheduler trick).
* Whichever fires first wins; the others are no-ops thanks to the
* `done` guard. Without all three, chunkedTerminalWrite and the deferred
* path of flushPendingWrites stall indefinitely when the substrate is
* degraded (visible-but-occluded window, OR idle-throttled tab, OR
* background tab on a different monitor).
*/
_safeYield(cb) {
let done = false;
const wrapped = () => {
if (done) return;
done = true;
cb();
};
requestAnimationFrame(wrapped);
setTimeout(wrapped, 50);
this._workerYield(wrapped);
},

/**
* Lazy-init a tiny "tick" worker whose only job is to postMessage back to
* us as fast as possible, escaping main-thread throttling. The worker's
* setTimeout(0) is not subject to Chrome's intensive wake-up throttling
* even when the parent tab is idle.
*/
_workerYield(cb) {
try {
if (this._yieldWorker === undefined) {
// First call: build the worker (or mark unavailable). Each
// postMessage in produces exactly one postMessage out — we count on
// FIFO 1:1 to drain queue entries.
const src = "onmessage=()=>setTimeout(()=>postMessage(0),0);";
const blob = new Blob([src], { type: 'application/javascript' });
const url = URL.createObjectURL(blob);
this._yieldWorker = new Worker(url);
URL.revokeObjectURL(url);
this._yieldQueue = [];
this._yieldWorker.onmessage = () => {
const fn = this._yieldQueue.shift();
if (fn) fn();
};
}
if (!this._yieldWorker) return;
this._yieldQueue.push(cb);
this._yieldWorker.postMessage(0);
} catch {
this._yieldWorker = null; // mark unavailable, future calls skip
}
},

/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames.
* Uses _safeYield to spread work across frames; falls back to setTimeout
* and a tick-Worker so progress continues on occluded / idle-throttled tabs.
* @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
Expand Down Expand Up @@ -1397,7 +1458,7 @@ Object.assign(CodemanApp.prototype, {
`[CRASH-DIAG] chunkedTerminalWrite complete: ${cleanBuffer.length} bytes in ${_chunkCount} chunks, ${_totalMs.toFixed(0)}ms total`
);
// Wait one more frame for xterm to finish rendering before resolving
requestAnimationFrame(finish);
this._safeYield(finish);
return;
}

Expand All @@ -1412,12 +1473,13 @@ Object.assign(CodemanApp.prototype, {
);
offset += chunkSize;

// Schedule next chunk on next frame
requestAnimationFrame(writeChunk);
// Schedule next chunk; rAF if possible, else setTimeout/Worker
// fallback so progress doesn't stall on occluded/unfocused windows.
this._safeYield(writeChunk);
};

// Start writing
requestAnimationFrame(writeChunk);
this._safeYield(writeChunk);
});
},

Expand Down