mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
344edc942b
commit
7fd76f06c3
6 files changed
+123
-36
No files matched your search
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user