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:
Codeman maintainer
2026-10-06 08:55:05 +02:00
parent f1537a7887
commit ead3d34411
7 changed files with 473 additions and 63 deletions
+66 -13
View File
@@ -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 */
}
+12 -6
View File
@@ -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 || []);
+86 -41
View File
@@ -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 {}
}
});
},
});