diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 961d5683..523c4260 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -479,9 +479,11 @@ export interface PasteTextCommands { /** * Escape a final `;` for tmux. tmux splits commands on an argument that ENDS in - * `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l 'a;'` types `a`, a lone `;` + * `;` (`set-buffer -- 'a;'` stores `a`, `send-keys -l -- 'a;'` types `a`, a lone `;` * is no argument at all); a final `\;` is its escape for a literal `;`. A `;` - * anywhere else in the argument is literal already. Exported for unit testing. + * anywhere else in the argument is literal already. Every `send-keys -l` passes + * `--` before the text, like `set-buffer`, or tmux parses a text that starts with + * `-` as its own options. Exported for unit testing. */ export function escapeTmuxTrailingSemicolon(text: string): string { return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text; @@ -3997,7 +3999,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // highlighted option (see submitsAsKeys). if (submitsAsKeys(textPart)) { await run( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` + `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else { await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); @@ -4014,7 +4016,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } else if (textPart) { // Text only, no Enter await run( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` + `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { // Enter only @@ -4168,7 +4170,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (textPart && hasCarriageReturn) { execSync( - `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + `${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, @@ -4180,7 +4182,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }); } else if (textPart) { execSync( - `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + `${tmux} send-keys -t ${shellescape(target)} -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 39eb1d55..e0a3fa88 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -3,7 +3,8 @@ * submit (`sendInput`) goes in as ONE bracketed paste. Also `submitsAsKeys()`: a * single character is typed instead, since a selection dialog ignores a pasted `3` * and the Enter then confirms the highlighted option. And - * `escapeTmuxTrailingSemicolon()`, which every literal text path runs through. + * `escapeTmuxTrailingSemicolon()`, which every literal text path runs through, + * and the `--` that ends tmux's option parsing before every `send-keys -l` text. * * Why: `send-keys -l` typed the text as an unmarked burst, and Codex's paste-burst * heuristic folded the Enter that followed 50 ms later into the paste as a newline, @@ -174,13 +175,13 @@ describe('sendInput wiring', () => { it('types a single-character submit with send-keys -l, so a dialog answer selects', async () => { const body = await sendInputBody(); const keysBranch = body.slice(body.indexOf('if (submitsAsKeys(textPart))'), body.indexOf('} else {')); - expect(keysBranch).toMatch(/send-keys[^`]*-l \$\{shellescape\(escapeTmuxTrailingSemicolon\(textPart\)\)\}/); + expect(keysBranch).toMatch(/send-keys[^`]*-l -- \$\{shellescape\(escapeTmuxTrailingSemicolon\(textPart\)\)\}/); expect(keysBranch).not.toContain('pasteText('); }); it('escapes a trailing ";" on every send-keys -l in sendInput and sendInputToPane', async () => { const src = await source(); - const typed = src.match(/send-keys[^`\n]*-l \$\{[^`\n]*\}/g) ?? []; + const typed = src.match(/send-keys[^`\n]*-l (?:-- )?\$\{[^`\n]*\}/g) ?? []; const inSend = [await sendInputBody(), src.slice(src.indexOf('sendInputToPane(muxName'))] .map((b) => b.slice(0, b.indexOf('\n }\n'))) .join('\n'); @@ -188,6 +189,15 @@ describe('sendInput wiring', () => { expect(typedHere.length).toBe(4); for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)'); }); + // tmux parses an argument after `-l` that starts with `-` as an option, so a + // prompt like `-x hello` or `--help` was refused instead of typed. `--` ends + // option parsing; set-buffer already gets one (buildPasteTextCommands). + it('ends option parsing with -- before the text on every send-keys -l', async () => { + const src = await source(); + const literal = src.match(/send-keys -t [^`\n]*\s-l\s[^`\n]*/g) ?? []; + expect(literal.length).toBe(4); + for (const cmd of literal) expect(cmd).toMatch(/\s-l -- \$\{shellescape\(/); + }); }); describe('sendInput line breaks', () => { @@ -269,7 +279,7 @@ describe('sendInput line breaks', () => { // Without an Enter, what is left is one line, so it is typed like any other. const typed = managerWithSession(); await typed.sendInput('s1', '\n\njust type'); - expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l 'just type'$/)]); + expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l -- 'just type'$/)]); }); it('leaves single-line input as it was: pasted with Enter, typed without', async () => { @@ -281,6 +291,23 @@ describe('sendInput line breaks', () => { 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'$/); + expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l -- 'just type'$/); + }); + + // The argv a shell hands tmux must put `--` right before the text, or tmux reads + // a leading `-` as its own option and types nothing. + it('types text that starts with a hyphen after --, on both send-keys -l branches', async () => { + const keysArgv = (cmd: string) => argvOf(cmd.replace(/^.*? send-keys /, `${FAKE_TMUX} send-keys `)); + + const typed = managerWithSession(); + await typed.sendInput('s1', '-x hello'); + expect(typed.ran).toHaveLength(1); + expect(keysArgv(typed.ran[0]).slice(-3)).toEqual(['-l', '--', '-x hello']); + + const answer = managerWithSession(); + await answer.sendInput('s1', '-\r'); + expect(answer.ran).toHaveLength(2); + expect(keysArgv(answer.ran[0]).slice(-3)).toEqual(['-l', '--', '-']); + expect(answer.ran[1]).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); }); });