Skip to content

Commit a75a8dc

Browse files
committed
fix(cli): stop scheduled prompts from concatenating in the composer
Under load Claude Code can drop the Enter sent right after a paste, so a scheduled /compact stayed in the composer and the follow-up pasted onto it, submitting one mangled /compactYou-are... line. Every scheduled submit now waits a full second before Enter and then re-sends Enter while the pasted text still shows at the bottom of the pane; retries after a real submit land on an empty composer where Enter is a no-op. Claude-Session: https://claude.ai/code/session_01Ck5ZMFqiBXC1qL84x4BT7H
1 parent 9e90725 commit a75a8dc

2 files changed

Lines changed: 55 additions & 23 deletions

File tree

‎cli/src/data.ts‎

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,10 @@ export function remoteTmuxSessionNames(): string[] {
115115
return names;
116116
}
117117

118-
/// One step of a tmux command chain: the arguments of a single tmux call, or a
119-
/// pause between two of them.
120-
export type TmuxStep = string[] | { sleep: number | string };
118+
/// One step of a tmux command chain: the arguments of a single tmux call, a
119+
/// pause between two of them, or a raw shell fragment (with `%TMUX%` standing
120+
/// in for the tmux binary) for the checks a plain call cannot express.
121+
export type TmuxStep = string[] | { sleep: number | string } | { raw: string };
121122

122123
export interface TmuxRunOptions {
123124
/// Run detached and return immediately, for chains that sleep first.
@@ -151,11 +152,11 @@ export function buildTmuxCommand(
151152
const machine = remoteMachineForSession(sessionName);
152153
const tmux = machine ? "tmux" : findTmux();
153154
const script = steps
154-
.map((step) =>
155-
Array.isArray(step)
156-
? `${tmux} ${step.map(shellToken).join(" ")}`
157-
: `sleep ${shellToken(String(step.sleep))}`
158-
)
155+
.map((step) => {
156+
if (Array.isArray(step)) return `${tmux} ${step.map(shellToken).join(" ")}`;
157+
if ("raw" in step) return step.raw.replaceAll("%TMUX%", tmux);
158+
return `sleep ${shellToken(String(step.sleep))}`;
159+
})
159160
.join(" && ");
160161
const command = machine
161162
? `boxd machine exec ${shellToken(machine)} -- ${shellEscape(script)}`
@@ -347,6 +348,34 @@ export function sendTmuxKey(sessionName: string, key: string): { ok: boolean; er
347348
}
348349
}
349350

351+
/// Paste text into the composer, submit it, and make sure the submit landed.
352+
///
353+
/// Under load the TUI can drop an Enter that arrives while it is still
354+
/// processing the paste before it, and the text left sitting in the composer
355+
/// concatenates with whatever is pasted next — a scheduled "/compact" plus its
356+
/// follow-up became one "/compactYou are..." line this way. The check re-sends
357+
/// Enter while the pasted text still shows at the bottom of the pane. After a
358+
/// real submit the transcript echo can match too, but those retries land
359+
/// within seconds of the submit, on an empty composer, where Enter is a no-op.
360+
function submitSteps(sessionName: string, text: string): TmuxStep[] {
361+
const steps: TmuxStep[] = [
362+
...pasteSteps(sessionName, text),
363+
{ sleep: 1 },
364+
["send-keys", "-t", sessionName, "Enter"],
365+
];
366+
const probe = text.split("\n")[0].trim().slice(0, 40);
367+
if (probe.length >= 4) {
368+
const session = shellToken(sessionName);
369+
steps.push({
370+
raw:
371+
`{ i=0; while [ "$i" -lt 2 ]; do sleep 1.5; ` +
372+
`%TMUX% capture-pane -t ${session} -p | tail -8 | grep -qF -- ${shellToken(probe)} || break; ` +
373+
`%TMUX% send-keys -t ${session} Enter; i=$((i+1)); done; }`,
374+
});
375+
}
376+
return steps;
377+
}
378+
350379
export function scheduleTmuxPrompt(
351380
sessionName: string,
352381
text: string,
@@ -356,12 +385,7 @@ export function scheduleTmuxPrompt(
356385
try {
357386
runTmux(
358387
sessionName,
359-
[
360-
{ sleep: delay },
361-
...pasteSteps(sessionName, text),
362-
{ sleep: 0.1 },
363-
["send-keys", "-t", sessionName, "Enter"],
364-
],
388+
[{ sleep: delay }, ...submitSteps(sessionName, text)],
365389
{ detached: true }
366390
);
367391
return { ok: true };
@@ -386,19 +410,15 @@ export function scheduleTmuxSelfCompact(
386410
// recognised as a slash command. 2s gives a comfortable buffer.
387411
{ sleep: 2 },
388412
["send-keys", "-t", sessionName, "Escape"],
389-
...pasteSteps(sessionName, "/compact"),
390-
{ sleep: 0.15 },
391-
["send-keys", "-t", sessionName, "Enter"],
413+
// The interrupt redraws the composer; pasting into the middle of that is
414+
// how an Enter gets dropped, so let it settle first.
415+
{ sleep: 0.5 },
416+
...submitSteps(sessionName, "/compact"),
392417
];
393418

394419
const followUpSteps: TmuxStep[] = followUp.trim().length === 0
395420
? []
396-
: [
397-
{ sleep: delay },
398-
...pasteSteps(sessionName, followUp),
399-
{ sleep: 0.1 },
400-
["send-keys", "-t", sessionName, "Enter"],
401-
];
421+
: [{ sleep: delay }, ...submitSteps(sessionName, followUp)];
402422

403423
try {
404424
runTmux(sessionName, [...compactSteps, ...followUpSteps], { detached: true });

‎cli/src/remote-tmux.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,18 @@ describe("tmux calls for a remote card", () => {
250250
assert.match(script, /tmux set-buffer -b kc-\d+-\d+ -- \/compact/);
251251
assert.match(script, /tmux set-buffer -b kc-\d+-\d+ -- 'carry on'/);
252252
});
253+
254+
test("every scheduled submit re-sends Enter while its text still sits in the composer", () => {
255+
scheduleTmuxSelfCompact("kc-remote", "carry on with the rebase", 2);
256+
const script = remoteScript(commands[0].command);
257+
// One verify loop per submit: the /compact and the follow-up.
258+
const loops = script.match(/capture-pane -t kc-remote -p \| tail -8 \| grep -qF -- \S+/g) ?? [];
259+
assert.equal(loops.length, 2);
260+
assert.match(script, /grep -qF -- \/compact \|\| break/);
261+
assert.match(script, /grep -qF -- 'carry on with the rebase' \|\| break/);
262+
// The raw loop runs the machine's tmux, not a Mac path.
263+
assert.doesNotMatch(script, /%TMUX%/);
264+
});
253265
});
254266

255267
describe("tmux calls for a local card", () => {

0 commit comments

Comments
 (0)