Skip to content

Commit e4773cb

Browse files
feat: add support for Codex agent (#6901)
Adds Codex to the available agents in the ACP sidebar and many other fixes: * Add Codex command to the Agent dropdown * Disable bottom buttons when the agent is running * Dedupe todos/plans (in case they have status updates) * some tools don't send a completion update, so we just always mark them completed if they are no longer the last block * Collapse unchanged regions in the diff viewer * Handle edge case when Claude ACP returns a different schema than the ACP protocol expects for diffs (`HACK: if the raw input is `abs_path`, `old_string`, `new_string` then handle it as if it is a diff`) * Handle edge case were the title comes back `"undefined"` from the ACP <img width="1196" height="558" alt="image" src="https://github.com/user-attachments/assets/b7918971-bb22-469e-96c3-f66119dac3a7" /> <img width="701" height="801" alt="Screenshot 2025-10-27 at 4 25 16 PM" src="https://github.com/user-attachments/assets/03fffbc4-52f8-42bf-80ed-49f3d1afc01e" /> <img width="1570" height="1278" alt="Screenshot 2025-10-27 at 4 24 14 PM" src="https://github.com/user-attachments/assets/91ae1969-b3f8-4c90-a03a-e49bb4859ebc" /> --------- Co-authored-by: Shahmir Varqha <[email protected]>
1 parent 7af2c5c commit e4773cb

File tree

12 files changed

+124
-36
lines changed

12 files changed

+124
-36
lines changed

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@
166166
"thememirror": "^2.0.1",
167167
"timestring": "^7.0.0",
168168
"typescript-memoize": "^1.1.1",
169-
"use-acp": "0.2.4",
169+
"use-acp": "0.2.5",
170170
"use-resize-observer": "^9.1.0",
171171
"vega-lite": "^5.23.0",
172172
"vega-loader": "^4.5.3",

frontend/src/components/ai/ai-provider-icon.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ import type { ProviderId } from "@/core/ai/ids/ids";
1515
import { cn } from "@/utils/cn";
1616
import marimoIcon from "../../assets/icon-32x32.png?inline";
1717

18-
type Aliases = "claude" | "gemini";
18+
type Aliases = "claude" | "gemini" | "codex";
1919

2020
const icons: Record<ProviderId | Aliases, string> = {
2121
openai: OpenAIIcon,
2222
anthropic: AnthropicIcon,
2323
claude: AnthropicIcon,
2424
gemini: GeminiIcon,
2525
google: GeminiIcon,
26+
codex: OpenAIIcon,
2627
ollama: OllamaIcon,
2728
azure: AzureIcon,
2829
bedrock: BedrockIcon,

frontend/src/components/chat/acp/__tests__/state.test.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
type ExternalAgentId,
1010
getAgentConnectionCommand,
1111
getAgentDisplayName,
12-
getAllAgentIds,
1312
getSessionsByAgent,
1413
removeSession,
1514
type TabId,
@@ -632,18 +631,6 @@ describe("state utility functions", () => {
632631
});
633632
});
634633

