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 <noreply@anthropic.com>
This commit is contained in:
timkjrandClaude Opus 5.5 committed 2026-10-10 12:49:01 -05:00
1 parent aa3730aace
commit d157911166
2 files changed
+98 -6

No files matched your search

+23 -6
View File
@@ -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
+75
View File
@@ -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();
});
});