fix(web): terminal paste shortcut no longer opens Firefox's Paste prompt#27478
Problem
In Firefox, pressing Ctrl+Shift+V (or Shift+Insert) in the web terminal pastes the text and also opens Firefox's native one-item Paste menu at the pointer, which takes focus from the terminal. Reproduced on main 5ef56853ef with the web dev server in Firefox 150 on Linux (Xvfb): copy text from another site, focus the terminal, press the shortcut.
Change
The paste shortcut called navigator.clipboard.readText() during the keydown, alongside the browser's own paste. Firefox (and Safari) confirm reads of clipboard content from another origin or app with that menu, even though the native paste event had already delivered the text and the read's result was going to be dropped.
The read now waits one task (setTimeout(…, 0)) and is skipped when onPaste has already claimed the paste token. The browser's native paste for the shortcut fires inside the keydown task, so in Firefox the read never starts. When no native paste arrives, or it arrives later (Electron's menu paste), the read still runs as the fallback. Duplicate suppression is unchanged.
Scope and approval
Fixes pingdotgg/t3code#27439
Verification
vp test run apps/web/src/terminal/ghostty/surface.test.ts: 34/34 pass. The new testdoes not read the clipboard when the shortcut's native paste arrivesfails on main (expected "vi.fn()" to not be called at all, but actually been called 1 times). Two existing paste-ordering tests now advance the fake timers before the read.vp run check:changed: pass.- Real app, before = 5ef56853ef, after = bd75fadd42 (terminal code identical to this head), each on its own dev server with a fresh database, Playwright Firefox 150 headful on Xvfb, 1280×800, light, DPR 1, whole X display captured so the browser's native menu is visible. Clipboard text copied from a different-origin tab. An init script logs
readTextcalls and nativepasteevents.
R1: Firefox, Ctrl+Shift+V, clipboard from another site. Before: readText called, then the native paste, and the Paste menu opens at the pointer. After: native paste only, no readText call, no menu. Text is pasted once on both sides. Recording: clean prompt, shortcut at about 0.5 s, settled for 2 s, then __focus_ok__ typed without clicking.
| Before (5ef56853ef) | After (bd75fadd42) | Diff0 | DiffT (>5/255) |
|---|---|---|---|
R2: Firefox, Shift+Insert, clipboard from another site. Same result as R1.
| Before (5ef56853ef) | After (bd75fadd42) | Diff0 | DiffT (>5/255) |
|---|---|---|---|
R3: Firefox, clipboard copied inside T3 Code (same origin), control. No menu on either side and one paste. Main still reads and drops the result; the head does not read. The stills are pixel-identical.
| Before (5ef56853ef) | After (bd75fadd42) | Diff0 |
|---|---|---|
R4: Chromium 148 headless shell, after only, behavioural control. Native paste arrives in the keydown task, no readText call, text pasted once.
What else changed: at zero threshold, R1 and R2 differ only inside the 62×34 menu rectangle (2,108 pixels), and R3 has no changed pixels. An independent rebuild of R1-after matched the delivered capture with 0 differing pixels.
Not checked:
- Whether the menu takes OS keyboard focus. Playwright injects keys into the page, past the menu, so the recordings show that the menu is gone but not the focus loss itself.
- macOS: Safari and Chrome with Cmd+V, and the Electron desktop app. On macOS the native paste likely arrives after the timer, through the app menu, so the read still runs there. That matches main: in Electron, which shows no prompt, the existing duplicate guard covers it, and Safari may still prompt.
- Wayland: the reporter's session (niri) and headful Chromium.
Capture driver (Playwright, scratch, not committed)
| 1 | import { firefox, chromium } from 'playwright'; |
| 2 | import { execFileSync, spawn } from 'node:child_process'; |
| 3 | import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; |
| 4 | import { once } from 'node:events'; |
| 5 | const [,, serverLog, outRoot, profile, label='run', key='Control+Shift+V', source='other', engine='firefox', mode='capture'] = process.argv; |
| 6 | const out = `${outRoot}/${label}-${new Date().toISOString().replace(/[:.]/g,'-')}`; |
| 7 | mkdirSync(out,{recursive:true}); |
| 8 | const server = readFileSync(serverLog,'utf8'); |
| 9 | let url = server.match(/pairingUrl:\s*(https?:\/\/[^\s\x1b]+)/)?.[1]; |
| 10 | if (!url) throw new Error('pairing URL not ready'); |
| 11 | const cookieFile=profile+(engine==='chromium'?'-chromium':'')+'-cookies.json'; |
| 12 | const actualProfile=profile+'/'+label+'-'+new Date().toISOString().replace(/[:.]/g,'-'); |
| 13 | if(existsSync(cookieFile)) url=new URL(url).origin; |
| 14 | const ctx = await (engine==='chromium'?chromium:firefox).launchPersistentContext(actualProfile,{ |
| 15 | headless:engine==='chromium', viewport:{width:1280,height:800}, deviceScaleFactor:1, |
| 16 | colorScheme:'light', reducedMotion:'reduce', |
| 17 | ...(engine==='chromium'?{permissions:['clipboard-read','clipboard-write']}:{args:['--kiosk']}), |
| 18 | }); |
| 19 | if(existsSync(cookieFile)) await ctx.addCookies(JSON.parse(readFileSync(cookieFile,'utf8'))); |
| 20 | const p=ctx.pages()[0]??await ctx.newPage(); |
| 21 | const log=(v)=>{console.log(v);}; |
| 22 | try { |
| 23 | await ctx.addInitScript(()=>{ |
| 24 | try { localStorage.setItem('t3code:theme','light'); localStorage.setItem('t3code:theme-follow-system','false'); localStorage.setItem('t3code:theme-appearance-mode','light'); } catch {} |
| 25 | const c=navigator.clipboard; if(!c) return; |
| 26 | const original=c.readText.bind(c); window.__readLog=[]; |
| 27 | c.readText=()=>{window.__readLog.push('readText called');const r=original();r.then(v=>window.__readLog.push(`readText resolved: ${JSON.stringify(v)}`),e=>window.__readLog.push(`readText rejected: ${e.name}`));return r;}; |
| 28 | window.addEventListener('paste',e=>window.__readLog.push(`native paste event: ${JSON.stringify(e.clipboardData?.getData('text/plain'))}`),true); |
| 29 | }); |
| 30 | await p.clock.setFixedTime(new Date('2026-10-30T09:00:00Z')); |
| 31 | await p.goto(url); |
| 32 | if(url.includes('/pair')) await p.waitForURL(u=>!u.pathname.startsWith('/pair'),{timeout:120000}); |
| 33 | writeFileSync(cookieFile,JSON.stringify(await ctx.cookies())); |
| 34 | await p.getByText('Send a message to start the conversation.').waitFor({timeout:120000}); |
| 35 | if(await p.getByRole('button',{name:'Toggle thread details panel',exact:true}).getAttribute('aria-pressed')==='true') await p.getByRole('button',{name:'Toggle thread details panel',exact:true}).click(); |
| 36 | const sidebar=p.locator('[data-slot="sidebar"][data-side="left"]'); |
| 37 | if(await sidebar.getAttribute('data-state')==='expanded') await p.getByRole('button',{name:'Toggle main sidebar',exact:true}).click(); |
| 38 | const input=p.getByLabel('Terminal input'); |
| 39 | await p.waitForTimeout(3000); |
| 40 | if(await input.count()===0) await p.getByRole('button',{name:'Toggle terminal drawer',exact:true}).click(); |
| 41 | await input.waitFor({state:'attached',timeout:30000}); |
| 42 | await p.waitForTimeout(3000); |
| 43 | log({browser:ctx.browser().version(),environment:await p.evaluate(()=>({ua:navigator.userAgent,dpr:devicePixelRatio,viewport:[innerWidth,innerHeight],outer:[outerWidth,outerHeight],fontSize:getComputedStyle(document.documentElement).fontSize,theme:localStorage.getItem('t3code:theme'),screen:[screen.width,screen.height]}))}); |
| 44 | log(execFileSync('xwininfo',['-root','-tree'],{encoding:'utf8'})); |
| 45 | if(mode==='smoke') { |
| 46 | execFileSync('import',['-window','root',`${out}/smoke.png`]); |
| 47 | log(await p.locator('body').innerText()); |
| 48 | log(await p.getByRole('button').evaluateAll(els=>els.map(e=>({label:e.getAttribute('aria-label'),title:e.getAttribute('title'),text:e.innerText})))) |
| 49 | } else { |
| 50 | const box=await p.locator('canvas').last().boundingBox(); |
| 51 | await p.mouse.click(box.x+200,box.y+40); |
| 52 | await p.keyboard.press('Control+C'); |
| 53 | await p.keyboard.type("export PS1='fixture$ '; printf '\\033[?12l'; clear"); |
| 54 | await p.keyboard.press('Enter'); |
| 55 | await p.waitForTimeout(1000); |
| 56 | if(source==='same') { |
| 57 | await p.locator('[contenteditable=true]').first().click(); |
| 58 | await p.keyboard.type('echo copied-inside-t3'); |
| 59 | await p.keyboard.press('Control+A'); await p.keyboard.press('Control+C'); await p.keyboard.press('Delete'); |
| 60 | } else { |
| 61 | const other=await ctx.newPage();await other.goto("data:text/html,<input id=src value='echo pasted-from-another-site'>"); |
| 62 | await other.click('#src');await other.keyboard.press('Control+A');await other.keyboard.press('Control+C');await other.close(); |
| 63 | } |
| 64 | await p.bringToFront();await p.mouse.click(box.x+200,box.y+40);await p.mouse.move(box.x+400,box.y+60); |
| 65 | const shot=async(path)=>engine==='chromium'?p.screenshot({path}):execFileSync('import',['-window','root',path]); |
| 66 | const stable=async(name)=>{ |
| 67 | await p.waitForLoadState('networkidle');await p.evaluate(()=>document.fonts.ready); |
| 68 | for(let n=0;n<12;n++) { |
| 69 | await shot(`${out}/${name}-stable-${n}-a.png`);await p.waitForTimeout(500);await shot(`${out}/${name}-stable-${n}-b.png`); |
| 70 | const cmp=spawn('compare',['-metric','AE',`${out}/${name}-stable-${n}-a.png`,`${out}/${name}-stable-${n}-b.png`,'null:']); |
| 71 | let metric='';cmp.stderr.on('data',b=>metric+=b);const [code]=await once(cmp,'exit'); |
| 72 | log({stable:name,attempt:n,metric:metric.trim()}); |
| 73 | if(code===0) {execFileSync('cp',[`${out}/${name}-stable-${n}-b.png`,`${out}/${name}.png`]);return;} |
| 74 | } throw new Error('Unstable frame: '+name); |
| 75 | }; |
| 76 | await p.waitForTimeout(500);await stable('pre'); |
| 77 | let recorder; |
| 78 | if(engine==='firefox' && (label.startsWith('r1')||label.startsWith('r2'))) { |
| 79 | recorder=spawn('ffmpeg',['-hide_banner','-loglevel','warning','-f','x11grab','-framerate','15','-video_size','1920x1080','-i',process.env.DISPLAY,'-vf','crop=1280:800:0:0','-c:v','libx264','-preset','ultrafast','-crf','12',`${out}/recording.mkv`],{stdio:['pipe','ignore','pipe']}); |
| 80 | recorder.stderr.on('data',b=>log('ffmpeg: '+b));await p.waitForTimeout(500); |
| 81 | } |
| 82 | log({action:'shortcut',recordingSeconds:0.5,key,source}); |
| 83 | await p.keyboard.press(key);await p.waitForTimeout(2000); |
| 84 | await stable('post'); |
| 85 | log({action:'type without click',text:'__focus_ok__'}); |
| 86 | await p.keyboard.type('__focus_ok__');await p.waitForTimeout(1000); |
| 87 | await shot(`${out}/after-typing.png`); |
| 88 | if(recorder){recorder.stdin.write('q\n');await once(recorder,'exit');} |
| 89 | log(await p.evaluate(()=>window.__readLog)); |
| 90 | log({activeElement:await p.evaluate(()=>document.activeElement?.getAttribute('aria-label'))}); |
| 91 | } |
| 92 | } catch(e) {execFileSync('import',['-window','root',`${out}/error.png`]);throw e;} |
| 93 | finally {log(`out: ${out}`);await ctx.close();} |
Model and harness: Claude Opus 5.5 (lead, red test, implementation, review B) and GPT-6.1 Sol (review A, proof audits, captures) via Claude Code and Codex in T3 Code.
🤖 Generated with Claude Code
Checks
Added 4 commits
Approvability
Verdict: Approved at b6f82e6
Macroscope's review found this PR approvable — The terminal paste shortcut now gives native paste time to complete before attempting a clipboard read, avoiding Firefox's unnecessary prompt. Existing duplicate-paste and disposal guards remain intact, keeping the change narrowly scoped.
You can add or adjust custom eligibility rules. Learn more.
No actionable comments were generated in the recent review.
External image Recent review info
-
External image Run configuration
- Source mode: Auto
- Repository: pingdotgg/t3code
- Branch: fix/issue-27439
- File: .coderabbit.config.ts
- Commit: b6f82e6
- Layers: Repository TS config
- Review profile: CHILL
- Plan: Team
- Run ID:
d001f647-66bc-4ba2-8248-be7bfea5ccc8
External image Commits
Reviewing files that changed from the base of the PR and between 32ecd71726fee16f335484c1f95cbce126d461f6 and b6f82e6185df8775d6a68f218ddfe19e73fbee06.
External image Files selected for processing: 2
apps/web/src/terminal/ghostty/surface.test.tsapps/web/src/terminal/ghostty/surface.ts
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review.
Walkthrough
The terminal paste shortcut now defers its clipboard read by one task and checks whether the surface and shortcut token remain current. Tests cover native paste arriving before the read and the later-menu-paste case.
Changes: Terminal paste shortcut
Layer / File(s) Summary Defer clipboard fallback and verify paste behavior
apps/web/src/terminal/ghostty/surface.ts,apps/web/src/terminal/ghostty/surface.test.tsonKeyDownschedules the clipboard read with a zero-delay timer and checks surface and token state before and after the read. Tests verify that a native paste arriving first prevents the read and delivers text once. The later-menu-paste test advances fake timers before its assertion.Priority: External image Low
Estimated code review effort: 2 (Simple) | ~10 minutes
Change: Bug fix · Severity of issue fixed: Low
Possibly related PRs
- pingdotgg/t3code#27451: The PR describes the same deferred clipboard-read fallback and native-paste token check.
Suggested reviewers:
t3dotgg,maria-rcks
Merge Risk: External image Minimal · up to b6f82
The shortcut avoids a redundant clipboard read when native paste arrives first. No merge-blocking risk was established.
Pre-merge checks: Passed 4
Check name Status Explanation Title check Passed The title clearly and concisely describes the main fix: preventing the terminal paste shortcut from opening Firefox's Paste prompt. Description check Passed The description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the reproduction, implementation, linked issue, automated tests, manual browser valida… Linked Issues check Passed Directly linked issue #27439requires that the paste shortcut paste text once and avoid Firefox's native Paste menu, which takes focus from the terminal. The issue names the cause: an immediate clipbo…Out of Scope Changes check Passed The whole-PR diff against the merge base touches two files. apps/web/src/terminal/ghostty/surface.ts changes only the paste-shortcut clipboard read in onKeyDown. It wraps the read in setTimeout(..., 0… Full details: Description check
Explanation
The description includes the required Problem, Change, Scope and approval, and Verification sections. It explains the reproduction, implementation, linked issue, automated tests, manual browser validation, limitations, and observed results.
Full details: Linked Issues check
Explanation
Directly linked issue
#27439requires that the paste shortcut paste text once and avoid Firefox's native Paste menu, which takes focus from the terminal. The issue names the cause: an immediate clipboard.readText() in GhosttyTerminalSurface.onKeyDown, whose result is discarded when the native paste already claimed the token. The head commit addresses this in apps/web/src/terminal/ghostty/surface.ts. The read now runs inside setTimeout(..., 0). The timer callback returns early when this.pasteShortcutToken !== token, so a native paste that claims the token first skips the read. The read stays as a fallback when no native paste arrives, or when it arrives later. Duplicate suppression logic is unchanged. Automated coverage: the new test 'does not read the clipboard when the shortcut's native paste arrives' asserts readText is not called and onData receives the text once. Two existing paste-ordering tests now advance fake timers before asserting. The PR reports that this test fails on main and passes at head. Manual evidence (Firefox 150 on Xvfb, text copied from another origin) shows no readText call and no Paste menu for Ctrl+Shift+V and Shift+Insert, with one paste. Limits: the capture shows the menu is absent, but it does not measure OS-level keyboard focus. Because the menu is the stated focus-stealing mechanism, the evidence supports the requirement. The issue scope is Firefox on Linux, which the PR tested.
Full details: Out of Scope Changes check
Explanation
The whole-PR diff against the merge base touches two files. apps/web/src/terminal/ghostty/surface.ts changes only the paste-shortcut clipboard read in onKeyDown. It wraps the read in setTimeout(..., 0) and adds a timer-level token check. apps/web/src/terminal/ghostty/surface.test.ts adds one regression test for the native-paste-first case. It also changes two existing paste-ordering tests to advance fake timers instead of awaiting the read promise. Those test edits follow from the deferred read and support the same objective. No unrelated functions, files, or behavior changes appear in the diff. The PR description's verification notes cover only the paste-shortcut path.
Finishing Touches
📝 Generate docstrings
- Commit to this branch
- Create a new PR
🧪 Generate unit tests (beta)
- Commit to this branch
- Create a new PR
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Comment @coderabbitai help to get the list of available commands.
Note
🤖 GPT-6.1 Terra [1m] responding on behalf of Theo
Closing in favor of the permission-aware Firefox paste fix. This timer-based patch still calls readText when native paste is late, the ordering covered by the retained regression. #27451 #27451 comment