635-
describe("getAllAgentIds", () => {
636-
it("should return all available agent IDs", () => {
637-
const agentIds = getAllAgentIds();
638-
expect(agentIds).toMatchInlineSnapshot(`
639-
[
640-
"claude",
641-
"gemini",
642-
]
643-
`);
644-
});
645-
});
646-
647634
describe("getAgentDisplayName", () => {
648635
it("should capitalize agent names", () => {
649636
expect({

frontend/src/components/chat/acp/agent-panel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,12 @@ const PromptArea = memo<PromptAreaProps>(
381381
</div>
382382
<div className="flex flex-row">
383383
<Tooltip content="Add context">
384-
<Button variant="text" size="icon" onClick={handleAddContext}>
384+
<Button
385+
variant="text"
386+
size="icon"
387+
onClick={handleAddContext}
388+
disabled={isLoading}
389+
>
385390
<AtSignIcon className="h-3.5 w-3.5" />
386391
</Button>
387392
</Tooltip>
@@ -392,6 +397,7 @@ const PromptArea = memo<PromptAreaProps>(
392397
className="cursor-pointer"
393398
onClick={() => fileInputRef.current?.click()}
394399
title="Attach a file"
400+
disabled={isLoading}
395401
>
396402
<PaperclipIcon className="h-3.5 w-3.5" />
397403
</Button>

frontend/src/components/chat/acp/agent-selector.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ const AVAILABLE_AGENTS = [
4242
displayName: "Gemini",
4343
iconId: "google",
4444
},
45+
{
46+
id: "codex",
47+
displayName: "Codex",
48+
iconId: "openai",
49+
},
4550
] as const;
4651

4752
interface AgentMenuItemProps {

frontend/src/components/chat/acp/blocks.tsx

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
XIcon,
2323
} from "lucide-react";
2424
import React from "react";
25-
import { mergeToolCalls } from "use-acp";
25+
import { JsonRpcError, mergeToolCalls } from "use-acp";
26+
import { z } from "zod";
2627
import { ReadonlyDiff } from "@/components/editor/code/readonly-diff";
2728
import { JsonOutput } from "@/components/editor/output/JsonOutput";
2829
import { Button } from "@/components/ui/button";
@@ -31,6 +32,7 @@ import {
3132
PopoverContent,
3233
PopoverTrigger,
3334
} from "@/components/ui/popover";
35+
import { uniqueByTakeLast } from "@/utils/arrays";
3436
import { logNever } from "@/utils/assertNever";
3537
import { cn } from "@/utils/cn";
3638
import { Strings } from "@/utils/strings";
@@ -102,13 +104,20 @@ export const ErrorBlock = (props: {
102104
onRetry?: () => void;
103105
onDismiss?: () => void;
104106
}) => {
105-
const { message } = props.data;
107+
const error = props.data;
108+
let message = props.data.message;
106109

107110
// Don't show WebSocket connection errors
108111
if (message.includes("WebSocket")) {
109112
return null;
110113
}
111114

115+
if (error instanceof JsonRpcError) {
116+
const dataStr =
117+
typeof error.data === "string" ? error.data : JSON.stringify(error.data);
118+
message = `${dataStr} (code: ${error.code})`;
119+
}
120+
112121
return (
113122
<div
114123
className="border border-[var(--red-6)] bg-[var(--red-2)] rounded-lg p-4 my-2"
@@ -307,7 +316,9 @@ export const AgentThoughtsBlock = (props: {
307316
};
308317

309318
export const PlansBlock = (props: { data: PlanNotificationEvent[] }) => {
310-
const plans = props.data.flatMap((item) => item.entries);
319+
// Dedupe plans by text, take the last one which may have a status update
320+
let plans = props.data.flatMap((item) => item.entries);
321+
plans = uniqueByTakeLast(plans, (item) => item.content);
311322

312323
return (
313324
<div className="rounded-lg border bg-background p-2 text-xs">
@@ -529,6 +540,7 @@ export const SessionNotificationsBlock = <
529540
data: T[];
530541
startTimestamp: number;
531542
endTimestamp: number;
543+
isLastBlock: boolean;
532544
}) => {
533545
if (props.data.length === 0) {
534546
return null;
@@ -537,7 +549,9 @@ export const SessionNotificationsBlock = <
537549

538550
const renderItems = (items: T[]) => {
539551
if (isToolCalls(items)) {
540-
return <ToolNotificationsBlock data={items} />;
552+
return (
553+
<ToolNotificationsBlock data={items} isLastBlock={props.isLastBlock} />
554+
);
541555
}
542556
if (isAgentThoughts(items)) {
543557
return (
@@ -591,6 +605,7 @@ export const CurrentModeBlock = (props: {
591605

592606
export const ToolNotificationsBlock = (props: {
593607
data: (ToolCallNotificationEvent | ToolCallUpdateNotificationEvent)[];
608+
isLastBlock: boolean;
594609
}) => {
595610
const toolCalls = mergeToolCalls(props.data);
596611

@@ -604,7 +619,9 @@ export const ToolNotificationsBlock = (props: {
604619
? "success"
605620
: item.status === "failed"
606621
? "error"
607-
: item.status === "in_progress" || item.status === "pending"
622+
: (item.status === "in_progress" ||
623+
item.status === "pending") &&
624+
!props.isLastBlock
608625
? "loading"
609626
: undefined
610627
}
@@ -631,7 +648,7 @@ export const DiffBlocks = (props: {
631648
return (
632649
<div
633650
key={item.path}
634-
className="border rounded-md overflow-hidden bg-[var(--gray-2)] overflow-y-auto scrollbar-thin"
651+
className="border rounded-md overflow-hidden bg-[var(--gray-2)] overflow-y-auto scrollbar-thin max-h-64"
635652
>
636653
{/* File path header */}
637654
<div className="px-2 py-1 bg-[var(--gray-2)] border-b text-xs font-medium text-[var(--gray-11)]">
@@ -651,8 +668,12 @@ export const DiffBlocks = (props: {
651668
function toolTitle(
652669
item: Pick<ToolCallUpdateNotificationEvent, "title" | "kind" | "locations">,
653670
) {
654-
const prefix =
655-
item.title || Strings.startCase(item.kind || "") || "Tool call";
671+
let title = item.title;
672+
// Hack: sometimes title comes back: "undefined", so lets undo that
673+
if (title === '"undefined"') {
674+
title = undefined;
675+
}
676+
const prefix = title || Strings.startCase(item.kind || "") || "Tool call";
656677
const firstLocation = item.locations?.[0];
657678
// Add the first location if it is not in the title already
658679
if (firstLocation && !prefix.includes(firstLocation.path)) {
@@ -699,6 +720,22 @@ export const ToolBodyBlock = (props: {
699720

700721
// Completely empty
701722
if (!content && !hasLocations && rawInput) {
723+
// HACK: if the raw input is `abs_path`, `old_string`, `new_string` then handle it as if it is a diff
724+
const rawDiff = rawDiffSchema.safeParse(rawInput);
725+
if (rawDiff.success) {
726+
return (
727+
<DiffBlocks
728+
data={[
729+
{
730+
type: "diff",
731+
oldText: rawDiff.data.old_string,
732+
newText: rawDiff.data.new_string,
733+
path: rawDiff.data.abs_path,
734+
},
735+
]}
736+
/>
737+
);
738+
}
702739
// Show rawInput
703740
return (
704741
<pre className="bg-[var(--slate-2)] p-1 text-muted-foreground border border-[var(--slate-4)] rounded text-xs overflow-auto scrollbar-thin max-h-64">
@@ -728,3 +765,9 @@ export const ToolBodyBlock = (props: {
728765
</div>
729766
);
730767
};
768+
769+
const rawDiffSchema = z.object({
770+
abs_path: z.string(),
771+
old_string: z.string(),
772+
new_string: z.string(),
773+
});

frontend/src/components/chat/acp/state.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { ExternalAgentSessionId, SessionSupportType } from "./types";
1111

1212
// Types
1313
export type TabId = TypedString<"TabId">;
14-
export type ExternalAgentId = "claude" | "gemini";
14+
export type ExternalAgentId = "claude" | "gemini" | "codex";
1515

1616
// No agents support loading sessions, so we limit to 1, otherwise
1717
// this is confusing to the user when switching between sessions
@@ -220,7 +220,7 @@ export function getSessionsByAgent(
220220
}
221221

222222
export function getAllAgentIds(): ExternalAgentId[] {
223-
return ["claude", "gemini"];
223+
return ["claude", "gemini", "codex"];
224224
}
225225

226226
export function getAgentDisplayName(agentId: ExternalAgentId): string {
@@ -251,6 +251,12 @@ const AGENT_CONFIG: Record<ExternalAgentId, AgentConfig> = {
251251
webSocketUrl: "ws://localhost:3019/message",
252252
sessionSupport: "single",
253253
},
254+
codex: {
255+
port: 3021,
256+
command: "npx @zed-industries/codex-acp",
257+
webSocketUrl: "ws://localhost:3021/message",
258+
sessionSupport: "single",
259+
},
254260
};
255261

256262
export function getAgentSessionSupport(

frontend/src/components/chat/acp/thread.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,10 @@ export const AgentThread = ({
5050
return true;
5151
});
5252

53-
const renderNotification = (group: NotificationEvent[]) => {
53+
const renderNotification = (
54+
group: NotificationEvent[],
55+
isLastBlock: boolean,
56+
) => {
5457
if (group.length === 0) {
5558
return null;
5659
}
@@ -90,6 +93,7 @@ export const AgentThread = ({
9093
data={data}
9194
startTimestamp={startTimestamp}
9295
endTimestamp={endTimestamp}
96+
isLastBlock={isLastBlock}
9397
/>
9498
);
9599
}
@@ -98,9 +102,12 @@ export const AgentThread = ({
98102

99103
return (
100104
<div className="flex flex-col gap-4 px-2 pb-10 flex-1">
101-
{combinedNotifications.map((notification) => (
105+
{combinedNotifications.map((notification, idx) => (
102106
<React.Fragment key={notification[0].id}>
103-
{renderNotification(notification)}
107+
{renderNotification(
108+
notification,
109+
idx === combinedNotifications.length - 1,
110+
)}
104111
</React.Fragment>
105112
))}
106113
{combinedNotifications.length === 0 && <ReadyToChatBlock />}

frontend/src/components/chat/chat-panel.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,12 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
372372
</div>
373373
<div className="flex flex-row">
374374
<Tooltip content="Add context">
375-
<Button variant="text" size="icon" onClick={onAddContext}>
375+
<Button
376+
variant="text"
377+
size="icon"
378+
onClick={onAddContext}
379+
disabled={isLoading}
380+
>
376381
<AtSignIcon className="h-3.5 w-3.5" />
377382
</Button>
378383
</Tooltip>
@@ -385,6 +390,7 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
385390
className="cursor-pointer"
386391
onClick={() => fileInputRef.current?.click()}
387392
title="Attach a file"
393+
disabled={isLoading}
388394
>
389395
<PaperclipIcon className="h-3.5 w-3.5" />
390396
</Button>

frontend/src/components/editor/code/readonly-diff.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ export const ReadonlyDiff = memo(
1515
unifiedMergeView({
1616
original: props.original,
1717
mergeControls: false,
18+
collapseUnchanged: {
19+
margin: 3,
20+
minSize: 4,
21+
},
1822
}),
1923
];
2024
}, [props.original, props.modified, theme]);

0 commit comments

Comments
 (0)