Merge pull request #600: fix(mobile): shell Paste inserts a multi-line block as one bracketed paste

This commit is contained in:
Codeman maintainer committed 2026-10-13 18:27:11 +02:00
commit b7c18ab5b7
2 files changed
+129 -19

No files matched your search

+41 -19
View File
@@ -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;
},
@@ -1466,6 +1477,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');
@@ -1487,13 +1501,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 and videos, close the dialog, and hand off to the shared
@@ -1502,7 +1523,8 @@ const KeyboardAccessoryBar = {
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
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;
};
+88
View File
@@ -609,3 +609,91 @@ describe('mobile prompt composer', () => {
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: 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();
});
});