Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions packages/agent-manager/src/__tests__/terminal/TtyWriter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ function mockExecFileSuccess(stdout = '') {
mockedExecFile.mockImplementation((...args: unknown[]) => {
const cb = args[args.length - 1] as (err: Error | null, result: { stdout: string }, stderr: string) => void;
cb(null, { stdout }, '');
return { stdin: { end: vi.fn() } };
});
}

Expand All @@ -41,22 +42,36 @@ describe('TtyWriter', () => {
tty: '/dev/ttys030',
};

it('sends message and Enter as separate tmux send-keys calls', async () => {
it('pastes message in bracketed paste mode and sends Enter separately', async () => {
mockExecFileSuccess();
const message = 'line 1\nline 2\n';

await TtyWriter.send(location, 'continue');
await TtyWriter.send(location, message);

expect(mockedExecFile).toHaveBeenCalledWith(
const loadArgs = mockedExecFile.mock.calls[0]?.[1] as string[];
const bufferName = loadArgs[2];
expect(bufferName).toMatch(/^ai-devkit-send-/);
expect(mockedExecFile).toHaveBeenNthCalledWith(
1,
'tmux',
['send-keys', '-t', 'main:0.1', '-l', 'continue'],
['load-buffer', '-b', bufferName, '-'],
expect.any(Function),
);
expect(mockedExecFile).toHaveBeenCalledWith(
expect(mockedExecFile.mock.results[0]?.value.stdin.end)
.toHaveBeenCalledWith(message);
expect(mockedExecFile).toHaveBeenNthCalledWith(
2,
'tmux',
['paste-buffer', '-t', 'main:0.1', '-b', bufferName, '-p', '-d'],
expect.any(Function),
);
expect(mockedExecFile).toHaveBeenNthCalledWith(
3,
'tmux',
['send-keys', '-t', 'main:0.1', 'Enter'],
expect.any(Function),
);
expect(mockedExecFile).toHaveBeenCalledTimes(2);
expect(mockedExecFile).toHaveBeenCalledTimes(3);
});

it('throws on tmux failure', async () => {
Expand Down
29 changes: 23 additions & 6 deletions packages/agent-manager/src/terminal/TtyWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,33 @@ export class TtyWriter {
}

private static async sendViaTmux(identifier: string, message: string): Promise<void> {
// Send text and Enter as two separate calls so that Enter arrives
// outside of bracketed paste mode. When the inner application (e.g.
// Claude Code) has bracketed paste enabled, tmux wraps the send-keys
// payload in paste brackets — if Enter is included, it gets swallowed
// as part of the paste instead of acting as a submit action.
await execFileAsync('tmux', ['send-keys', '-t', identifier, '-l', message]);
// Paste the message body using tmux bracketed paste, then send Enter as
// a separate key so the inner TUI treats it as submission rather than
// pasted content.
const bufferName = `ai-devkit-send-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
await TtyWriter.execFileWithInput('tmux', ['load-buffer', '-b', bufferName, '-'], message);
await execFileAsync('tmux', ['paste-buffer', '-t', identifier, '-b', bufferName, '-p', '-d']);
await new Promise((resolve) => setTimeout(resolve, 150));
await execFileAsync('tmux', ['send-keys', '-t', identifier, 'Enter']);
}

private static async execFileWithInput(command: string, args: string[], input: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = execFile(command, args, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
if (!child.stdin) {
reject(new Error(`Cannot write stdin to ${command}`));
return;
}
child.stdin.end(input);
});
}

/**
* Build an AppleScript that finds an iTerm2 session by TTY and runs a
* command against it. The `sessionCommand` is inserted inside a
Expand Down
Loading