From 3489f6cc801eaa897254c226ef5a136a642d3e2a Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 13:26:04 -0400 Subject: [PATCH 1/3] fix(tmux): send prompts that start with a hyphen literally `send-keys -l ` keeps parsing options after `-l`, so text starting with `-` was read as a flag ("unknown flag -x", "invalid flag --") and never typed. End option parsing with `--` at every send-keys -l site in sendInput() and sendInputToPane(), as set-buffer already does. --- src/tmux-manager.ts | 8 ++++---- test/tmux-submit-paste.test.ts | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..73bae4e2 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3869,7 +3869,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // highlighted option (see submitsAsKeys). if (submitsAsKeys(textPart)) { await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, { timeout: EXEC_TIMEOUT_MS } ); } else { @@ -3885,7 +3885,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } else if (textPart) { // Text only, no Enter await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, + `${this.tmux()} send-keys -t "${session.muxName}" -l -- ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, { timeout: EXEC_TIMEOUT_MS } ); } else if (hasCarriageReturn) { @@ -4030,7 +4030,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, @@ -4042,7 +4042,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 ba529f18..8d0756c1 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, @@ -163,13 +164,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'); @@ -177,4 +178,13 @@ 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\(/); + }); }); From 0eb9255465ad2be601cb1da1cc90667b73f51e7f Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 24 Oct 2026 13:57:37 +0200 Subject: [PATCH 2/3] fix: apply the review items for #677 at merge src/tmux-manager.ts: escapeTmuxTrailingSemicolon() JSDoc says every send-keys -l passes -- before the text, and its example uses the -l -- form test/tmux-submit-paste.test.ts: the trailing-semicolon wiring test matches -l (?:-- )? so it pins only the semicolon rule test/tmux-submit-paste.test.ts: master's two recorded-command tests for typed text expect the -l -- form (merge interaction with the inputRunner() refactor) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/tmux-manager.ts | 6 ++++-- test/tmux-submit-paste.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 4a3ab1fa..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; diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 9af06c40..7cc04b7f 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -181,7 +181,7 @@ describe('sendInput wiring', () => { 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'); @@ -279,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 () => { @@ -291,6 +291,6 @@ 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'$/); }); }); From d920375e5e4a79ce29fc49e9f1c67c7b8e7a0bb9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 24 Oct 2026 14:37:05 +0200 Subject: [PATCH 3/3] fix: address the release-prep review of #677 test: record sendInput('-x hello') and sendInput('-\r') and check the shell argv ends -l -- on both send-keys -l branches Co-Authored-By: Claude Opus 5.5 (1M context) --- test/tmux-submit-paste.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 7cc04b7f..e0a3fa88 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -293,4 +293,21 @@ describe('sendInput line breaks', () => { expect(typed.ran).toHaveLength(1); 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$/); + }); });