mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #600: fix(mobile): shell Paste inserts a multi-line block as one bracketed paste
This commit is contained in:
commit
b7c18ab5b7
2 files changed
+129
-19
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user