diff --git a/README.md b/README.md index 18c2136..5b73f41 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,8 @@ The tmux server runs in its own unit, so every Claude process lives in **that** | Ctrl-b 0 / 1 | Claude window / shell window | | Ctrl-b s | Switch session (the URL follows) | | Ctrl-b d | Detach (the session keeps running) | -| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) | +| Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. | +| Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual | | Ctrl+Shift+C / V | Copy selection / paste | | Ctrl+Shift+P | Command palette (also the ⌕ button) | | Ctrl+Shift+E | Files pane (Esc: back, then close) | diff --git a/debian/changelog b/debian/changelog index 33cce5a..d2f4b01 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +devdesk (0.10.2) bookworm; urgency=medium + + * Selecting text in the terminal works like a normal terminal (#51). A + drag is a browser selection that stays highlighted and is copied when + you let go, with a short toast. Ctrl+C copies the selection and + interrupts only when nothing is selected. The wheel still scrolls tmux + history, and the tmux status line still takes clicks. + + -- Jared Vititoe Mon, 12 Oct 2026 16:36:00 -0400 + devdesk (0.10.1) bookworm; urgency=medium * Everything in 0.10.0, which was never published: its CI test for the diff --git a/web/index.template.html b/web/index.template.html index 1c7f150..ab541fe 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -176,7 +176,7 @@ │ ^b ddetach │ - ⇧dragselect + dragcopy │ drop / pasteattach files │ diff --git a/web/src/main.js b/web/src/main.js index 3342bca..b15ae08 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -876,6 +876,8 @@ const KEY_HELP = [ ['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'], ['Ctrl+P', 'Files: go to file'], ['Ctrl+S', 'Files: save the open file'], + ['Drag', 'Select text; it is copied when you let go'], + ['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'], ['Ctrl+⇧ C / V', 'Copy selection / paste'], ['⇧ drag', 'Select text in the browser'], ['Drop or paste a file', 'Upload it and attach it for Claude'], @@ -1199,6 +1201,7 @@ async function boot() { fontSize, theme: getTheme(), onShiftEnter: () => state.conn?.sendInput('\n'), + onCopied: (text) => toast.success(`Copied ${text.length.toLocaleString()} character${text.length === 1 ? '' : 's'}`, 1500), })); onThemeChange((theme) => { term.options.theme = THEMES[theme]; diff --git a/web/src/term.js b/web/src/term.js index c7b9fce..6c42c32 100644 --- a/web/src/term.js +++ b/web/src/term.js @@ -90,7 +90,7 @@ export async function loadFonts(size) { } } -export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) { +export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) { const term = new Terminal({ fontFamily: FONT_FAMILY, fontSize, @@ -113,16 +113,28 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt window.open(uri, '_blank', 'noopener'); })); + const copySelection = () => { + const text = term.getSelection(); + navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {}); + }; + term.attachCustomKeyEventHandler((event) => { // Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit. if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) { if (event.type === 'keydown') onShiftEnter(); return false; } + // Ctrl+C with text selected copies it instead of interrupting (devdesk#51). + if (event.type === 'keydown' && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey + && event.code === 'KeyC' && term.hasSelection()) { + copySelection(); + term.clearSelection(); + return false; + } // Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals. if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) { if (event.code === 'KeyC' && term.hasSelection()) { - navigator.clipboard?.writeText(term.getSelection()).catch(() => {}); + copySelection(); return false; } if (event.code === 'KeyV') { @@ -135,6 +147,35 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt term.open(container); + // devdesk#51: tmux runs with the mouse on (wheel scrolls its history), so a + // drag would select inside tmux, which copies and then drops the highlight on + // release. A left-button drag in the terminal is made a browser selection + // instead (what Shift+drag does): it stays highlighted and is copied on + // release. Clicks on tmux's status line (the window tabs) still go to tmux. + const screen = () => container.querySelector('.xterm-screen'); + container.addEventListener('mousedown', (event) => { + if (event.button !== 0 || event.shiftKey || event.ddForced || term.modes.mouseTrackingMode === 'none') return; + const rect = screen()?.getBoundingClientRect(); + if (!rect) return; + const row = Math.floor(((event.clientY - rect.top) / rect.height) * term.rows); + if (row >= term.rows - 1) return; // status line + event.preventDefault(); + event.stopImmediatePropagation(); + const forced = new MouseEvent('mousedown', { + bubbles: true, cancelable: true, view: window, detail: event.detail, button: 0, buttons: event.buttons, + clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY, + shiftKey: true, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, + }); + forced.ddForced = true; + event.target.dispatchEvent(forced); + }, true); + // Copy when the drag (or double/triple click) ends with something selected. + container.addEventListener('mouseup', () => { + setTimeout(() => { + if (term.hasSelection() && term.getSelection().trim()) copySelection(); + }, 0); + }); + // Touch devices: the DOM renderer is more reliable at high DPR than WebGL. if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit }; try {