mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later grid tiles) to share what today only the primary terminal has: - _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the exactly-once input queue, its ACK handling and the redelivery sweep now deliver over any registered socket bound to a session, not only this._ws. ACKs are routed by the receiving socket's session; silence is judged per socket; a stale handle cannot unregister its replacement. - registerFilePathLinkProvider, cleanedTerminalSelection, copyTerminalSelection and _handleImagePaste take an optional target terminal and session (defaults: the primary pane). - _focusedPane() is the one place to ask which pane the keyboard is in (primary only, for now); _forEachTile() replaces the _splitPane special cases in the font, family, weight, skin and resize paths. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+66
-13
@@ -867,6 +867,11 @@ class CodemanApp {
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||
// session they are bound to: the split pane's second terminal registers its
|
||||
// socket here so its input rides the same exactly-once queue. Values are
|
||||
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
|
||||
this._extraInputSockets = new Map();
|
||||
this._persistReliableTimer = null;
|
||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||
@@ -3397,7 +3402,7 @@ class CodemanApp {
|
||||
} else if (msg.t === 'ia') {
|
||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||
// the durable queue so it can never be re-delivered/lost.
|
||||
this._onWsInputAck(msg.seq, msg);
|
||||
this._onWsInputAck(msg.seq, msg, sessionId);
|
||||
} else if (msg.t === 'zc') {
|
||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||
// always the one this client asked for (issue #464).
|
||||
@@ -3579,6 +3584,49 @@ class CodemanApp {
|
||||
this._reliableSend(sessionId, input, useMux);
|
||||
}
|
||||
|
||||
/**
|
||||
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
|
||||
* first, then one registered by a second terminal (the split pane). The
|
||||
* delivery layer below asks this instead of reading `this._ws` directly, so a
|
||||
* second terminal's input gets the same exactly-once queue, ACKs and
|
||||
* half-open detection as the primary's.
|
||||
*
|
||||
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
|
||||
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
|
||||
*/
|
||||
_inputSocketFor(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
|
||||
}
|
||||
const handle = this._extraInputSockets?.get(sessionId);
|
||||
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a second terminal's socket for `sessionId` (call from its onopen).
|
||||
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
|
||||
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
|
||||
* just opened does not look silent to the redelivery sweep, which would
|
||||
* otherwise force-close it as half-open on the first stale record.
|
||||
*/
|
||||
_registerInputSocket(sessionId, handle) {
|
||||
if (!sessionId || !handle) return;
|
||||
handle.lastRecvAt = Date.now();
|
||||
if (!this._extraInputSockets) this._extraInputSockets = new Map();
|
||||
this._extraInputSockets.set(sessionId, handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a registration, but only the one `handle` made: a replacement socket
|
||||
* registers its own handle before the old one's close lands, and that late
|
||||
* close must not unregister the socket that replaced it.
|
||||
*/
|
||||
_unregisterInputSocket(sessionId, handle) {
|
||||
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||
@@ -3590,9 +3638,10 @@ class CodemanApp {
|
||||
*/
|
||||
_sendInputEphemeral(sessionId, input) {
|
||||
if (!sessionId || !input) return;
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
sock.ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
return;
|
||||
} catch {
|
||||
// socket died mid-send — fall through to a best-effort POST
|
||||
@@ -3646,11 +3695,12 @@ class CodemanApp {
|
||||
// over the single ordered stream. They stay pending until the server ACKs
|
||||
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
for (const rec of list) {
|
||||
if (rec.sentAt !== 0) continue;
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||
sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||
rec.sentAt = Date.now();
|
||||
rec.tries++;
|
||||
} catch {
|
||||
@@ -3671,7 +3721,7 @@ class CodemanApp {
|
||||
if (!cur || cur.length === 0) break;
|
||||
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
if (this._inputSocketFor(sessionId)) {
|
||||
break;
|
||||
}
|
||||
const rec = cur[0];
|
||||
@@ -3759,8 +3809,10 @@ class CodemanApp {
|
||||
* called a duplicate is the mechanism working as designed — the original did
|
||||
* land — and re-sending it would type the same thing twice.
|
||||
*/
|
||||
_onWsInputAck(seq, msg) {
|
||||
const sessionId = this._wsSessionId;
|
||||
_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
|
||||
// `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
|
||||
// frames carry none, and with a second terminal's socket in play
|
||||
// `this._wsSessionId` is no longer the only candidate.
|
||||
if (!sessionId || !Number.isInteger(seq)) return;
|
||||
if (msg && msg.err) {
|
||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||
@@ -3805,19 +3857,20 @@ class CodemanApp {
|
||||
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (!list || list.length === 0) continue;
|
||||
const isActiveWs =
|
||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
||||
if (isActiveWs) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
const oldest = list[0];
|
||||
// Only tear the socket down when the oldest unacked frame is stale AND the
|
||||
// socket has been silent for the timeout: a connection still delivering
|
||||
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
||||
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
||||
// Silence is measured on THIS socket: the primary's last frame says
|
||||
// nothing about a second terminal's connection, and the reverse.
|
||||
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
||||
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
||||
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
|
||||
if (stale && silent) {
|
||||
try {
|
||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
||||
sock.ws.close(); // half-open: never recovers on its own — force reconnect
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
_handleImagePaste() {
|
||||
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||
// default to the primary pane. A second terminal (the split pane) passes its
|
||||
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||
_handleImagePaste(target = {}) {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const sessionId = target.sessionId || this.activeSessionId;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (self.terminal) self.terminal.focus();
|
||||
if (terminal) terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && self.terminal) self.terminal.paste(text);
|
||||
if (text && terminal) terminal.paste(text);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/** Upload a batch and normally insert its paths into the active terminal.
|
||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||
* into its textarea instead. Returns successful paths in selection order. */
|
||||
* into its textarea instead. `options.sessionId` names the session to upload
|
||||
* to (default: the active one). Returns successful paths in selection order. */
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
|
||||
@@ -1416,7 +1416,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// without this call Pane B never learned about a window resize, an
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// stayed at whatever size it was last dragged to.
|
||||
this._splitPane?.fit();
|
||||
this._forEachTile?.((tile) => tile.fit());
|
||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||
};
|
||||
|
||||
@@ -1827,9 +1827,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
* When clicked, opens a floating log viewer window with live streaming.
|
||||
*
|
||||
* `target` defaults to the primary terminal and the active session. A second
|
||||
* terminal (the split pane) passes its own `{ terminal, getSessionId,
|
||||
* setHovered }`, so a path printed there opens against THAT pane's session and
|
||||
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
|
||||
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
|
||||
* Returns the provider.
|
||||
*/
|
||||
registerFilePathLinkProvider() {
|
||||
registerFilePathLinkProvider(target = {}) {
|
||||
const self = this;
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const getSessionId = target.getSessionId || (() => this.activeSessionId);
|
||||
const setHovered =
|
||||
target.setHovered ||
|
||||
((hovered) => {
|
||||
this._linkHovered = hovered;
|
||||
});
|
||||
const isPrimary = terminal === this.terminal;
|
||||
|
||||
// Debug: Track if provider is being invoked
|
||||
let lastInvokedLine = -1;
|
||||
@@ -1842,7 +1857,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||||
}
|
||||
|
||||
const buffer = self.terminal.buffer.active;
|
||||
const buffer = terminal.buffer.active;
|
||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
@@ -1866,7 +1881,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
self.terminal.cols,
|
||||
terminal.cols,
|
||||
MAX_STITCHED_ROWS
|
||||
);
|
||||
if (!logical) {
|
||||
@@ -1919,10 +1934,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
setHovered(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1978,17 +1993,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
setHovered(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2031,10 +2047,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// produce), so the tap path asks this SAME provider what is under the finger
|
||||
// rather than growing a second, driftable copy of the patterns.
|
||||
// See _terminalLinkAtPoint.
|
||||
this._terminalLinkProvider = provider;
|
||||
this.terminal.registerLinkProvider(provider);
|
||||
if (isPrimary) this._terminalLinkProvider = provider;
|
||||
terminal.registerLinkProvider(provider);
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
return provider;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4480,6 +4497,28 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Terminal Controls
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary }`.
|
||||
*
|
||||
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||
* primary pane. Today it always answers with the primary pane; the split
|
||||
* pane's second terminal plugs in here once it tracks its own focus.
|
||||
*/
|
||||
_focusedPane() {
|
||||
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Run `fn(tile)` for every secondary terminal pane on screen: today the split
|
||||
* pane's second terminal, when one is open. Font, weight, family and skin
|
||||
* changes go through here so they reach every pane without a special case
|
||||
* per pane kind. Agent Teams terminals size themselves and are not tiles.
|
||||
*/
|
||||
_forEachTile(fn) {
|
||||
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||
},
|
||||
|
||||
clearTerminal() {
|
||||
this.terminal.clear();
|
||||
},
|
||||
@@ -4639,15 +4678,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||
*/
|
||||
cleanedTerminalSelection(text) {
|
||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
cleanedTerminalSelection(text, target = {}) {
|
||||
// `target` names a second terminal (the split pane) and its session; both
|
||||
// default to the primary pane, whose `this.terminal` this file otherwise reads.
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
|
||||
if (!raw) return '';
|
||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange();
|
||||
const range = this._normalisedSelectionRange(terminal);
|
||||
return clean(raw, {
|
||||
margin: this._cliGutterColumns(),
|
||||
margin: this._cliGutterColumns(target.sessionId),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
@@ -4717,8 +4759,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = this.cleanedTerminalSelection(text);
|
||||
async copyTerminalSelection(text, target = {}) {
|
||||
// Every terminal touched below is the TARGET one: clearing or refocusing the
|
||||
// primary after copying from the split pane would hit the wrong pane.
|
||||
const terminal = target.terminal || this.terminal;
|
||||
const selection = this.cleanedTerminalSelection(text, target);
|
||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||
@@ -4727,7 +4772,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// selection, so a padding-only selection left set can no longer swallow a
|
||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||
// clear avoids is a highlight that sits there having copied nothing.
|
||||
this.terminal?.clearSelection?.();
|
||||
terminal?.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
return false;
|
||||
}
|
||||
@@ -4735,14 +4780,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
// drops the selection on any keypress, so this matches existing feel).
|
||||
this.terminal.clearSelection?.();
|
||||
terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||
// is the CJK-aware focus router, not xterm's raw focus().
|
||||
this.terminal.focus();
|
||||
terminal.focus();
|
||||
return ok;
|
||||
},
|
||||
|
||||
@@ -5688,10 +5733,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontSize = size;
|
||||
tile.localFit();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5717,10 +5762,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._refitAfterCellSizeChange();
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontFamily = resolved;
|
||||
tile.localFit();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -5772,11 +5817,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
/* pane not laid out yet — its own resize observer refits it */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontWeight = fontWeight;
|
||||
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||
tile.localFit();
|
||||
});
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -6245,13 +6290,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
tile.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
|
||||
});
|
||||
|
||||
it('drops a frame the WebSocket refused with an error ACK', () => {
|
||||
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
|
||||
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
|
||||
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview The durable input layer delivers over ANY registered terminal
|
||||
* socket, not only the primary one (`_inputSocketFor`, app.js).
|
||||
*
|
||||
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
|
||||
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
|
||||
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
|
||||
* bound to another session (the split pane's Pane B, later a grid tile) had no
|
||||
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
|
||||
* and no idle-alert acknowledgement. These tests pin the seam: a registered
|
||||
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
|
||||
* registration can never evict the socket that replaced it, and the sweep
|
||||
* judges a socket's silence by that socket's own last frame.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function loadCodemanAppClass() {
|
||||
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
WebSocket: { OPEN: 1 },
|
||||
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||
document: { addEventListener: vi.fn() },
|
||||
localStorage: {
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
|
||||
type Frame = { t: string; d: string; seq?: number; cid?: string };
|
||||
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
|
||||
type Handle = { ws: FakeSocket; lastRecvAt: number };
|
||||
type Rec = { seq: number; data: string; sentAt: number; tries: number };
|
||||
|
||||
type App = {
|
||||
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||
_sendInputEphemeral: (sessionId: string, input: string) => void;
|
||||
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
|
||||
_registerInputSocket: (sessionId: string, handle: Handle) => void;
|
||||
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
|
||||
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
|
||||
_onWsReady: (sessionId: string) => void;
|
||||
_redeliverSweep: () => void;
|
||||
_pendingDeliveries: Map<string, Rec[]>;
|
||||
_seqCounters: Map<string, number>;
|
||||
_ws: FakeSocket | null;
|
||||
_wsSessionId: string | null;
|
||||
_wsLastRecvAt: number;
|
||||
_reliableAckTimeoutMs: number;
|
||||
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeApp(): App {
|
||||
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||
app._clientId = 'c-test';
|
||||
app._seqCounters = new Map();
|
||||
app._pendingDeliveries = new Map();
|
||||
app._postDraining = new Set();
|
||||
app._persistReliableState = vi.fn();
|
||||
app._persistReliableNow = vi.fn();
|
||||
app._updateConnectionIndicator = vi.fn();
|
||||
app.markIdleAlertSeen = vi.fn();
|
||||
app.activeSessionId = 'primary';
|
||||
app._ws = null;
|
||||
app._wsSessionId = null;
|
||||
app._wsLastRecvAt = 0;
|
||||
app._reliableAckTimeoutMs = 4000;
|
||||
return app as unknown as App;
|
||||
}
|
||||
|
||||
function fakeSocket(frames: Frame[]): FakeSocket {
|
||||
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
|
||||
}
|
||||
|
||||
describe('a registered second socket joins the exactly-once queue', () => {
|
||||
it('delivers seq-tagged frames over the registered socket for its session', () => {
|
||||
const app = makeApp();
|
||||
const primaryFrames: Frame[] = [];
|
||||
const tileFrames: Frame[] = [];
|
||||
app._ws = fakeSocket(primaryFrames);
|
||||
app._wsSessionId = 'primary';
|
||||
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||
|
||||
app._sendInputAsync('other', 'x');
|
||||
app._sendInputAsync('other', 'y');
|
||||
|
||||
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||
['x', 1, 'c-test'],
|
||||
['y', 2, 'c-test'],
|
||||
]);
|
||||
// Nothing for the other session leaks onto the primary socket.
|
||||
expect(primaryFrames).toEqual([]);
|
||||
});
|
||||
|
||||
it('still prefers the primary socket for the primary session', () => {
|
||||
const app = makeApp();
|
||||
const primaryFrames: Frame[] = [];
|
||||
app._ws = fakeSocket(primaryFrames);
|
||||
app._wsSessionId = 'primary';
|
||||
|
||||
app._sendInputAsync('primary', 'a');
|
||||
|
||||
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
|
||||
const app = makeApp();
|
||||
const posts: Array<{ input: string; seq: number }> = [];
|
||||
global.fetch = vi.fn(async (_url, init) => {
|
||||
posts.push(JSON.parse(String(init?.body)));
|
||||
return new Response('{}', { status: 200 });
|
||||
});
|
||||
const closing = { ...fakeSocket([]), readyState: 3 };
|
||||
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
|
||||
|
||||
expect(app._inputSocketFor('other')).toBeNull();
|
||||
app._sendInputAsync('other', 'z');
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||
});
|
||||
|
||||
it('routes ephemeral input over the registered socket too', () => {
|
||||
const app = makeApp();
|
||||
const tileFrames: Frame[] = [];
|
||||
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||
|
||||
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
|
||||
|
||||
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ACK routing', () => {
|
||||
it('drops the record from the queue of the session the ACK arrived for', () => {
|
||||
const app = makeApp();
|
||||
app._ws = fakeSocket([]);
|
||||
app._wsSessionId = 'primary';
|
||||
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
|
||||
app._sendInputAsync('primary', 'p');
|
||||
app._sendInputAsync('other', 'o');
|
||||
|
||||
// Both sessions issued seq 1. The ACK names no session, so the socket's own
|
||||
// session decides which queue it drains; the primary's must be untouched.
|
||||
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
|
||||
|
||||
expect(app._pendingDeliveries.get('other')).toBeUndefined();
|
||||
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
|
||||
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
|
||||
});
|
||||
|
||||
it('defaults to the primary socket session when no session is passed', () => {
|
||||
const app = makeApp();
|
||||
app._ws = fakeSocket([]);
|
||||
app._wsSessionId = 'primary';
|
||||
app._sendInputAsync('primary', 'p');
|
||||
|
||||
app._onWsInputAck(1, { t: 'ia', seq: 1 });
|
||||
|
||||
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-sends everything pending over a freshly registered socket on ready', () => {
|
||||
const app = makeApp();
|
||||
const first: Frame[] = [];
|
||||
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
|
||||
app._registerInputSocket('other', firstHandle);
|
||||
app._sendInputAsync('other', 'a');
|
||||
expect(first.map((f) => f.d)).toEqual(['a']);
|
||||
|
||||
// The socket dies without an ACK; a replacement opens and asks for a flush.
|
||||
firstHandle.ws.readyState = 3;
|
||||
const second: Frame[] = [];
|
||||
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
|
||||
app._onWsReady('other');
|
||||
|
||||
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration ownership', () => {
|
||||
it('a stale handle cannot unregister the socket that replaced it', () => {
|
||||
const app = makeApp();
|
||||
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||
const newFrames: Frame[] = [];
|
||||
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
|
||||
app._registerInputSocket('other', oldHandle);
|
||||
app._registerInputSocket('other', newHandle);
|
||||
|
||||
// The old socket's close lands late and tries to clean up after itself.
|
||||
app._unregisterInputSocket('other', oldHandle);
|
||||
|
||||
app._sendInputAsync('other', 'still-here');
|
||||
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
|
||||
});
|
||||
|
||||
it('the owning handle does unregister', () => {
|
||||
const app = makeApp();
|
||||
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||
app._registerInputSocket('other', handle);
|
||||
app._unregisterInputSocket('other', handle);
|
||||
|
||||
expect(app._inputSocketFor('other')).toBeNull();
|
||||
});
|
||||
|
||||
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
|
||||
const app = makeApp();
|
||||
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||
const before = Date.now();
|
||||
app._registerInputSocket('other', handle);
|
||||
|
||||
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the redelivery sweep judges each socket by its own last frame', () => {
|
||||
function staleRecord(app: App, sessionId: string) {
|
||||
const rec = app._pendingDeliveries.get(sessionId)![0];
|
||||
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||
}
|
||||
|
||||
it('force-closes a silent registered socket with a stale record', () => {
|
||||
const app = makeApp();
|
||||
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||
app._registerInputSocket('other', handle);
|
||||
app._sendInputAsync('other', 'a');
|
||||
staleRecord(app, 'other');
|
||||
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||
// The primary socket is chatty; that must not vouch for the other one.
|
||||
app._ws = fakeSocket([]);
|
||||
app._wsSessionId = 'primary';
|
||||
app._wsLastRecvAt = Date.now();
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
expect(handle.ws.close).toHaveBeenCalledTimes(1);
|
||||
expect(app._ws.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-drives (does not close) a registered socket that is still receiving', () => {
|
||||
const app = makeApp();
|
||||
const frames: Frame[] = [];
|
||||
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
|
||||
app._registerInputSocket('other', handle);
|
||||
app._sendInputAsync('other', 'a');
|
||||
staleRecord(app, 'other');
|
||||
handle.lastRecvAt = Date.now();
|
||||
// A SILENT primary must not condemn the live registered socket either.
|
||||
app._wsLastRecvAt = 0;
|
||||
|
||||
app._redeliverSweep();
|
||||
|
||||
expect(handle.ws.close).not.toHaveBeenCalled();
|
||||
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
|
||||
|
||||
describe('the client lifts itself over the watermark', () => {
|
||||
const handler = appSource.slice(
|
||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
||||
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||
appSource.indexOf('/** Called from ws.onopen')
|
||||
);
|
||||
|
||||
|
||||
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||
}
|
||||
|
||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
||||
function makeApp(
|
||||
opts: {
|
||||
teammates?: number;
|
||||
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||||
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; localFit: () => void } | null;
|
||||
} = {}
|
||||
) {
|
||||
const fit = vi.fn();
|
||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||
@@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
||||
}
|
||||
const app = {
|
||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||
// Secondary panes (the split pane's second terminal) are reached through
|
||||
// this helper, so it is wired for real like the geometry chain below.
|
||||
_forEachTile: mixin._forEachTile,
|
||||
_splitPane: opts.splitPane ?? null,
|
||||
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||
// through the one function that floors the result and reports it (#464).
|
||||
@@ -98,6 +108,21 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
||||
}
|
||||
|
||||
describe('applyTerminalFontWeights', () => {
|
||||
it('reaches an open split pane: same weights, refit in place', () => {
|
||||
const splitTerminal = fakeTerminal();
|
||||
const localFit = vi.fn();
|
||||
const { app } = makeApp({ splitPane: { terminal: splitTerminal, localFit } });
|
||||
|
||||
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||
terminalFontWeight: '300',
|
||||
terminalFontWeightBold: '700',
|
||||
});
|
||||
|
||||
expect(splitTerminal.options.fontWeight).toBe(300);
|
||||
expect(splitTerminal.options.fontWeightBold).toBe(700);
|
||||
expect(localFit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('writes both slots to the live terminal', () => {
|
||||
const { app, fit } = makeApp();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user