fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block

TmuxManager.sendInput() removed every `\n` before the text reached tmux, so a
multi-line prompt sent with `useMux` (or a multi-line respawn prompt) arrived as
one line with its lines run together: `"echo A\necho B\r"` ran `echo Aecho B`.

The text half of a submit has gone in as one bracketed paste
(buildPasteTextCommands) since the Codex paste-burst fix, and inside the paste
markers Claude Code and Codex insert a pasted line break into the composer
instead of submitting. So the `\n` is now kept (`\r\n` loses only its `\r`) and
the existing paste carries the lines; the Enter stays a separate tmux call.
Multi-line text without an Enter is pasted too rather than typed with
`send-keys -l`, where every line break would reach the pane as a key.
Single-line input is unchanged.

The test-mode gate moves from the top of sendInput() into inputRunner(), as
readPaneRows() does, so a test subclass can record the commands sendInput()
would run instead of pinning the branch shapes on the source.

SubmitVerifier still compares the composer's last glyph line with the prompt
minus its newlines, so it does not reliably recognise a multi-line paste and
may not re-press an Enter that Claude Code ignored. `agent send` and the TUI
therefore keep refusing or joining multi-line input; the docs that described
the stripping now describe the paste and that limit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudia[bot]andClaude Opus 5.5 committed 2026-10-21 03:51:31 +02:00
1 parent 39077281d1
commit e3de354461
19 files changed
+164 -56

No files matched your search

+90 -4
View File
@@ -11,16 +11,27 @@
* a one-line prompt). With `paste-buffer -p` the paste is marked and the separate
* Enter submits.
*
* Also the line breaks of a multi-line `sendInput`: kept, and pasted as ONE block
* with or without the Enter, where they used to be stripped and the lines joined.
*
* Strategy: pure command shape, plus a round trip through a real shell with a fake
* `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked
* on the bytes a shell actually hands over. `pasteText` runs against a recording
* runner. No real tmux.
* runner, and `sendInput` against a subclass whose `inputRunner()` records the
* commands instead of running them. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/mux-interface.js';
import {
TmuxManager,
buildPasteTextCommands,
escapeTmuxTrailingSemicolon,
pasteText,
submitsAsKeys,
} from '../src/tmux-manager.js';
const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
@@ -136,8 +147,8 @@ describe('pasteText', () => {
});
describe('sendInput wiring', () => {
// sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the
// branch that submits text is pinned on the source instead.
// These pin the branch shapes on the source; the commands sendInput actually runs
// are recorded in 'sendInput line breaks' below.
async function source(): Promise<string> {
const { readFileSync } = await import('node:fs');
return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
@@ -178,3 +189,78 @@ describe('sendInput wiring', () => {
for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)');
});
});
describe('sendInput line breaks', () => {
class RecordingManager extends TmuxManager {
ran: string[] = [];
protected override inputRunner(): (cmd: string) => Promise<unknown> {
return async (cmd: string) => {
this.ran.push(cmd);
};
}
}
function managerWithSession(): RecordingManager {
const manager = new RecordingManager();
manager.registerSession({
sessionId: 's1',
muxName: 'codeman-abc12345',
pid: 100,
createdAt: 0,
workingDir: '/tmp',
mode: 'claude',
attached: true,
} as MuxSession);
return manager;
}
/** The text a recorded `set-buffer` command hands to tmux, as a shell delivers it. */
function bufferText(setBufferCmd: string): string {
return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string;
}
it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => {
const manager = managerWithSession();
expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true);
expect(manager.ran).toHaveLength(3);
const [set, paste, enter] = manager.ran;
expect(bufferText(set)).toBe('line one\nline two\n\nparagraph three');
expect(paste).toMatch(/ paste-buffer -p -d -b \S+ -t 'codeman-abc12345'$/);
expect(enter).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
});
it('turns CRLF line endings into plain line breaks', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\r\ntwo\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('pastes multi-line text without Enter too, and never types it as keys', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo');
expect(manager.ran).toHaveLength(2);
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
expect(manager.ran[1]).toContain(' paste-buffer -p ');
for (const cmd of manager.ran) expect(cmd).not.toContain('send-keys');
});
it('drops trailing line breaks instead of pasting an empty last line', async () => {
const manager = managerWithSession();
await manager.sendInput('s1', 'one\ntwo\n\n\r');
expect(bufferText(manager.ran[0])).toBe('one\ntwo');
});
it('leaves single-line input as it was: pasted with Enter, typed without', async () => {
const submitted = managerWithSession();
await submitted.sendInput('s1', 'read your inbox\r');
expect(bufferText(submitted.ran[0])).toBe('read your inbox');
expect(submitted.ran.at(-1)).toMatch(/ send-keys -t "codeman-abc12345" Enter$/);
const typed = managerWithSession();
await typed.sendInput('s1', 'just type');
expect(typed.ran).toHaveLength(1);
expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l 'just type'$/);
});
});