Merge master (PR #112 terminal pane-buffer rework) into v1-readiness-hardening

Conflict in src/web/public/app.js selectSession: combined #112's
_clearTerminalLoadState cleanup on stale select with #113's
{success,data} envelope unwrap of the terminal fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 03:17:12 +02:00
co-authored by Claude Opus 4.8
13 changed files with 883 additions and 72 deletions
+112 -20
View File
@@ -12,6 +12,24 @@
* @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js
*/
(function (global) {
const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/;
const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/;
function isTerminalQueryResponse(data) {
return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data);
}
function shouldSuppressTerminalQueryResponse(data) {
return isTerminalQueryResponse(data);
}
global.CodemanTerminalInput = {
isTerminalQueryResponse,
shouldSuppressTerminalQueryResponse,
};
})(window);
Object.assign(CodemanApp.prototype, {
// ═══════════════════════════════════════════════════════════════
// Terminal Setup — xterm.js config and input handling
@@ -164,7 +182,24 @@ Object.assign(CodemanApp.prototype, {
// whitespace) -- if so, xterm handled it and we should not double-send.
// Use a microtask to check after xterm's own handlers have run.
const data = e.data;
const pendingBefore = this._localEchoOverlay?.pendingText || '';
Promise.resolve().then(() => {
if (
this._lastTerminalData?.data === data &&
performance.now() - this._lastTerminalData.time < 100
) {
xtermTextarea.value = '';
return;
}
const pendingAfter = this._localEchoOverlay?.pendingText || '';
if (
this._localEchoEnabled &&
pendingAfter.length > pendingBefore.length &&
pendingAfter.endsWith(data)
) {
xtermTextarea.value = '';
return;
}
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || (val.trim() === '' && data !== ' ')) return;
@@ -328,6 +363,7 @@ Object.assign(CodemanApp.prototype, {
'touchmove',
(ev) => {
if (ev.touches.length === 1 && isTouching) {
ev.preventDefault();
didScroll = true;
const touchY = ev.touches[0].clientY;
const delta = touchLastY - touchY; // positive = scroll down
@@ -343,7 +379,7 @@ Object.assign(CodemanApp.prototype, {
}
}
},
{ passive: true }
{ passive: false }
);
container.addEventListener(
@@ -382,6 +418,8 @@ Object.assign(CodemanApp.prototype, {
// Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch
this._chunkedWriteGen = 0;
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Handle resize with throttling for performance
this._resizeTimeout = null;
@@ -498,11 +536,15 @@ Object.assign(CodemanApp.prototype, {
// CJK input has focus — block xterm from sending to PTY
if (window.cjkActive || document.activeElement?.id === 'cjkInput') return;
if (this.activeSessionId) {
// Filter out terminal query responses that xterm.js generates automatically.
// These are responses to DA (Device Attributes), DSR (Device Status Report), etc.
// sent by tmux when attaching. Without this filter, they appear as typed text.
// Patterns: \x1b[?...c (DA1), \x1b[>...c (DA2), \x1b[...R (CPR), \x1b[...n (DSR)
if (/^\x1b\[[\?>=]?[\d;]*[cnR]$/.test(data)) return;
// Filter terminal query replies generated by xterm.js itself.
// Forwarding them through the WebSocket injects DA/DSR/CPR replies
// into the foreground process as typed input (for example "0;276;0c").
if (
window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data)
) {
return;
}
this._lastTerminalData = { data, time: performance.now() };
// ── Local Echo Mode ──
// When enabled, keystrokes are buffered locally in the overlay for
@@ -1594,6 +1636,38 @@ Object.assign(CodemanApp.prototype, {
}
},
scrollToLastNonEmptyLine() {
if (!this.terminal?.buffer?.active) {
this.terminal?.scrollToBottom?.();
return;
}
const buffer = this.terminal.buffer.active;
const totalLines = buffer.baseY + buffer.length;
let lastNonEmptyLine = -1;
for (let lineIndex = totalLines - 1; lineIndex >= 0; lineIndex--) {
const line = buffer.getLine(lineIndex);
if (line?.translateToString(true).trim()) {
lastNonEmptyLine = lineIndex;
break;
}
}
if (lastNonEmptyLine >= 0 && typeof this.terminal.scrollToLine === 'function') {
let targetLine = Math.max(0, lastNonEmptyLine - this.terminal.rows + 2);
const maxTargetLine = Math.max(0, lastNonEmptyLine);
while (targetLine < maxTargetLine) {
const line = buffer.getLine(targetLine);
if (line?.translateToString(true).trim()) break;
targetLine++;
}
this.terminal.scrollToLine(targetLine);
} else {
this.terminal.scrollToBottom();
}
},
/**
* Write large buffer to terminal in chunks to avoid UI jank.
* Uses _safeYield to spread work across frames; falls back to setTimeout
@@ -1602,22 +1676,19 @@ Object.assign(CodemanApp.prototype, {
* @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written
*/
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) {
// Generation counter: if a newer chunkedTerminalWrite starts (tab switch),
// older writes abort instead of continuing to push stale data into the terminal.
const writeGen = ++this._chunkedWriteGen;
const bufferLoadOwner = this._beginBufferLoad(loadOwner);
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
resolve();
return;
}
// Block live SSE writes during buffer load to prevent interleaving
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
// Strip any DEC 2026 markers that might be in the buffer
// (from historical SSE data that was stored with markers)
const cleanBuffer = buffer.replace(DEC_SYNC_STRIP_RE, '');
@@ -1625,15 +1696,14 @@ Object.assign(CodemanApp.prototype, {
const finish = () => {
// Only finish if we're still the active write — a newer write owns buffer load state
if (this._chunkedWriteGen === writeGen) {
this._finishBufferLoad();
this._finishBufferLoad(bufferLoadOwner);
}
resolve();
};
// For small buffers, write directly — single-frame render is fast enough
if (cleanBuffer.length <= chunkSize) {
this.terminal.write(cleanBuffer);
finish();
this.terminal.write(cleanBuffer, finish);
return;
}
@@ -1691,9 +1761,23 @@ Object.assign(CodemanApp.prototype, {
* (especially Ink cursor-up redraws), corrupting the terminal display.
* After unblocking, new SSE/WS events deliver subsequent output normally.
*/
_finishBufferLoad() {
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner;
this._bufferLoadOwner = loadOwner;
this._isLoadingBuffer = true;
this._loadBufferQueue = [];
return loadOwner;
},
_finishBufferLoad(owner) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;
}
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
return true;
},
// ═══════════════════════════════════════════════════════════════
@@ -1814,23 +1898,30 @@ Object.assign(CodemanApp.prototype, {
/**
* Send resize to a session with minimum dimension enforcement.
* @param {string} sessionId
* @param {{ forceHttp?: boolean }} [options]
* @returns {Promise<void>}
*/
async sendResize(sessionId) {
async sendResize(sessionId, options = {}) {
// Fit terminal to container before reading dimensions — ensures local
// terminal size matches what we report to the server PTY.
if (this.fitAddon) this.fitAddon.fit();
const dims = this.getTerminalDimensions();
if (!dims) return;
if (!dims) return false;
// Did the dimensions actually change since the last resize we sent? Callers
// use this to skip work (e.g. the post-resize TUI-redraw settle) when no
// real SIGWINCH was triggered — switching tabs at the same browser size is
// a no-op on the server and needs no redraw grace.
const prev = this._lastResizeDims;
const changed = !prev || prev.cols !== dims.cols || prev.rows !== dims.rows;
// Update _lastResizeDims so the throttledResize handler won't redundantly
// clear the terminal for the same dimensions (which would blank the screen
// without a subsequent Ink redraw to repaint it).
this._lastResizeDims = { cols: dims.cols, rows: dims.rows };
// Fast path: WebSocket resize
if (this._wsReady && this._wsSessionId === sessionId) {
if (!options.forceHttp && this._wsReady && this._wsSessionId === sessionId) {
try {
this._ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows }));
return;
return changed;
} catch {
// Fall through to HTTP POST
}
@@ -1840,6 +1931,7 @@ Object.assign(CodemanApp.prototype, {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dims),
});
return changed;
},
/**