feat(terminal): Auto Copy, put a finished selection on the clipboard

App Settings > Terminal & Input > Selection & clipboard > Auto Copy
Selection (`autoCopySelection`, per-device, default OFF). With it on,
highlighting text in the terminal copies it: mouse drag, double-click
word, triple-click line, and the phone long-press selection. Ctrl+C is
untouched and still copies on demand.

Three things decide the shape of it:

* It fires at the END of a gesture, never in onSelectionChange. That
  callback runs for every cell a drag crosses, so copying there would be
  one clipboard write per mouse move. It only arms a pending flag; a
  document-level mouseup listener flushes, and the touch path calls the
  flush itself because it preventDefaults its touchend and no mouseup
  ever arrives there.
* The flush is synchronous inside the handler, because both clipboard
  paths need user activation: Firefox gates navigator.clipboard
  .writeText on it, and execCommand('copy'), the fallback the plain-HTTP
  LAN install lands on, has to run in the gesture's own task. A timer or
  a wait for onSelectionChange loses it, invisibly in Chrome.
* It deliberately does NOT do what copyTerminalSelection() does. That
  one clears the selection (so a second Ctrl+C is an interrupt) and
  focuses the terminal. Clearing would make text vanish under the cursor
  that just highlighted it, and focusing opens the on-screen keyboard
  over it on a phone. Focus is instead restored to whatever held it,
  which only matters for the execCommand fallback.

Guards are pure in decideAutoCopy() (constants.js): off, blank or
whitespace-only text, and a 1M-char cap, since a drag off the top of the
viewport autoscrolls and one gesture can sweep the whole 50k-line
scrollback. Past the cap the copy is refused rather than truncated, with
a toast pointing at Ctrl+C.

Feedback is silent on success except once per page load, so a feature
that works by doing nothing visible can still be told from a dead
toggle; failures and refusals toast, throttled to 10s.

Per-device on both counts the settings rule requires: in `displayKeys`
and absent from the .strict() SettingsUpdateSchema, because clipboard
access differs by device and by origin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-20 10:46:11 +02:00
parent 07b9c7fd7b
commit 7936a75e28
9 changed files with 561 additions and 0 deletions
+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
@@ -381,6 +381,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;
@@ -2007,6 +2011,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,
@@ -2203,6 +2208,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,
@@ -2883,6 +2893,7 @@ Object.assign(CodemanApp.prototype, {
'terminalFontFamily',
'language',
'terminalWheelLocalScrollback',
'autoCopySelection',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
'showTabDetachButton',
'mobileOverviewEnabled',
+126
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 {