mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
+112
-20
@@ -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;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user