From fe1c51eeb5c6d4dfb68eeeae42a0e6a14e9fb380 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:10:59 +0200 Subject: [PATCH] fix(terminal): one Ctrl+V is one paste, not two Owner, 2026-09-22: "found a bug in the terminals. when I copy text and paste it pastes twice", with every word in the screenshot arriving doubled. The key handler pasted and returned false. Returning false only tells xterm to leave the key alone; it does not cancel the browser's own action for Ctrl+V, which is a native paste event into xterm's textarea, and xterm pastes on that too. So every paste arrived twice, in both apps (the core is shared). preventDefault() now cancels the native one for Ctrl+V and Ctrl+Shift+V. The new `paste` e2e counts what reached the shell: it failed with 2 copies for both keys before the fix and passes with 1 after, and the right-click Paste is held to the same. The owner's clipboard is saved and put back. App 0.7.1, core 0.6.1. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 9 +++ core/package.json | 2 +- core/renderer/components/TerminalPanel.tsx | 10 ++++ package-lock.json | 4 +- package.json | 2 +- tools/e2e/run.mjs | 65 ++++++++++++++++++++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f409b85..d22bc06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,6 +139,15 @@ so an update never silently changes what an existing user sees; the bridge to ma leaves mid-install (the host's, never the user's), and a running install's window comes back when the question has gone; `updateGuard` holds it. The window's Tab trap also leaves Ctrl+Tab alone, which used to switch tabs AND move the focus inside the window in one press. +- **ONE CTRL+V IS ONE PASTE** (owner, 2026-09-22: "found a bug in the terminals. when I copy text and + paste it pastes twice"). The key handler pastes and returns `false`, and returning false only + tells XTERM to leave the key alone: it does not cancel the BROWSER's default for Ctrl+V, which is + a native paste event into xterm's textarea, which xterm pastes as well. Every paste arrived + twice, in both apps. The handler now calls `preventDefault()` for Ctrl+V and Ctrl+Shift+V, so + its own paste (the one that knows images and bracketed framing) is the only one. Any key the + core takes over from the browser needs the same: returning false is not cancelling. The + `paste` e2e counts what reached the shell for Ctrl+V, Ctrl+Shift+V and the right-click Paste + (it failed with 2 copies before the fix). - **COMMAND HELP IS A POPUP THAT SHOWS AND COPIES, AND NOTHING ELSE** (#12; owner, 2026-09-19: "an easy to use panel where you can find shell commands... searchable... metadata on each command so a natural-language search finds it... optional in settings", and 2026-09-20: "a pop up with copy diff --git a/core/package.json b/core/package.json index 914511b..83dbe99 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.6.0", + "version": "0.6.1", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index d23fc62..c2eee3c 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -480,11 +480,21 @@ function createSession(id: string, root: string, shellId: string | undefined): S termApi().termInput(id, '\\\r') return false } + // ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "when I copy text and paste + // it pastes twice"). Returning false only tells XTERM to leave the key + // alone; it does not cancel the BROWSER's own action for Ctrl+V, which is + // a native paste event into xterm's textarea - and xterm pastes on that + // event too. So every paste arrived twice, once from here and once from + // there. preventDefault cancels the native one; this handler is the paste, + // since it is the one that knows about images (Claude Code reads those + // itself when it gets the ^V keystroke) and bracketed framing. if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && !e.shiftKey) { + e.preventDefault() pasteHere() return false } if ((e.key === 'v' || e.key === 'V') && e.ctrlKey && e.shiftKey) { + e.preventDefault() // The escape hatch: plain text paste even when an image rides along. const clip = termApi().readClipboard() if (clip.text) { diff --git a/package-lock.json b/package-lock.json index 85e541d..bb07ab0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.7.0", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.7.0", + "version": "0.7.1", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 92a8973..359407f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.7.0", + "version": "0.7.1", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 81c544f..630ffaf 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -383,6 +383,71 @@ const scenarios = { }, /** Tabs come back in their folders; a folder that has gone is dropped. */ + /** + * ONE CTRL+V IS ONE PASTE (owner, 2026-09-22: "found a bug in the terminals. + * when I copy text and paste it pastes twice", with a screenshot of every + * word arriving doubled). What is counted is what reached the SHELL, read off + * the terminal, for a plain Ctrl+V, for the Ctrl+Shift+V text-only escape + * hatch, and for the right-click Paste. The clipboard is the owner's: it is + * saved first and put back at the end. + */ + async paste(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha] }) + await until(async () => (await tabLabels(page)).length === 1) + const held = await app.evaluate(({ clipboard }) => ({ + text: clipboard.readText(), + html: clipboard.readHTML(), + rtf: clipboard.readRTF(), + image: clipboard.readImage().isEmpty() ? null : clipboard.readImage().toDataURL(), + formats: clipboard.availableFormats() + })) + try { + await page.waitForFunction(() => /PS [^>]*>\s*$/.test((document.querySelector('.xterm .xterm-rows')?.textContent ?? '').trimEnd()), null, { timeout: 45000 }) + const count = async (word) => ((await termText(page)).match(new RegExp(word, 'g')) ?? []).length + const clear = async () => { + // Esc clears PSReadLine's line, so each paste is counted on its own. + await page.keyboard.press('Escape') + await sleep(300) + } + await page.locator('.xterm').first().click() + + await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEA')) + await page.keyboard.press('Control+v') + await sleep(900) + ok((await count('PASTEONCEA')) === 1, `Ctrl+V pastes the text ONCE (${await count('PASTEONCEA')} copies arrived)`) + await clear() + + await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEB')) + await page.keyboard.press('Control+Shift+v') + await sleep(900) + ok((await count('PASTEONCEB')) === 1, `Ctrl+Shift+V pastes the text ONCE (${await count('PASTEONCEB')} copies arrived)`) + await clear() + + // The right-click menu's Paste reaches the same one paste, by another door. + await app.evaluate(({ clipboard }) => clipboard.writeText('PASTEONCEC')) + await page.locator('.xterm').first().click({ button: 'right' }) + await page.locator('[role="menuitem"]:has-text("Paste")').first().click() + await sleep(900) + ok((await count('PASTEONCEC')) === 1, `the right-click Paste pastes ONCE (${await count('PASTEONCEC')} copies arrived)`) + await clear() + } finally { + await app + .evaluate(({ clipboard, nativeImage }, was) => { + const data = {} + if (was.text) data.text = was.text + if (was.html) data.html = was.html + if (was.rtf) data.rtf = was.rtf + if (was.image) data.image = nativeImage.createFromDataURL(was.image) + if (Object.keys(data).length) clipboard.write(data) + else clipboard.clear() + }, held) + .catch(() => {}) + if (held.formats.some((f) => /FileName|uri-list/i.test(f))) console.log(' (the clipboard held copied FILES, which cannot be put back; it is empty now)') + await app.close().catch(() => {}) + } + }, + async restore(ok) { const w = world() let { app, page } = await launch(w, { args: [w.alpha, w.beta] })