From d157911166b6b7c5d41d3d90faabe8930bc7d0e2 Mon Sep 17 00:00:00 2001 From: timkjr Date: Fri, 9 Oct 2026 18:53:38 -0500 Subject: [PATCH 1/2] fix(mobile): shell Paste inserts a multi-line block as one bracketed paste The shell accessory bar's Paste overlay sent its text raw and then an Enter (`app.sendInput(text)` + a delayed `\r`), so every newline in a multi-line paste reached the shell as Enter and each line ran on its own the moment it arrived, followed by one more Enter. Send it the way a terminal paste does instead: one write wrapped in ESC[200~ ... ESC[201~ with newlines as CR, through the same path Compose already uses (its bracketed write is extracted into `_sendBracketedPaste`, unchanged for Compose). Paste never presses Enter: the block lands at the prompt for the user to review and run. The overlay is only reached for shell sessions (the shell bar's Paste button, and Compose redirecting a shell), so agent sessions are unaffected. Also: a paste over the input-frame budget is refused with a toast and keeps its text for trimming (the raw path could exceed the WS frame limit), and the paste goes to the session the overlay was opened for, not whichever tab is active at Send. Co-Authored-By: Claude Opus 5.5 --- src/web/public/keyboard-accessory.js | 29 ++++++++--- test/mobile-prompt-composer.test.ts | 75 ++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index b4206897f..1ddfe0c55 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1211,17 +1211,26 @@ const KeyboardAccessoryBar = { * the durable session-bound path. Enter stays a separate delayed write * because Codex drops keys sharing a PTY read with a bracketed paste. */ _sendComposedPrompt(sessionId, text) { + if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false; + setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120); + return true; + }, + + /** One bracketed-paste write on the durable session-bound path, the same + * bytes a terminal paste produces, so the receiving shell or CLI takes a + * multi-line block as one edit. Never presses Enter: callers decide. + * `label` names the payload in the too-long toast. */ + _sendBracketedPaste(sessionId, text, label) { if (!sessionId || !text || typeof app._sendInputAsync !== 'function') return false; app._predictiveEcho?.clearPredictions(); // Match xterm's prepareTextForTerminal(): CR keeps embedded newlines inside // the single-line input transport and is what terminal.paste() emitted. const pasteText = text.replace(/\r?\n/g, '\r'); if (pasteText.length > this._composerMaxLength) { - app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error'); + app.showToast?.(`${label} is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error'); return false; } app._sendInputAsync(sessionId, `${COMPOSER_PASTE_START}${pasteText}${COMPOSER_PASTE_END}`); - setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120); return true; }, @@ -1408,6 +1417,9 @@ const KeyboardAccessoryBar = { * uploads to /api/sessions/:id/paste-image and inserts the saved path. */ pasteFromClipboard() { if (typeof app === 'undefined' || !app.activeSessionId) return; + // The shell this overlay was opened for: a tab switch while it is open must + // not deliver the paste to whatever session is active at Send time. + const sessionId = app.activeSessionId; // Create overlay const overlay = document.createElement('div'); @@ -1429,13 +1441,18 @@ const KeyboardAccessoryBar = { const close = () => overlay.remove(); + // Only shell sessions reach this overlay (agent bars get Compose instead), + // so the text lands at the prompt as one bracketed paste and is NOT run: + // sent raw, every newline was an Enter and each line ran on its own as it + // arrived. The user reviews the block and presses Enter themselves. const sendText = () => { const text = textarea.value; - close(); - if (text) { - app.sendInput(text); - setTimeout(() => app.sendInput('\r'), 80); + if (!text) { + close(); + return; } + // A refused (too-long) paste keeps the dialog and its text for trimming. + if (this._sendBracketedPaste(sessionId, text, 'Paste')) close(); }; // Filter to images, close the dialog, and hand off to the shared diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index 8429740d1..c47899227 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -437,3 +437,78 @@ describe('mobile prompt composer', () => { expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false); }); }); + +// The shell bar's Paste button (and Compose, which redirects here for a shell +// session) must deliver a multi-line block the way a real terminal paste does: +// one bracketed write, so the shell takes the block as one edit instead of +// running every line the moment its newline arrives, and no Enter, so the +// user reviews the block at the prompt before running it. +describe('mobile shell Paste', () => { + beforeEach(() => vi.restoreAllMocks()); + + function openShellPaste() { + const harness = loadComposer(); + harness.app.sessions.set('session-1', { mode: 'shell' }); + harness.bar.composePrompt(); + const input = harness.document.querySelector('.paste-textarea') as HTMLTextAreaElement; + const send = harness.document.querySelector('.paste-overlay .paste-send') as HTMLButtonElement; + return { ...harness, input, send }; + } + + it('sends a multi-line block once, bracketed, and never presses Enter', () => { + const { app, document, input, send, runTimers } = openShellPaste(); + expect(document.querySelector('.prompt-composer-overlay')).toBeNull(); + input.value = 'cd ~/dotfiles\ngit pull\n./sync.sh'; + + send.click(); + runTimers(); + + expect(app._sendInputAsync).toHaveBeenCalledOnce(); + expect(app._sendInputAsync).toHaveBeenCalledWith( + 'session-1', + '\x1b[200~cd ~/dotfiles\rgit pull\r./sync.sh\x1b[201~' + ); + expect(document.querySelector('.paste-overlay')).toBeNull(); + }); + + it('normalizes CRLF line endings to the CR a terminal paste carries', () => { + const { app, input, send } = openShellPaste(); + input.value = 'echo one\r\necho two'; + + send.click(); + + expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~echo one\recho two\x1b[201~'); + }); + + it('refuses a paste longer than one input frame and keeps the text for trimming', () => { + const { app, bar, document, input, send } = openShellPaste(); + input.value = 'x'.repeat(bar._composerMaxLength + 1); + + send.click(); + + expect(app._sendInputAsync).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('too long'), 'error'); + expect(document.querySelector('.paste-overlay')).not.toBeNull(); + expect(input.value.length).toBe(bar._composerMaxLength + 1); + }); + + it('delivers to the shell it was opened for after a tab switch', () => { + const { app, input, send } = openShellPaste(); + input.value = 'ls\npwd'; + app.activeSessionId = 'session-2'; + + send.click(); + + expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~ls\rpwd\x1b[201~'); + }); + + it('sends nothing for an empty paste', () => { + const { app, document, send, runTimers } = openShellPaste(); + + send.click(); + runTimers(); + + expect(app._sendInputAsync).not.toHaveBeenCalled(); + expect(document.querySelector('.paste-overlay')).toBeNull(); + }); +}); From 683a1c218f5b77cc202ce48908d86fddd4248a84 Mon Sep 17 00:00:00 2001 From: timkjr Date: Mon, 12 Oct 2026 13:47:14 -0500 Subject: [PATCH 2/2] fix(mobile): send a shell Paste image to the session the dialog was opened for The Paste dialog's image branch called _uploadAndInsertImages() without a session, so it fell back to the active session at pick time: after a tab switch with the dialog open, the image went to the other tab, while the text Send already used the session captured at open. Also corrects the comments around the shared bracketed write: the size refusal now lives in _sendBracketedPaste(), the budget covers the shell Paste too, and a shell without bracketed paste (dash, bash before 5.1) gets the markers stripped by tmux, so every line but the last still runs there. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/keyboard-accessory.js | 33 ++++++++++++++++------------ test/mobile-prompt-composer.test.ts | 13 +++++++++++ 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 1ddfe0c55..bac413fe5 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -636,12 +636,13 @@ function applyOneShotCtrl(data) { return { data, consumed: true }; } -// The composer's Send goes out as ONE bracketed-paste frame on the WebSocket -// input path, and ws-routes.ts drops a frame longer than MAX_INPUT_LENGTH -// (config/terminal-limits.ts: 64 KiB, compared in UTF-16 code units) WITHOUT -// an ACK, which would wedge the durable input queue. So the prompt budget is -// that limit minus the two markers, derived here once so the refusal in -// _sendComposedPrompt() and the toast that names the maximum cannot drift. +// The composer's Send, and the shell Paste dialog's, goes out as ONE +// bracketed-paste frame on the WebSocket input path, and ws-routes.ts drops a +// frame longer than MAX_INPUT_LENGTH (config/terminal-limits.ts: 64 KiB, +// compared in UTF-16 code units) WITHOUT an ACK, which would wedge the durable +// input queue. So the budget is that limit minus the two markers, derived here +// once so the refusal in _sendBracketedPaste() and the toast that names the +// maximum cannot drift. const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024; const COMPOSER_PASTE_START = '\x1b[200~'; const COMPOSER_PASTE_END = '\x1b[201~'; @@ -664,7 +665,7 @@ const KeyboardAccessoryBar = { _composerDrafts: new Map(), _composerUploads: new Map(), _composerOverlay: null, - // Longest prompt Send accepts: the input frame limit minus both markers. + // Longest prompt or shell paste Send accepts: the input frame limit minus both markers. _composerMaxLength: COMPOSER_INPUT_FRAME_LIMIT - COMPOSER_PASTE_START.length - COMPOSER_PASTE_END.length, /** HTML for simple mode: arrows, commands, Compose, Esc, dismiss */ @@ -1205,11 +1206,12 @@ const KeyboardAccessoryBar = { textarea.dispatchEvent(new Event('input', { bubbles: true })); }, - /** Deliver one complete prompt as an explicit bracketed paste. xterm loses - * its DECSET 2004 mirror after terminal replay, even though the CLI still - * expects bracketed input, so build the byte-identical sequence directly on - * the durable session-bound path. Enter stays a separate delayed write - * because Codex drops keys sharing a PTY read with a bracketed paste. */ + /** Deliver one complete prompt as an explicit bracketed paste + * (_sendBracketedPaste()), then Enter. xterm loses its DECSET 2004 mirror + * after terminal replay, even though the CLI still expects bracketed input, + * so the frame is built directly rather than through terminal.paste(). + * Enter stays a separate delayed write because Codex drops keys sharing a + * PTY read with a bracketed paste. */ _sendComposedPrompt(sessionId, text) { if (!this._sendBracketedPaste(sessionId, text, 'Prompt')) return false; setTimeout(() => app._sendInputAsync(sessionId, '\r', { useMux: true }), 120); @@ -1444,7 +1446,9 @@ const KeyboardAccessoryBar = { // Only shell sessions reach this overlay (agent bars get Compose instead), // so the text lands at the prompt as one bracketed paste and is NOT run: // sent raw, every newline was an Enter and each line ran on its own as it - // arrived. The user reviews the block and presses Enter themselves. + // arrived. The user reviews the block and presses Enter themselves. A shell + // that never enabled bracketed paste (dash, bash before 5.1) gets the + // markers stripped by tmux, so there every line but the last still runs. const sendText = () => { const text = textarea.value; if (!text) { @@ -1461,7 +1465,8 @@ const KeyboardAccessoryBar = { const images = Array.from(files || []).filter((f) => f.type.startsWith('image/')); if (images.length === 0) return false; close(); - if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images); + // Upload to, and insert into, the shell this overlay was opened for. + if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images, { sessionId }); return true; }; diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index c47899227..79a997205 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -502,6 +502,19 @@ describe('mobile shell Paste', () => { expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~ls\rpwd\x1b[201~'); }); + it('uploads a picked image to the shell it was opened for after a tab switch', () => { + const { app, document } = openShellPaste(); + app.activeSessionId = 'session-2'; + const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement; + const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' }); + Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] }); + + fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true })); + + expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { sessionId: 'session-1' }); + expect(document.querySelector('.paste-overlay')).toBeNull(); + }); + it('sends nothing for an empty paste', () => { const { app, document, send, runTimers } = openShellPaste();