mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
+59
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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") */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1627,6 +1627,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Selection & 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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user