Merge pull request #325 from Ark0N/feat/auto-copy-selection

feat(terminal): Auto Copy, put a finished selection on the clipboard
This commit is contained in:
Ark0N
2026-08-21 02:10:19 +02:00
committed by GitHub
14 changed files with 830 additions and 28 deletions
+59 -1
View File
@@ -415,6 +415,15 @@ export class Session extends EventEmitter {
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
// strip (see _handleTerminalOutput / isAltScreenStripMode)
private _altScreenSeqCarry: string = '';
/**
* Mouse-tracking DECSET modes the CLI currently has ON, as observed while
* STRIPPING them out of the stream below. Kept as a set rather than a boolean
* because a TUI may enable 1002 and later disable 1000 (a mode it never
* enabled); tracking is on while any of them is.
*/
private _cliMouseModes = new Set<number>();
private _cliMouseTracking = false;
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
private rejectPromise: ((reason: Error) => void) | null = null;
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
@@ -1285,6 +1294,7 @@ export class Session extends EventEmitter {
niceValue: this._niceConfig.niceValue,
color: this._color,
flickerFilterEnabled: this._flickerFilterEnabled,
cliMouseTracking: this._cliMouseTracking || undefined,
cliVersion: this._cliVersion || undefined,
cliModel: this._cliModel || undefined,
cliAccountType: this._cliAccountType || undefined,
@@ -1546,6 +1556,46 @@ export class Session extends EventEmitter {
};
}
/**
* Remember whether the CLI currently wants to be told about mouse clicks.
*
* The strip in {@link _handleTerminalOutput} is the ONLY place these sequences
* exist. After it, neither the browser nor xterm can ever learn that the CLI
* asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is
* permanently 'none' for a stripped mode. The browser hand-encodes SGR reports
* to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state
* to consult it had to do that on EVERY click, delivering mouse reports to a
* CLI that never asked for them. Publishing this through `toState()` is what
* lets the browser report a click only when the CLI is listening.
*
* Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is
* alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on
* is not asking about clicks, and counting those would put the stray reports
* straight back.
*
* This must stay in lockstep with the strip regex that calls it: a sequence
* removed from the stream but not recorded here is one the browser can neither
* see nor be told about.
*/
private _recordStrippedMouseMode(seq: string): void {
// eslint-disable-next-line no-control-regex
const match = /\x1b\[\?(\d+)([hl])$/.exec(seq);
if (!match) return;
const mode = Number(match[1]);
if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return;
if (match[2] === 'h') this._cliMouseModes.add(mode);
else this._cliMouseModes.delete(mode);
this._syncCliMouseTracking();
}
/** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */
private _syncCliMouseTracking(): void {
const active = this._cliMouseModes.size > 0;
if (active === this._cliMouseTracking) return;
this._cliMouseTracking = active;
this.emit('mouseTrackingChanged', active);
}
private _handleTerminalOutput(data: string): void {
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
@@ -1598,7 +1648,10 @@ export class Session extends EventEmitter {
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[3J/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
this._recordStrippedMouseMode(seq);
return '';
});
}
}
@@ -2525,6 +2578,11 @@ export class Session extends EventEmitter {
this._messages = [];
this._lineBuffer = '';
this._altScreenSeqCarry = '';
// A restarted pane starts with no mouse mode: the new program has not asked
// for one yet, and carrying the old CLI's state over would report clicks
// into a program that never enabled tracking.
this._cliMouseModes.clear();
this._syncCliMouseTracking();
this._markActivity(true);
}
+8
View File
@@ -500,6 +500,14 @@ export interface SessionState {
color?: SessionColor;
/** Flicker filter enabled (buffers output after screen clears) */
flickerFilterEnabled?: boolean;
/**
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
* by the server on its way out of the stream (those sequences are stripped for
* claude/codex/gemini, so the browser can never see them itself). The browser
* hand-encodes a click report ONLY when this is true; without it, every click
* sent mouse reports to a CLI that never asked for them.
*/
cliMouseTracking?: boolean;
/** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */
cliVersion?: string;
/** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */
+53
View File
@@ -562,6 +562,55 @@ function resolveTerminalFontFamily(custom) {
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
}
// ---------------------------------------------------------------------------
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
// without a terminal, a clipboard, or a browser.
// ---------------------------------------------------------------------------
/**
* Upper bound on an AUTO-copied selection.
*
* A drag that runs off the top of the viewport autoscrolls, so one gesture can
* sweep the entire 50k-line scrollback (millions of characters), and writing
* that to the clipboard on every mouseup is a real hazard on a phone. Past the
* cap the copy is REFUSED rather than truncated (half a selection on the
* clipboard is worse than none) and the user is told to press Ctrl+C, which
* still copies the whole thing through the explicit path.
*/
const AUTO_COPY_MAX_CHARS = 1_000_000;
/**
* What an auto-copy attempt should do at the end of a selection gesture.
*
* `pending` is set by xterm's onSelectionChange and cleared on every flush;
* `lastCopied` is the text this surface auto-copied last. Either one alone is
* wrong, which is why both are here:
*
* - onSelectionChange does not reliably fire BEFORE the mouseup that ends the
* drag (xterm fires it from its own document-level mouseup handler, and
* listener order between the two is registration order, not something this
* code controls). Gating on `pending` alone would silently drop the first
* copy of a drag-selection.
* - Gating on `text !== lastCopied` alone drops a deliberate re-selection of
* the same text after the user copied something else in between, and it
* would let any unrelated mouseup on the page re-copy a stale selection.
*
* So: a genuine selection change (`pending`) always copies, and otherwise only
* text that differs from the last auto-copy does.
*
* @param {{enabled?: boolean, text?: string, lastCopied?: string, pending?: boolean}} params
* @returns {'copy'|'skip'|'too-large'}
*/
function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
if (!enabled) return 'skip';
// Whitespace-only is what a drag across blank cells produces; putting a wall
// of spaces on the clipboard is never what the gesture meant.
if (typeof text !== 'string' || !text.trim()) return 'skip';
if (!pending && text === lastCopied) return 'skip';
if (text.length > AUTO_COPY_MAX_CHARS) return 'too-large';
return 'copy';
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -595,6 +644,10 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanAutoCopy = {
decide: decideAutoCopy,
MAX_CHARS: AUTO_COPY_MAX_CHARS,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
+5
View File
@@ -335,6 +335,11 @@
'Extended Keyboard Bar': '扩展键盘栏',
'Gesture Control (beta)': '手势控制(测试版)',
'Wheel Scrolls Local History': '滚轮滚动本地历史',
'Auto Copy Selection': '自动复制选中内容',
'Selection & clipboard': '选中与剪贴板',
'Auto Copy: selection copied': '自动复制:已复制选中内容',
'Auto Copy failed: the browser blocked clipboard access': '自动复制失败:浏览器阻止了剪贴板访问',
'Selection too large to copy automatically. Press Ctrl+C.': '选中内容过大,无法自动复制。请按 Ctrl+C。',
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
+13
View File
@@ -1627,6 +1627,19 @@
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Selection &amp; clipboard</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
<div class="set-row" data-search="auto copy selection clipboard highlight copy on select mouse">
<div class="set-row-text">
<span class="set-row-label">Auto Copy Selection</span>
<span class="set-row-desc">Put highlighted terminal text on the clipboard as soon as you finish selecting it, with mouse, double-click or long-press. Ctrl+C still copies on demand, and nothing outside the terminal is copied.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
</div>
</div>
</div>
<div class="set-group">
<div class="set-group-head"><h4>Font</h4><span class="set-scope">device</span></div>
<div class="set-group-body">
+11
View File
@@ -390,6 +390,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
// overwrites the system clipboard on a gesture the user may have meant only as
// a way to read, so it is opt-in rather than a default anyone has to discover.
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
@@ -2016,6 +2020,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
@@ -2212,6 +2217,11 @@ Object.assign(CodemanApp.prototype, {
showFileViewerButton: _fvb,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// Copy-on-select. Per-device (clipboard access differs by device and by
// origin: the plain-HTTP LAN install has no navigator.clipboard at all)
// and absent from SettingsUpdateSchema (.strict()), so sending it would
// 400 the whole settings PUT.
autoCopySelection: _acs,
// Per-device by nature (the font must exist on the device) and absent
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
terminalFontFamily: _tff,
@@ -2892,6 +2902,7 @@ Object.assign(CodemanApp.prototype, {
'terminalFontFamily',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'showTabDetachButton',
'mobileOverviewEnabled',
+158 -8
View File
@@ -592,9 +592,35 @@ Object.assign(CodemanApp.prototype, {
this._touchSelectionActive = false;
this._touchSelectionAnchor = null;
this._hideTouchSelectionBar();
// Nothing highlighted, nothing for Auto Copy to flush. xterm drops the
// selection on any keypress, so without this a keystroke would leave a
// stale flush armed for the next unrelated mouseup.
this._autoCopyPending = false;
return;
}
// Auto Copy arms here and fires at the END of the gesture (below). This
// callback runs on EVERY cell the drag crosses, so copying here would be
// one clipboard write per mouse move.
this._autoCopyPending = true;
});
// Auto Copy (copy-on-select, per-device `autoCopySelection`, default OFF).
//
// ⚠️ The flush is SYNCHRONOUS inside the mouseup handler, deliberately: both
// clipboard paths need user activation (Firefox gates navigator.clipboard
// .writeText on it, and Safari requires document.execCommand('copy'), the
// plain-HTTP fallback install.sh's LAN option lands on, to run inside the
// gesture's own task). Deferring to a timer or to onSelectionChange loses it.
//
// ⚠️ document-level, because a drag that ends outside the terminal (the user
// sweeps up past the header) delivers its mouseup to the document, not to
// the container. Unrelated mouseups elsewhere on the page are filtered by
// decideAutoCopy, not by the listener's target.
if (!this._autoCopyListenerInstalled) {
this._autoCopyListenerInstalled = true;
document.addEventListener('mouseup', () => this._flushAutoCopySelection());
}
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
@@ -1892,6 +1918,11 @@ Object.assign(CodemanApp.prototype, {
this._suppressTrustedTapMouseEvents();
this._armTouchSelectionFocusGuard();
this._positionTouchSelectionBar();
// Auto Copy's other entry point. The touchend that ends this gesture is
// preventDefault()ed (that is what stops the compat mouse pair from
// stealing the selection back), so the document mouseup listener never sees
// this surface at all, so a phone would have a dead toggle without this call.
this._flushAutoCopySelection();
},
/** Whole logical line under the anchor — the common case a word selection just missed. */
@@ -1912,6 +1943,9 @@ Object.assign(CodemanApp.prototype, {
this._touchSelectionAnchor = { index, length };
this._applyTouchSelection(index, length);
this._positionTouchSelectionBar();
// Widening to the whole line is a new selection the user asked for, and the
// button's click carries the activation both clipboard paths need.
this._flushAutoCopySelection();
},
/** Copy through the shared path: Clipboard API, else execCommand (plain-HTTP installs). */
@@ -3878,6 +3912,98 @@ Object.assign(CodemanApp.prototype, {
return ok;
},
/**
* Auto Copy's ON/OFF, read at flush time from the CACHED settings object
* (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit).
*
* Reading it here rather than mirroring it into a field is deliberate: there
* is then no apply-path a future settings save can forget to call, and the
* toggle takes effect on the very next selection instead of the next reload.
*/
_autoCopySelectionEnabled() {
try {
return this.loadAppSettingsFromStorage?.()?.autoCopySelection === true;
} catch {
return false;
}
},
/**
* Copy the current terminal selection because the user finished highlighting
* it. Called at the end of a selection GESTURE: the document mouseup
* installed in initTerminal, and the touch-selection gesture end (a touchend
* the touch path preventDefaults, so no mouseup ever arrives there).
*
* ⚠️ This is NOT copyTerminalSelection(): that one clears the selection (so a
* second Ctrl+C is an interrupt) and hands focus back to the terminal. Both
* are wrong here: clearing would make the text vanish from under the cursor
* that just highlighted it, and focusing opens the on-screen keyboard over
* the text on a phone. Focus is instead RESTORED to whatever held it, which
* only matters for the execCommand fallback (it focuses a temp textarea on
* the way through); the Clipboard API path never moves focus at all.
*/
async _flushAutoCopySelection() {
const decide = window.CodemanAutoCopy?.decide;
if (!decide || !this.terminal) return;
const text = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
const verdict = decide({
enabled: this._autoCopySelectionEnabled(),
text,
lastCopied: this._autoCopyLastText,
pending: !!this._autoCopyPending,
});
this._autoCopyPending = false;
if (verdict === 'skip') return;
if (verdict === 'too-large') {
this._autoCopyNotify('Selection too large to copy automatically. Press Ctrl+C.', 'warning');
return;
}
this._autoCopyLastText = text;
const focusedBefore = document.activeElement;
const ok = await this._copyText(text);
if (
focusedBefore &&
focusedBefore !== document.activeElement &&
focusedBefore.isConnected &&
typeof focusedBefore.focus === 'function'
) {
try {
focusedBefore.focus();
} catch {}
}
if (!ok) {
// Let the next gesture retry the same text rather than dedupe itself into
// silence after a transient clipboard refusal.
this._autoCopyLastText = '';
this._autoCopyNotify('Auto Copy failed: the browser blocked clipboard access', 'error');
return;
}
// Silent on success, like every terminal emulator's copy-on-select, except
// ONCE per page load: a feature that works by doing nothing visible needs to
// say so the first time, or the user cannot tell it from a dead toggle.
if (!this._autoCopyHintShown) {
this._autoCopyHintShown = true;
this.showToast('Auto Copy: selection copied', 'success');
}
},
/**
* Failure/refusal toast, throttled. Auto Copy fires per gesture, so an
* install where the clipboard is permanently blocked would otherwise paint a
* toast on every drag, which trains the user to ignore toasts everywhere.
*/
_autoCopyNotify(message, type) {
const now = Date.now();
const last = this._autoCopyNotifiedAt;
// `last === undefined` is checked rather than falling back to 0: the first
// failure must always be reported, and a `now - 0 < throttle` comparison
// only happens to hold because the wall clock is a big number.
if (last !== undefined && now - last < 10_000) return;
this._autoCopyNotifiedAt = now;
this.showToast(message, type);
},
_syncMobileHelperTextareaToCursor() {
if (!MobileDetection.isTouchDevice() || !this.terminal?.element) return;
try {
@@ -3918,7 +4044,7 @@ Object.assign(CodemanApp.prototype, {
const mouseMode = this.terminal.modes?.mouseTrackingMode;
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input';
if (!mouseTrackingOn && !this._shouldReportMouseToCli()) return 'input';
const buffer = this.terminal.buffer?.active;
if (!buffer?.getLine) return 'input';
@@ -4218,9 +4344,10 @@ Object.assign(CodemanApp.prototype, {
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
// follows the same path as a desktop click.
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
} else if (shouldActivate && this._sessionUsesServerMouseStrip()) {
} else if (shouldActivate && this._shouldReportMouseToCli()) {
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
// report directly to the PTY while retaining local touch scrollback.
// report directly to the PTY while retaining local touch scrollback. Only
// while the CLI actually has tracking on (see _shouldReportMouseToCli).
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}
@@ -4288,9 +4415,32 @@ Object.assign(CodemanApp.prototype, {
// output stream has mouse-tracking DECSET sequences stripped before reaching the
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
_sessionUsesServerMouseStrip() {
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
return mode === 'claude' || mode === 'codex' || mode === 'gemini';
/**
* True when the browser has to hand-encode a click report for the CLI.
*
* Two conditions, and dropping either one is a bug that has already happened:
*
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
* xterm's own encoder is permanently idle here and something has to stand
* in for it.
* 2. The CLI actually has a mouse-tracking mode on right now. The server
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
* publishes it as `cliMouseTracking`. Without this half the browser
* reported EVERY click, so a CLI sitting at its composer with no dialog
* open, or a pane that has fallen back to a shell prompt, received mouse
* reports it never asked for. A shell prints those as literal text
* (`[<0;88;20M`) and they garble the next line typed.
*
* Fails toward silence: an unknown or stale flag reports nothing rather than
* injecting bytes. After a server restart the flag is false until the CLI
* re-emits its DECSET, which closing and reopening a dialog does.
*/
_shouldReportMouseToCli() {
const session = this.sessions?.get(this.activeSessionId);
const mode = session?.mode || 'claude';
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
return session?.cliMouseTracking === true;
},
// True when xterm's viewport shows the live PTY screen (not scrolled up into
@@ -4556,7 +4706,7 @@ Object.assign(CodemanApp.prototype, {
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
// for a plain left-click when the server strips mouse DECSETs (see
// _sessionUsesServerMouseStrip). Every skip below is a click that already has
// _shouldReportMouseToCli). Every skip below is a click that already has
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
// reported already), modified clicks (shift keeps xterm's selection
// override), double/triple clicks (word/line selection), drag-selections,
@@ -4570,7 +4720,7 @@ Object.assign(CodemanApp.prototype, {
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
const mode = this.terminal.modes?.mouseTrackingMode;
if (mode && mode !== 'none') return;
if (!this._sessionUsesServerMouseStrip()) return;
if (!this._shouldReportMouseToCli()) return;
if (this.terminal.hasSelection?.()) return;
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
+17
View File
@@ -52,6 +52,7 @@ export interface SessionListenerRefs {
limitResumeCancelled: (data: { reason: string }) => void;
respawnBreakerTripped: (data: { count: number }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
mouseTrackingChanged: (active: boolean) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
ralphCompletionDetected: (phrase: string) => void;
@@ -350,6 +351,20 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.broadcastSessionStateDebounced(session.id);
},
/**
* The CLI turned mouse tracking on or off (observed while stripping the
* DECSETs out of the stream). Rides the full session state so the browser
* learns it through the session object it already merges, with no new SSE
* event to keep in sync across the two registries.
*
* Broadcast IMMEDIATELY, not debounced: this flips when a dialog opens, and
* a user can click that dialog inside the 500ms debounce window, which is
* exactly the click that has to be reported.
*/
mouseTrackingChanged: () => {
deps.broadcast(SseEvent.SessionUpdated, { session: deps.getSessionStateWithRespawn(session) });
},
// ─── Ralph Tracking Events ──────────────────────────────
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
@@ -461,6 +476,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('limitResumeCancelled', refs.limitResumeCancelled);
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('mouseTrackingChanged', refs.mouseTrackingChanged);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
@@ -495,6 +511,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('limitResumeCancelled', refs.limitResumeCancelled);
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('mouseTrackingChanged', refs.mouseTrackingChanged);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);