mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 09:19:42 +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:
@@ -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 {}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user