diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index b4206897f..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,23 +1206,33 @@ 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); + 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 +1419,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 +1443,20 @@ 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. 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; - 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 @@ -1444,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 8429740d1..79a997205 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -437,3 +437,91 @@ 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('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(); + + send.click(); + runTimers(); + + expect(app._sendInputAsync).not.toHaveBeenCalled(); + expect(document.querySelector('.paste-overlay')).toBeNull(); + }); +});