fix(web): terminal paste shortcut no longer opens Firefox's Paste prompt#27478

closed4
main
fix/issue-27439
opened13d agobyJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 10d ago

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 test does not read the clipboard when the shortcut's native paste arrives fails 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 readText calls and native paste events.

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)Diff0DiffT (>5/255)

R2: Firefox, Shift+Insert, clipboard from another site. Same result as R1.

Before (5ef56853ef)After (bd75fadd42)Diff0DiffT (>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)
js
1import { firefox, chromium } from 'playwright';
2import { execFileSync, spawn } from 'node:child_process';
3import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
4import { once } from 'node:events';
5const [,, serverLog, outRoot, profile, label='run', key='Control+Shift+V', source='other', engine='firefox', mode='capture'] = process.argv;
6const out = `${outRoot}/${label}-${new Date().toISOString().replace(/[:.]/g,'-')}`;
7mkdirSync(out,{recursive:true});
8const server = readFileSync(serverLog,'utf8');
9let url = server.match(/pairingUrl:\s*(https?:\/\/[^\s\x1b]+)/)?.[1];
10if (!url) throw new Error('pairing URL not ready');
11const cookieFile=profile+(engine==='chromium'?'-chromium':'')+'-cookies.json';
12const actualProfile=profile+'/'+label+'-'+new Date().toISOString().replace(/[:.]/g,'-');
13if(existsSync(cookieFile)) url=new URL(url).origin;
14const 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});
19if(existsSync(cookieFile)) await ctx.addCookies(JSON.parse(readFileSync(cookieFile,'utf8')));
20const p=ctx.pages()[0]??await ctx.newPage();
21const log=(v)=>{console.log(v);};
22try {
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;}
93finally {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

23 successful · 17 skipped
All checks passed
b6f82e6

Added 4 commits

  • fix(web): terminal paste shortcut no longer opens Firefox's Paste prompt6d621bc
  • Merge remote-tracking branch 'origin/main' into fix/issue-27439b6f82e6
  • fix(web): narrow the deferred terminal paste read commentbd75fad
  • test(web): reproduce #27439 terminal paste shortcut reading clipboardcaeaf3a
Synced from GitHub

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.

Synced from GitHub

Review in Change Stack →

No actionable comments were generated in the recent review.

External image Recent review info
External image Run configuration
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.ts
  • apps/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.ts
onKeyDown schedules 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 nameStatusExplanation
Title checkPassedThe title clearly and concisely describes the main fix: preventing the terminal paste shortcut from opening Firefox's Paste prompt.
Description checkPassedThe 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 checkPassedDirectly linked issue #27439 requires 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 checkPassedThe 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 #27439 requires 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