Skip to content

Commit 937e737

Browse files
committed
fix: sending enter separately
1 parent 42839f3 commit 937e737

File tree

2 files changed

+47
-21
lines changed

2 files changed

+47
-21
lines changed

packages/agent-manager/src/__tests__/terminal/TtyWriter.test.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,22 @@ describe('TtyWriter', () => {
4040
tty: '/dev/ttys030',
4141
};
4242

43-
it('sends message via tmux send-keys', async () => {
43+
it('sends message and Enter as separate tmux send-keys calls', async () => {
4444
mockExecFileSuccess();
4545

4646
await TtyWriter.send(location, 'continue');
4747

4848
expect(mockedExecFile).toHaveBeenCalledWith(
4949
'tmux',
50-
['send-keys', '-t', 'main:0.1', 'continue', 'Enter'],
50+
['send-keys', '-t', 'main:0.1', '-l', 'continue'],
5151
expect.any(Function),
5252
);
53+
expect(mockedExecFile).toHaveBeenCalledWith(
54+
'tmux',
55+
['send-keys', '-t', 'main:0.1', 'Enter'],
56+
expect.any(Function),
57+
);
58+
expect(mockedExecFile).toHaveBeenCalledTimes(2);
5359
});
5460

5561
it('throws on tmux failure', async () => {
@@ -74,9 +80,12 @@ describe('TtyWriter', () => {
7480

7581
expect(mockedExecFile).toHaveBeenCalledWith(
7682
'osascript',
77-
['-e', expect.stringContaining('write text "hello"')],
83+
['-e', expect.stringContaining('write text "hello" newline no')],
7884
expect.any(Function),
7985
);
86+
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
87+
const script = scriptArg[1];
88+
expect(script).toContain('key code 36');
8089
});
8190

8291
it('escapes special characters in message', async () => {
@@ -86,7 +95,7 @@ describe('TtyWriter', () => {
8695

8796
expect(mockedExecFile).toHaveBeenCalledWith(
8897
'osascript',
89-
['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there"')],
98+
['-e', expect.stringContaining('write text "say \\"hi\\" \\\\ there" newline no')],
9099
expect.any(Function),
91100
);
92101
});
@@ -113,24 +122,11 @@ describe('TtyWriter', () => {
113122

114123
const scriptArg = (mockedExecFile.mock.calls[0] as unknown[])[1] as string[];
115124
const script = scriptArg[1];
116-
// Must use keystroke, NOT do script
117125
expect(script).toContain('keystroke "hello"');
118126
expect(script).toContain('key code 36');
119127
expect(script).not.toContain('do script');
120128
});
121129

122-
it('uses execFile to avoid shell injection', async () => {
123-
mockExecFileSuccess('ok');
124-
125-
await TtyWriter.send(location, "don't stop");
126-
127-
expect(mockedExecFile).toHaveBeenCalledWith(
128-
'osascript',
129-
['-e', expect.any(String)],
130-
expect.any(Function),
131-
);
132-
});
133-
134130
it('throws when tab not found', async () => {
135131
mockExecFileSuccess('not_found');
136132

packages/agent-manager/src/terminal/TtyWriter.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,25 +46,47 @@ export class TtyWriter {
4646
}
4747

4848
private static async sendViaTmux(identifier: string, message: string): Promise<void> {
49-
await execFileAsync('tmux', ['send-keys', '-t', identifier, message, 'Enter']);
49+
// Send text and Enter as two separate calls so that Enter arrives
50+
// outside of bracketed paste mode. When the inner application (e.g.
51+
// Claude Code) has bracketed paste enabled, tmux wraps the send-keys
52+
// payload in paste brackets — if Enter is included, it gets swallowed
53+
// as part of the paste instead of acting as a submit action.
54+
await execFileAsync('tmux', ['send-keys', '-t', identifier, '-l', message]);
55+
await new Promise((resolve) => setTimeout(resolve, 150));
56+
await execFileAsync('tmux', ['send-keys', '-t', identifier, 'Enter']);
5057
}
5158

5259
private static async sendViaITerm2(tty: string, message: string): Promise<void> {
5360
const escaped = escapeAppleScript(message);
61+
// Send text WITHOUT a trailing newline to avoid the newline being swallowed
62+
// by bracketed paste mode. Then simulate pressing Return separately so that
63+
// Claude Code (and other interactive TUIs) treat it as a real submit action.
5464
const script = `
5565
tell application "iTerm"
66+
set targetSession to missing value
5667
repeat with w in windows
5768
repeat with t in tabs of w
5869
repeat with s in sessions of t
5970
if tty of s is "${tty}" then
60-
tell s to write text "${escaped}"
61-
return "ok"
71+
set targetSession to s
72+
exit repeat
6273
end if
6374
end repeat
75+
if targetSession is not missing value then exit repeat
6476
end repeat
77+
if targetSession is not missing value then exit repeat
6578
end repeat
79+
if targetSession is missing value then return "not_found"
80+
tell targetSession to write text "${escaped}" newline no
6681
end tell
67-
return "not_found"`;
82+
tell application "iTerm" to activate
83+
delay 0.15
84+
tell application "System Events"
85+
tell process "iTerm2"
86+
key code 36
87+
end tell
88+
end tell
89+
return "ok"`;
6890

6991
const { stdout } = await execFileAsync('osascript', ['-e', script]);
7092
if (stdout.trim() !== 'ok') {
@@ -77,6 +99,9 @@ return "not_found"`;
7799
// Use System Events keystroke to type into the foreground process,
78100
// NOT Terminal.app's "do script" which runs a new shell command.
79101
// First activate Terminal and select the correct tab, then type via System Events.
102+
// Send the text first, then wait for the paste/input to complete before pressing
103+
// Return separately — this ensures interactive TUIs (like Claude Code) see the
104+
// Return as a real submit action, not part of a bracketed paste.
80105
const script = `
81106
tell application "Terminal"
82107
set targetFound to false
@@ -99,6 +124,11 @@ delay 0.1
99124
tell application "System Events"
100125
tell process "Terminal"
101126
keystroke "${escaped}"
127+
end tell
128+
end tell
129+
delay 0.15
130+
tell application "System Events"
131+
tell process "Terminal"
102132
key code 36
103133
end tell
104134
end tell

0 commit comments

Comments
 (0)