fix(tmux): type a single-character submit so a dialog answer selects

Claude Code's and Codex's selection dialogs ignore a bracketed paste, so
after the previous commit a pasted `3` + Enter confirmed the highlighted
option instead of option 3. sendInput() now types a single character with
`send-keys -l` as master did (submitsAsKeys()). One character cannot form
the burst Codex reads as a paste, so the Codex fix is unaffected.

The trailing-`;` escape moves into escapeTmuxTrailingSemicolon() and now
also covers every `send-keys -l` call in sendInput() and
sendInputToPane(); tmux 3.5a types `x;` for `send-keys -l 'x\;'`.

Docs: the @fileoverview lines and orchestrator-loop-architecture.md still
described `send-keys -l`; CLAUDE.md, architecture-invariants.md and
cron-guide.md name the single-character exception.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-14 21:23:58 +02:00
1 parent 344edc942b
commit 7fd76f06c3
6 files changed
+123 -36

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 ### Key Patterns
**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `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` **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 delivery and WS resilience
**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`. 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 ### 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 | | Mode | Mechanism | Use when |
| ------- | --------------------------------------------------------------- | ------------------------------------------------ | | ------- | --------------------------------------------------------------- | ------------------------------------------------ |
| `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter | Default; like a human pasting the prompt and pressing Enter. | | `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. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. |
> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman, > ⚠️ **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) 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 Claude CLI receives prompt, executes, outputs results
+60 -24
View File
@@ -7,13 +7,13 @@
* - **Persistence**: Sessions survive server restarts and disconnects * - **Persistence**: Sessions survive server restarts and disconnects
* - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup * - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
* - **Resource tracking**: Memory, CPU, and child process stats per session * - **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 * - **Teammate support**: Immutable pane IDs enable targeting individual teammates
* *
* tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json. * tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
* *
* Key features: * 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 * - `list-sessions -F` provides structured queries
* - `display-message -p '#{pane_pid}'` for reliable PID discovery * - `display-message -p '#{pane_pid}'` for reliable PID discovery
* - Single server architecture * - Single server architecture
@@ -448,6 +448,27 @@ export interface PasteTextCommands {
discard: string; 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 * tmux commands that type `text` into `target` as ONE bracketed paste, ahead of a
* separately sent `Enter` that submits it. Exported for unit testing. * separately sent `Enter` that submits it. Exported for unit testing.
@@ -467,15 +488,13 @@ export interface PasteTextCommands {
* text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims * text. tmux keeps a named buffer until something deletes it (`buffer-limit` trims
* only automatic ones), hence `discard` for a paste that fails. * only automatic ones), hence `discard` for a paste that fails.
* *
* tmux splits commands on an argument that ENDS in `;` (`set-buffer -- 'a;'` stores * A single character is typed instead (submitsAsKeys), so a dialog answer selects.
* `a`, a lone `;` fails with "no data specified"); a final `\;` is its escape for a * A final `;` is escaped (escapeTmuxTrailingSemicolon).
* literal `;`.
*/ */
export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands { export function buildPasteTextCommands(tmux: string, target: string, text: string): PasteTextCommands {
const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`; const buffer = `codeman-submit-${process.pid}-${++submitBufferSeq}`;
const data = text.endsWith(';') ? `${text.slice(0, -1)}\\;` : text;
return { return {
set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(data)}`, set: `${tmux} set-buffer -b ${buffer} -- ${shellescape(escapeTmuxTrailingSemicolon(text))}`,
paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`, paste: `${tmux} paste-buffer -p -d -b ${buffer} -t ${shellescape(target)}`,
discard: `${tmux} delete-buffer -b ${buffer}`, discard: `${tmux} delete-buffer -b ${buffer}`,
}; };
@@ -3800,7 +3819,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* Send input directly to a tmux session. * Send input directly to a tmux session.
* *
* - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands), * - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands),
* so Codex cannot fold the Enter into a paste burst * 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) * - 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 * - `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 * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
@@ -3838,20 +3858,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// single tmux invocation (via \;) causes Ink to interpret Enter as a newline // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
// character in the input buffer rather than as form submission. The text goes // 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 // in as a bracketed paste so Codex cannot fold the Enter into it either
// (see buildPasteTextCommands). // (see buildPasteTextCommands). A single character is typed, because a
await pasteText( // selection dialog ignores a pasted `3` and Enter then confirms the
(cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), // highlighted option (see submitsAsKeys).
buildPasteTextCommands(this.tmux(), session.muxName, textPart) 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 new Promise((resolve) => setTimeout(resolve, 50));
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // Text only, no Enter
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { await execAsync(
timeout: EXEC_TIMEOUT_MS, `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
}); { timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // Enter only
await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
@@ -3993,19 +4023,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
if (textPart && hasCarriageReturn) { if (textPart && hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(
encoding: 'utf-8', `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
timeout: EXEC_TIMEOUT_MS, {
}); encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS, timeout: EXEC_TIMEOUT_MS,
}); });
} else if (textPart) { } else if (textPart) {
execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { execSync(
encoding: 'utf-8', `${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`,
timeout: EXEC_TIMEOUT_MS, {
}); encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
}
);
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, { execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
encoding: 'utf-8', encoding: 'utf-8',
+59 -8
View File
@@ -1,6 +1,9 @@
/** /**
* Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter * Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter
* submit (`sendInput`) goes in as ONE bracketed paste. * 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 * 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, * heuristic folded the Enter that followed 50 ms later into the paste as a newline,
@@ -17,7 +20,7 @@
*/ */
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildPasteTextCommands, pasteText } from '../src/tmux-manager.js'; 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)))' --`; const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
@@ -74,6 +77,27 @@ describe('buildPasteTextCommands', () => {
}); });
}); });
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', () => { describe('pasteText', () => {
const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' }; const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' };
@@ -114,16 +138,43 @@ describe('pasteText', () => {
describe('sendInput wiring', () => { describe('sendInput wiring', () => {
// sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the // sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the
// branch that submits text is pinned on the source instead. // branch that submits text is pinned on the source instead.
it('submits text through pasteText, not send-keys -l', async () => { async function source(): Promise<string> {
const { readFileSync } = await import('node:fs'); const { readFileSync } = await import('node:fs');
const src = readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
const body = src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods')); }
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( const submitBranch = body.slice(
body.indexOf('if (textPart && hasCarriageReturn)'), body.indexOf('if (textPart && hasCarriageReturn)'),
body.indexOf('} else if (textPart)') body.indexOf('} else if (textPart)')
); );
expect(submitBranch).toContain('pasteText('); const pasteBranch = submitBranch.slice(submitBranch.indexOf('} else {'));
expect(submitBranch).toContain('buildPasteTextCommands('); expect(pasteBranch).toContain('pasteText(');
expect(submitBranch).not.toMatch(/send-keys[^`]*-l/); 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)');
}); });
}); });