Skip to content

Commit 1226f27

Browse files
committed
Fix Codex prompt auto-send
1 parent 4c52e11 commit 1226f27

4 files changed

Lines changed: 201 additions & 2 deletions

File tree

src/store/taskStatus.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
isTrustQuestionAutoHandled,
5656
isAutoTrustSettling,
5757
isAgentAskingQuestion,
58+
isAgentBracketedPasteEnabled,
5859
getTaskAttentionState,
5960
getTaskDotStatus,
6061
taskNeedsAttention,
@@ -522,6 +523,56 @@ describe('isAutoTrustSettling', () => {
522523
});
523524
});
524525

526+
// ---------------------------------------------------------------------------
527+
// bracketed paste mode
528+
// ---------------------------------------------------------------------------
529+
describe('isAgentBracketedPasteEnabled', () => {
530+
it('tracks bracketed paste enable and disable sequences', () => {
531+
markAgentSpawned('agent-1');
532+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(false);
533+
534+
markAgentOutput('agent-1', new TextEncoder().encode('\x1b[?2004h'), 'task-1');
535+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(true);
536+
537+
markAgentOutput('agent-1', new TextEncoder().encode('\x1b[?2004l'), 'task-1');
538+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(false);
539+
});
540+
541+
it('uses the last bracketed paste sequence in a chunk', () => {
542+
markAgentSpawned('agent-1');
543+
544+
markAgentOutput(
545+
'agent-1',
546+
new TextEncoder().encode('\x1b[?2004h redraw \x1b[?2004l exit'),
547+
'task-1',
548+
);
549+
550+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(false);
551+
});
552+
553+
it('tracks bracketed paste sequences split across PTY chunks', () => {
554+
markAgentSpawned('agent-1');
555+
556+
markAgentOutput('agent-1', new TextEncoder().encode('\x1b[?20'), 'task-1');
557+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(false);
558+
559+
markAgentOutput('agent-1', new TextEncoder().encode('04h'), 'task-1');
560+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(true);
561+
});
562+
563+
it('detects bracketed paste sequence before retained tail in a large chunk', () => {
564+
markAgentSpawned('agent-1');
565+
566+
markAgentOutput(
567+
'agent-1',
568+
new TextEncoder().encode('\x1b[?2004h' + 'x'.repeat(20_000)),
569+
'task-1',
570+
);
571+
572+
expect(isAgentBracketedPasteEnabled('agent-1')).toBe(true);
573+
});
574+
});
575+
525576
// ---------------------------------------------------------------------------
526577
// task attention
527578
// ---------------------------------------------------------------------------

src/store/taskStatus.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface AgentTrackingState {
4040
lastAnalysisAt?: number;
4141
pendingAnalysis?: ReturnType<typeof setTimeout>;
4242
pendingAnalysisDueAt?: number;
43+
bracketedPasteEnabled?: boolean;
4344
}
4445

4546
const agentStates = new Map<string, AgentTrackingState>();
@@ -53,6 +54,18 @@ function getAgentState(agentId: string): AgentTrackingState {
5354
return state;
5455
}
5556

