Merge pull request #628 from Randalix/fix/codex-submit-paste

fix(tmux): submit text as a bracketed paste so Codex does not swallow Enter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 01:59:46 +02:00
commit 8084cae0fe
9 files changed
+307 -28

No files matched your search

+1 -1
View File
@@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
### Key Patterns
**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience)
**Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop`/`blocked` exist for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md`
+1 -1
View File
@@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.
### Input delivery and WS resilience
**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`).
### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR
+1 -1
View File
@@ -204,7 +204,7 @@ trailing carriage return:
| Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `typed` | `session.writeViaMux()` — tmux `send-keys -l` (literal) + Enter | Default; behaves like a human typing the prompt. |
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. |
| `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman,
+1 -1
View File
@@ -244,7 +244,7 @@ RalphLoop.tick() → assignTasks() // OR OrchestratorLoop does its own assignme
session.sendInput(task.prompt)
│
▼
writeViaMux() → tmux send-keys -l "prompt..." + Enter
writeViaMux() → tmux paste-buffer -p (prompt as one bracketed paste) + Enter
│
▼
Claude CLI receives prompt, executes, outputs results
+1 -1
View File
@@ -4,7 +4,7 @@
*
* Claude Code 2.1.277 (auto-installed 2026-09-18) takes typed text the moment its
* composer paints but ignores Enter for the first 30 to 50 seconds after it, so the
* `send-keys -l <text>` + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* text + `send-keys Enter` pair `TmuxManager.sendInput()` sends 50 ms
* apart leaves the prompt sitting on the composer with `0 tokens`, and every caller
* that then waits for the turn (send-and-wait, the agent skill, the maintainer bot,
* cron, Ralph) burns its whole timeout on a turn that never started. Measured through
+1 -1
View File
@@ -4527,7 +4527,7 @@ export class Session extends EventEmitter {
*
* More reliable than direct PTY write for programmatic input, especially
* with Claude CLI which uses Ink (React for terminals).
* Uses tmux `send-keys -l` to inject text + Enter.
* Uses tmux to inject the text (one bracketed paste) + Enter.
*
* @param data - Input data with optional `\r` for Enter
* @returns true if input was sent, false if no mux session or PTY
+119 -20
View File
@@ -7,13 +7,13 @@
* - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session
* - **Reliable input**: `send-keys -l` sends literal text in a single command
* - **Reliable input**: a submitted prompt goes in as one bracketed paste, then a separate Enter
* - **Teammate support**: Immutable pane IDs enable targeting individual teammates
*
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
*
* Key features:
* - `send-keys 'text' Enter` sends literal text in a single command
* - `paste-buffer -p` + a separate `send-keys Enter` submits a prompt (see sendInput)
* - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture
@@ -436,6 +436,84 @@ export function resolveTmuxPaneTarget(muxName: string, paneTarget?: string): str
return `${muxName}.${paneTarget}`;
}
let submitBufferSeq = 0;
/** The tmux commands of one bracketed paste, see buildPasteTextCommands(). */
export interface PasteTextCommands {
/** Fills a buffer of its own with the text. */
set: string;
/** Pastes that buffer into the target, bracketed, and deletes it (`-d`). */
paste: string;
/** Deletes the buffer when `paste` failed; `-d` only runs on a successful paste. */
discard: string;
}
/**
* 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 `;`
* 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.
*/
export function escapeTmuxTrailingSemicolon(text: string): string {
return text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
}
/**
* Whether a text+Enter submit types `text` as keys instead of pasting it: true for
* a single character. Claude Code's and Codex's selection dialogs ignore a bracketed
* paste, so a pasted `3` + Enter confirms the highlighted option instead of option 3;
* a typed `3` selects it. One character cannot form the burst Codex reads as a paste,
* so typing it is safe there. Exported for unit testing.
*/
export function submitsAsKeys(text: string): boolean {
return [...text].length === 1;
}
/**
* tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing.
*
* Why a paste and not `send-keys -l`: Codex detects a paste that arrives WITHOUT
* bracketed-paste markers heuristically, from characters arriving in a fast burst,
* and folds an Enter that lands inside that burst window into the paste as a
* newline. `send-keys -l` types exactly such an unmarked burst, so a text+Enter
* pair sat in Codex's composer unsubmitted: always for long text (Codex shows it as
* `[Pasted Content N chars]`, which SubmitVerifier cannot match against the prompt,
* so nothing re-presses Enter), intermittently for short text at the 50 ms gap.
* `paste-buffer -p` wraps the text in the markers (only when the pane asked for
* them, so an app without bracketed paste sees the same bytes as before) and the
* Enter after it is unambiguous.
*
* Each call gets its own named buffer so concurrent sends cannot paste each other's
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails.
*
* A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* A final `;` is escaped (escapeTmuxTrailingSemicolon).
*/
export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands {
const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`;
return {
set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`,
paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`,
discard: `${tmux} delete-buffer -b ${buffer}`,
};
}
/**
* Run a bracketed paste. A failed paste (the pane or the server is gone) deletes
* the buffer before the paste's own error propagates; a failing delete is ignored.
*/
export async function pasteText(run: (cmd: string) => Promise<unknown>, cmds: PasteTextCommands): Promise<void> {
await run(cmds.set);
try {
await run(cmds.paste);
} catch (err) {
await run(cmds.discard).catch(() => undefined);
throw err;
}
}
/**
* Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
* output (lines like `%0:1`). Returns the pane id whose active flag is 1.
@@ -3738,10 +3816,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
/**
* Send input directly to a tmux session using `send-keys`.
* Send input directly to a tmux session.
*
* Uses tmux send-keys for reliable input delivery:
* - `-l` flag sends literal text (no key interpretation)
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst; a single character is typed
* with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys)
* - Text without `\r` is typed literally with `send-keys -l` (no key interpretation)
* - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
* - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
*/
@@ -3776,19 +3856,32 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Send text first, then Enter as a SEPARATE tmux command after a short delay.
// Ink (Claude CLI's terminal framework) needs them split — sending both in a
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission.
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
// character in the input buffer rather than as form submission. The text goes
// in as a bracketed paste so Codex cannot fold the Enter into it either
// (see buildPasteTextCommands). A single character is typed, because a
// selection dialog ignores a pasted `3` and Enter then confirms the
// highlighted option (see submitsAsKeys).
if (submitsAsKeys(textPart)) {
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else {
await pasteText(
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }),
buildPasteTextCommands(this.tmux(), session.muxName, textPart)
);
}
await new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
// Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
timeout: EXEC_TIMEOUT_MS,
});
await execAsync(
`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
// Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
@@ -3930,19 +4023,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
} else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
});
execSync(
`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
{
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
} else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8',
+2 -2
View File
@@ -2049,7 +2049,7 @@ export function registerSessionRoutes(
const stopDeathWatch = wantsWait ? watchForDeadWorker(ctx.mux, session, id) : () => {};
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
// (tmux paste + Enter) is fire-and-forget to avoid blocking the HTTP response.
// Every write here is `fromUser`: this route carries a person's prompt, or an
// agent's on their behalf, so it may name the tab (Ralph, respawn, cron and
// approvals write through the session directly and never say so).
@@ -2072,7 +2072,7 @@ export function registerSessionRoutes(
// A plain prompt (`<text>\r`) goes through the mux even when the caller did not
// ask for it: written straight into the pane it arrives as one burst, and Claude
// Code takes a long burst as a paste whose `\r` becomes a newline, so the prompt
// sat unsent (see isPlainPromptInput). The mux path types the text, presses Enter
// sat unsent (see isPlainPromptInput). The mux path pastes the text, presses Enter
// separately and arms the SubmitVerifier. An explicit `useMux: false` keeps the
// raw write for a caller that really wants it.
const autoMux = useMux === undefined && isPlainPromptInput(inputStr);
+180
View File
@@ -0,0 +1,180 @@
/**
* Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter
* 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.
*
* 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,
* so the prompt sat in the composer unsubmitted (always at 1500 chars, sometimes for
* a one-line prompt). With `paste-buffer -p` the paste is marked and the separate
* Enter submits.
*
* 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.
*
* 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';
const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
function argvOf(cmd: string): string[] {
return JSON.parse(execSync(cmd, { encoding: 'utf-8' })) as string[];
}
describe('buildPasteTextCommands', () => {
it('fills a buffer, pastes it bracketed into the target and deletes it', () => {
const { set, paste, discard } = buildPasteTextCommands('tmux -L codeman', 'codeman-abc12345', 'hello');
const buffer = /set-buffer -b (\S+) -- /.exec(set)?.[1];
expect(buffer).toMatch(/^codeman-submit-\d+-\d+$/);
expect(set).toBe(`tmux -L codeman set-buffer -b ${buffer} -- 'hello'`);
expect(paste).toBe(`tmux -L codeman paste-buffer -p -d -b ${buffer} -t 'codeman-abc12345'`);
expect(discard).toBe(`tmux -L codeman delete-buffer -b ${buffer}`);
});
it('never types the text with send-keys (the unmarked burst Codex misreads)', () => {
for (const cmd of Object.values(buildPasteTextCommands('tmux', 'codeman-abc12345', 'read your inbox'))) {
expect(cmd).not.toContain('send-keys');
}
});
it('uses a fresh buffer per call, so concurrent sends cannot paste each other', () => {
const a = buildPasteTextCommands('tmux', 't', 'a').set;
const b = buildPasteTextCommands('tmux', 't', 'b').set;
expect(/-b (\S+)/.exec(a)?.[1]).not.toBe(/-b (\S+)/.exec(b)?.[1]);
});
it.each([
['quotes and shell syntax', `it's "quoted" $HOME \`id\` $(id) & | > <`],
['tmux format syntax', '#{session_name} #S'],
['leading dash', '-t other --help'],
['non-ASCII', 'Mailbox lesen — äöü “x”'],
['a long prompt', 'x'.repeat(1500)],
])('hands the text to tmux unchanged through the shell: %s', (_label, text) => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text);
expect(argvOf(set).at(-1)).toBe(text);
});
it.each([
['x;', 'x\\;'],
[';', '\\;'],
['x;;', 'x;\\;'],
['x\\;', 'x\\\\;'],
])('escapes a trailing ";" (tmux command separator): %s', (text, expected) => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text);
expect(argvOf(set).at(-1)).toBe(expected);
});
it('leaves a ";" inside the text alone', () => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', 'a; b');
expect(argvOf(set).at(-1)).toBe('a; b');
});
});
describe('submitsAsKeys', () => {
it.each(['3', '9', 'y', 'n', ';', 'ä', '👍'])('types a single character: %s', (text) => {
expect(submitsAsKeys(text)).toBe(true);
});
it.each(['10', 'yes', '/cost', 'read your inbox', 'x'.repeat(1500)])('pastes anything longer: %s', (text) => {
expect(submitsAsKeys(text)).toBe(false);
});
});
describe('escapeTmuxTrailingSemicolon', () => {
it.each([
['x;', 'x\\;'],
[';', '\\;'],
['a; b', 'a; b'],
['plain', 'plain'],
])('%s -> %s', (text, expected) => {
expect(escapeTmuxTrailingSemicolon(text)).toBe(expected);
});
});
describe('pasteText', () => {
const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' };
function recorder(failOn: string[] = []) {
const ran: string[] = [];
const run = async (cmd: string) => {
ran.push(cmd);
if (failOn.includes(cmd)) throw new Error(`${cmd} failed`);
};
return { ran, run };
}
it('fills the buffer, then pastes it; the paste deletes it, so nothing else runs', async () => {
const { ran, run } = recorder();
await pasteText(run, cmds);
expect(ran).toEqual(['SET', 'PASTE']);
});
it('deletes the buffer when the paste fails, and reports the paste error', async () => {
const { ran, run } = recorder(['PASTE']);
await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed');
expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']);
});
it('still reports the paste error when the delete fails too', async () => {
const { ran, run } = recorder(['PASTE', 'DISCARD']);
await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed');
expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']);
});
it('does not paste when filling the buffer failed', async () => {
const { ran, run } = recorder(['SET']);
await expect(pasteText(run, cmds)).rejects.toThrow('SET failed');
expect(ran).toEqual(['SET']);
});
});
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.
async function source(): Promise<string> {
const { readFileSync } = await import('node:fs');
return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
}
async function sendInputBody(): Promise<string> {
const src = await source();
return src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods'));
}
it('submits text through pasteText, not send-keys -l', async () => {
const body = await sendInputBody();
const submitBranch = body.slice(
body.indexOf('if (textPart && hasCarriageReturn)'),
body.indexOf('} else if (textPart)')
);
const pasteBranch = submitBranch.slice(submitBranch.indexOf('} else {'));
expect(pasteBranch).toContain('pasteText(');
expect(pasteBranch).toContain('buildPasteTextCommands(');
expect(pasteBranch).not.toMatch(/send-keys[^`]*-l/);
});
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).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 inSend = [await sendInputBody(), src.slice(src.indexOf('sendInputToPane(muxName'))]
.map((b) => b.slice(0, b.indexOf('\n }\n')))
.join('\n');
const typedHere = typed.filter((cmd) => inSend.includes(cmd));
expect(typedHere.length).toBe(4);
for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)');
});
});