Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 41 additions & 19 deletions src/web/public/keyboard-accessory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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~';
Expand All @@ -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 */
Expand Down Expand Up @@ -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;
},

Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand All @@ -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;
};

Expand Down
88 changes: 88 additions & 0 deletions test/mobile-prompt-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading