# Terminal smart copy (Ctrl+C) plan Issue: [#211](https://github.com/Ark0N/Codeman/issues/211) "Terminal: Ctrl+C should copy when text is selected (interrupt otherwise)". Origin: r/selfhosted feedback, "Biggest stumbling block is apparent lack of copy-paste in the terminal." Status: **implemented and shipped** on 2026-08-05 (this document is kept as the rationale record). It was first served as an isolated beta over Tailscale for manual sign-off, then landed. Section 2 is the research that shaped the design, sections 4 to 6 describe what was built. --- ## 1. What the issue asks for - Text selected in the terminal + `Ctrl+C` -> copy the selection, toast, clear the selection, do NOT send the byte to the PTY. - No selection + `Ctrl+C` -> unchanged, the interrupt (`0x03`) reaches the PTY. - `Ctrl+Shift+C` as an explicit copy chord. - The selection check must run before the shortcut registry dispatch so a rebind cannot cost the user their interrupt key. - Paste is out of scope (it already works via `Ctrl+V`, which terminal-ui.js routes to the image/text paste trap). ## 2. Verified current behavior ### 2.1 xterm cancels the Ctrl+C keydown, so no copy can happen `src/web/public/vendor/xterm.min.js` (xterm 6.x), `_keyDown`: ```js _keyDown(x){ if(this._keyDownHandled=!1, this._keyDownSeen=!0, this._customKeyEventHandler && this._customKeyEventHandler(x)===!1) return !1; ... evaluateKeyboardEvent(...) ... this.cancel(x) ... } ``` Two consequences that shape the design: 1. The custom handler runs **first**, before xterm evaluates the key. Returning `false` exits before `cancel(x)`, so returning `false` does **not** call `preventDefault()` for us. 2. When the handler returns `true`, xterm turns Ctrl+C into `0x03` and cancels the event, which is why the browser's own copy command never runs. Probe (headless chromium against an isolated server on port 3174, selection active, real focus on `.xterm-helper-textarea`, synthetic Ctrl+C keydown): ```json { "hasSelection": true, "defaultPrevented": true, "dataSeen": ["\"\\u0003\""], "clipboardAfter": "SENTINEL-BEFORE", "stillHasSelection": false } ``` So today: interrupt byte sent, clipboard untouched, and xterm drops the selection anyway. The last point matters, "copy then clear the selection" is not a behavior change in how the selection feels, it is what already happens on any keypress. ### 2.2 Why right-click Copy works today xterm registers a `copy` listener on its root element that substitutes the selection text: ```js this._register(addDisposableListener(this.element,"copy",(k=>{ this.hasSelection() && copyHandler(k,this._selectionService) }))) ``` Second probe (port 3175, real `page.keyboard.press('Control+c')`, custom handler patched to return `false` for Ctrl+C without `preventDefault`): ```json { "dataSeen": [], "copyEvents": ["xterm-element"], "clipboardAfter": "native-copy-probe-line\n...", "stillHasSelection": true } ``` So a "return false and let the browser copy" implementation would also work in Chromium. It is rejected below (section 3.3) because it gives no toast, does not clear the selection, and leans on per-browser behavior of the copy command when the focused element is xterm's empty helper textarea. ### 2.3 The document-level capture handler will not interfere `setupEventListeners()` in `src/web/public/app.js:989` runs on document capture, before xterm's textarea listener. Its registry loop skips any entry whose action is not in the local `SHORTCUT_ACTIONS` map: ```js if (shortcut.disabled || !shortcut.action) continue; const action = SHORTCUT_ACTIONS[shortcut.action]; if (!action) continue; ``` This is exactly how `command-palette` already behaves: it is a full registry entry (rebindable and disableable in App Settings) whose dispatch happens in a dedicated, focus-aware gate rather than the generic loop. The new copy entry follows that pattern, so the capture handler falls through untouched and the terminal handler owns the decision. ### 2.4 Registry matching rules that constrain the bindings `matchesShortcutEvent()` (`app.js:4890`): - Ctrl and Cmd are interchangeable as the primary modifier, so a `['ctrl']` binding also matches Cmd+C on macOS. That is fine here: with a selection it copies (same result the native macOS path gives today), without one it falls through. - Every other modifier must be declared exactly: `if (mods.includes('shift') !== !!e.shiftKey) return false`. So `Ctrl+Shift+C` needs its own binding, a plain `ctrl+c` binding will never swallow it. - `binding.code` wins when present, otherwise `binding.key` is compared case-insensitively. ### 2.5 Where selection is actually possible - The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on. - `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change. - Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface. ### 2.6 Helpers that already exist and should be reused | Need | Existing code | | --- | --- | | Clipboard write with an HTTP-safe fallback | `_copyText(text)` in `app.js:1887` (Clipboard API, then hidden textarea + `execCommand`) | | Toast | `showToast(message, type)` in `panels-ui.js:4385` | | Translated string | `'Copied to clipboard'` already in `i18n.js:453` | | Focus-aware chord gate to copy the shape of | `shouldOpenCommandPaletteFromShortcut(e)` in `panels-ui.js:285` | | Buffer-wide copy (currently unreferenced) | `copyTerminal()` in `terminal-ui.js:2615` | `_copyText` matters more than it looks: `install.sh`'s LAN option serves plain HTTP, where `navigator.clipboard` is undefined. The issue's suggested `navigator.clipboard.writeText` alone would silently do nothing for those users, the `execCommand` fallback covers them. ## 3. Design ### 3.1 Behavior | Chord | Selection present | No selection | | --- | --- | --- | | `Ctrl+C` (and Cmd+C, per registry equivalence) | copy, toast, clear selection, swallow the key | fall through, xterm sends `0x03` (interrupt) | | `Ctrl+Shift+C` | copy, toast, clear selection, swallow the key | swallow, no-op (see 3.2) | | Shortcut disabled in App Settings | never copies, `Ctrl+C` is always the interrupt | unchanged | | Rebound to another chord | that chord copies when a selection exists | plain `Ctrl+C` is always the interrupt | ### 3.2 Why `Ctrl+Shift+C` with no selection is swallowed rather than forwarded Today `Ctrl+Shift+C` produces `0x03` as well (the shift is irrelevant to the control byte), so forwarding would be "no regression". But once the chord is advertised as *the explicit copy key*, letting it interrupt a running agent when the selection happens to be empty is a footgun with no upside. Swallowing costs nothing: a user who wants to interrupt has `Ctrl+C` right there. The rule in code is "no selection and the matched chord had Shift -> swallow", not a hardcoded key check, so it stays correct under rebinds. ### 3.3 Why an explicit clipboard write rather than falling through to the native copy Probe 2 showed the native path works in Chromium, but the explicit write is chosen because it: - gives the "Copied to clipboard" toast, which is the discoverability half of the issue, - clears the selection so a second `Ctrl+C` interrupts (the smart-copy contract), - works on plain-HTTP LAN installs through `_copyText`'s `execCommand` fallback, - does not depend on how each browser treats a copy command issued while an empty textarea has focus. ### 3.4 Why no new app setting Per-shortcut enable/disable and rebinding already exist in App Settings -> Shortcuts and are driven by the registry. A user who wants "Ctrl+C is always interrupt" unchecks one box. Adding a `terminalSmartCopy` setting would duplicate that and would drag in the per-device vs synced decision (`displayKeys` + `.strict()` `SettingsUpdateSchema`) for no gain. ## 4. Code changes, file by file ### 4.1 `src/web/public/app.js`, registry entry Add to `DEFAULT_SHORTCUTS` (after the `clear-terminal` entry, ~line 351) so the Terminal group stays together: ```js { id: 'copy-selection', group: 'Terminal', label: 'Copy Selection', bindings: [ { modifiers: ['ctrl'], key: 'c' }, { modifiers: ['ctrl', 'shift'], key: 'C' }, ], // Dispatched by shouldCopyTerminalSelectionFromShortcut() in terminal-ui.js, // deliberately NOT in SHORTCUT_ACTIONS: the generic capture loop always // preventDefaults, which would cost the user the interrupt key. action: 'copyTerminalSelection', }, ``` Match on `key`, not `code`. xterm decides what byte to emit from the produced character, so intercepting the physical `KeyC` on a layout where it does not produce "c" would diverge from what xterm would have sent. The `action` string is required for App Settings to render the row as configurable (`configurable = !!shortcut.action && Array.isArray(shortcut.bindings)`, `settings-ui.js:2624`). Do **not** add `copyTerminalSelection` to `SHORTCUT_ACTIONS`. ### 4.2 `src/web/public/terminal-ui.js`, the gate New prototype method, modeled on `shouldOpenCommandPaletteFromShortcut`: ```js shouldCopyTerminalSelectionFromShortcut(ev) { if (!ev || ev.type !== 'keydown') return false; // the handler also runs for keypress/keyup if (!ev.ctrlKey && !ev.metaKey && !ev.altKey) return false; // hot path: plain typing exits here const registryAvailable = typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function'; const entry = registryAvailable ? this.getShortcutRegistry().find((s) => s.id === 'copy-selection') : null; if (entry) return !entry.disabled && this.matchesShortcutEvent(ev, entry); return (ev.key || '').toLowerCase() === 'c' && !ev.altKey; // fallback for isolated harnesses } ``` ### 4.3 `src/web/public/terminal-ui.js`, the branch Inside `attachCustomKeyEventHandler` (`terminal-ui.js:133`), after the command-palette gate and before the `Ctrl+V` branch: ```js // Smart copy (#211): with a selection, Ctrl+C copies instead of sending ^C. // With no selection it MUST fall through (return true, no preventDefault) or // the interrupt key is lost. Ctrl+Shift+C is the explicit chord and never // falls through: an "explicit copy" that interrupts the agent is a footgun. if (this.shouldCopyTerminalSelectionFromShortcut?.(ev)) { const selection = this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''; if (selection) { ev.preventDefault(); void this.copyTerminalSelection(selection); return false; } if (ev.shiftKey) { ev.preventDefault(); return false; } return true; } ``` `preventDefault()` is explicit because returning `false` alone does not cancel the event (section 2.1), and without it the browser would run its own copy on top of ours. ### 4.4 `src/web/public/terminal-ui.js`, the copy action ```js async copyTerminalSelection(text) { const selection = text ?? (this.terminal.hasSelection?.() ? this.terminal.getSelection() : ''); if (!selection) return false; const ok = await this._copyText(selection); if (ok) { this.terminal.clearSelection?.(); this.showToast('Copied to clipboard', 'success'); } else { this.showToast('Failed to copy', 'error'); } // _copyText's execCommand fallback focuses a temp textarea; restore the // terminal (this.terminal.focus is the CJK-aware router, not xterm's raw focus). this.terminal.focus(); return ok; } ``` The selection text is captured **before** the first `await`, and `navigator.clipboard.writeText` is reached in the same task as the keydown, so user activation still holds. ### 4.5 `src/web/public/i18n.js` `'Copied to clipboard'` exists. Add `'Failed to copy': '复制失败'` (the error path is new to this surface). ### 4.6 Documentation | File | Change | | --- | --- | | `README.md` shortcut table (~line 648) | `\| `Ctrl/Cmd+C` \| Copy selection (interrupts when nothing is selected) \|` and a `Ctrl+Shift+C` row | | `src/web/public/index.html` help modal, Terminal section (~line 641) | `