mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
commit
8084cae0fe
9 files changed
+307
-28
No files matched your search
+119
-20
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user