57+
function updateBracketedPasteMode(state: AgentTrackingState, text: string): void {
58+
// Bracketed paste mode is controlled by CSI ? 2004 h/l. Track the last
59+
// mode switch seen in the new PTY data so synthetic prompt sends can match
60+
// terminal paste semantics instead of arriving as rapid raw keystrokes.
61+
// eslint-disable-next-line no-control-regex
62+
const re = /\x1b\[\?2004([hl])/g;
63+
let m: RegExpExecArray | null;
64+
while ((m = re.exec(text)) !== null) {
65+
state.bracketedPasteEnabled = m[1] === 'h';
66+
}
67+
}
68+
5669
const POST_AUTO_TRUST_SETTLE_MS = 1_000;
5770

5871
function isAutoTrustPending(agentId: string): boolean {
@@ -431,6 +444,11 @@ export function isAgentAskingQuestion(agentId: string): boolean {
431444
return questionAgents().has(agentId);
432445
}
433446

447+
/** True when the agent's terminal has requested bracketed paste mode. */
448+
export function isAgentBracketedPasteEnabled(agentId: string): boolean {
449+
return agentStates.get(agentId)?.bracketedPasteEnabled === true;
450+
}
451+
434452
function updateQuestionState(agentId: string, hasQuestion: boolean): void {
435453
setQuestionAgents((prev) => {
436454
if (hasQuestion === prev.has(agentId)) return prev;
@@ -540,6 +558,7 @@ function scheduleAgentAnalysis(agentId: string, intervalMs: number, now: number)
540558
export function markAgentSpawned(agentId: string): void {
541559
const state = getAgentState(agentId);
542560
state.outputTailBuffer = '';
561+
state.bracketedPasteEnabled = false;
543562
clearAutoTrustState(agentId);
544563
state.lastAnalysisAt = undefined;
545564
cancelPendingAnalysis(state);
@@ -632,6 +651,7 @@ export function markAgentOutput(agentId: string, data: Uint8Array, taskId?: stri
632651
state.lastDataAt = now;
633652

634653
const text = state.decoder.decode(data, { stream: true });
654+
updateBracketedPasteMode(state, state.outputTailBuffer.slice(-16) + text);
635655
const combined = state.outputTailBuffer + text;
636656
state.outputTailBuffer =
637657
combined.length > TAIL_BUFFER_MAX

src/store/tasks.test.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { IPC } from '../../electron/ipc/channels';
3+
4+
const { mockInvoke, mockIsAgentBracketedPasteEnabled, mockSetStore, mockStore } = vi.hoisted(
5+
() => ({
6+
mockInvoke: vi.fn(),
7+
mockIsAgentBracketedPasteEnabled: vi.fn(),
8+
mockSetStore: vi.fn(),
9+
mockStore: {
10+
agents: {},
11+
tasks: {},
12+
} as {
13+
agents: Record<string, { status: string }>;
14+
tasks: Record<
15+
string,
16+
{
17+
initialPrompt?: string;
18+
lastPrompt?: string;
19+
stepsEnabled?: boolean;
20+
}
21+
>;
22+
},
23+
}),
24+
);
25+
26+
vi.mock('../lib/ipc', () => ({
27+
Channel: vi.fn(),
28+
invoke: mockInvoke,
29+
}));
30+
31+
vi.mock('./core', () => ({
32+
setStore: mockSetStore,
33+
store: mockStore,
34+
cleanupPanelEntries: vi.fn(),
35+
}));
36+
37+
vi.mock('./persistence', () => ({
38+
saveState: vi.fn(),
39+
}));
40+
41+
vi.mock('./focus', () => ({
42+
setTaskFocusedPanel: vi.fn(),
43+
}));
44+
45+
vi.mock('./projects', () => ({
46+
getProject: vi.fn(),
47+
getProjectBranchPrefix: vi.fn(),
48+
getProjectPath: vi.fn(),
49+
isProjectMissing: vi.fn(),
50+
}));
51+
52+
vi.mock('../lib/bookmarks', () => ({
53+
setPendingShellCommand: vi.fn(),
54+
}));
55+
56+
vi.mock('./taskStatus', () => ({
57+
clearAgentActivity: vi.fn(),
58+
clearTaskGitStatusTracking: vi.fn(),
59+
isAgentBracketedPasteEnabled: mockIsAgentBracketedPasteEnabled,
60+
isAgentIdle: vi.fn(),
61+
markAgentBusy: vi.fn(),
62+
markAgentSpawned: vi.fn(),
63+
rescheduleTaskStatusPolling: vi.fn(),
64+
}));
65+
66+
vi.mock('./completion', () => ({
67+
recordMergedLines: vi.fn(),
68+
recordTaskCompleted: vi.fn(),
69+
}));
70+
71+
vi.mock('../lib/log', () => ({
72+
warn: vi.fn(),
73+
}));
74+
75+
import { sendPrompt } from './tasks';
76+
77+
function writePayloads(): string[] {
78+
return mockInvoke.mock.calls
79+
.filter(([channel]) => channel === IPC.WriteToAgent)
80+
.map(([, payload]) => payload.data);
81+
}
82+
83+
describe('sendPrompt', () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks();
86+
mockInvoke.mockResolvedValue(undefined);
87+
mockIsAgentBracketedPasteEnabled.mockReturnValue(false);
88+
mockStore.agents = { 'agent-1': { status: 'running' } };
89+
mockStore.tasks = {
90+
'task-1': {
91+
lastPrompt: '',
92+
},
93+
};
94+
});
95+
96+
it('wraps prompt text in bracketed paste when the agent enabled it', async () => {
97+
mockIsAgentBracketedPasteEnabled.mockReturnValue(true);
98+
99+
await sendPrompt('task-1', 'agent-1', 'hello Codex');
100+
101+
expect(writePayloads()).toEqual(['\x1b[I', '\x1b[200~hello Codex\x1b[201~', '\r']);
102+
expect(mockSetStore).toHaveBeenCalledWith('tasks', 'task-1', 'lastPrompt', 'hello Codex');
103+
});
104+
105+
it('sends raw prompt text when bracketed paste is not enabled', async () => {
106+
await sendPrompt('task-1', 'agent-1', 'hello Codex');
107+
108+
expect(writePayloads()).toEqual(['\x1b[I', 'hello Codex', '\r']);
109+
});
110+
111+
it('keeps Enter outside the bracketed paste block', async () => {
112+
mockIsAgentBracketedPasteEnabled.mockReturnValue(true);
113+
114+
await sendPrompt('task-1', 'agent-1', 'line 1\nline 2');
115+
116+
expect(writePayloads()).toEqual(['\x1b[I', '\x1b[200~line 1\nline 2\x1b[201~', '\r']);
117+
});
118+
});

src/store/tasks.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
markAgentBusy,
1212
clearAgentActivity,
1313
clearTaskGitStatusTracking,
14+
isAgentBracketedPasteEnabled,
1415
isAgentIdle,
1516
rescheduleTaskStatusPolling,
1617
} from './taskStatus';
@@ -52,6 +53,8 @@ function initTaskInStore(
5253

5354
const AGENT_WRITE_READY_TIMEOUT_MS = 8_000;
5455
const AGENT_WRITE_RETRY_MS = 50;
56+
const BRACKETED_PASTE_START = '\x1b[200~';
57+
const BRACKETED_PASTE_END = '\x1b[201~';
5558

5659
function sleep(ms: number): Promise<void> {
5760
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -477,9 +480,16 @@ export async function sendPrompt(taskId: string, agentId: string, text: string):
477480
// to the PTY, which may suspend readline input processing; \x1b[I re-activates it.
478481
await writeToAgentWhenReady(agentId, '\x1b[I');
479482
// Send text and Enter separately so TUI apps (Claude Code, Codex)
480-
// don't treat the \r as part of a pasted block
483+
// don't treat the \r as part of a pasted block. When the agent has enabled
484+
// bracketed paste, wrap only the prompt text; this avoids Codex's paste-burst
485+
// guard treating rapid synthetic keystrokes plus Enter as a paste.
481486
setTaskLastInputAt(taskId);
482-
await writeToAgentWhenReady(agentId, effectiveText);
487+
await writeToAgentWhenReady(
488+
agentId,
489+
isAgentBracketedPasteEnabled(agentId)
490+
? `${BRACKETED_PASTE_START}${effectiveText}${BRACKETED_PASTE_END}`
491+
: effectiveText,
492+
);
483493
await new Promise((r) => setTimeout(r, 50));
484494
await writeToAgentWhenReady(agentId, '\r');
485495
setStore('tasks', taskId, 'lastPrompt', text);

0 commit comments

Comments
 (0)