Skip to content

Commit 4795a91

Browse files
authored
feat(tui): Display ACP tool calls in TUI chat history (#60)
## Summary 🤖 Generated with [Nori](https://www.npmjs.com/package/nori-ai) - Display ACP (Agent Client Protocol) tool calls in the TUI chat history with gear emoji - Add AcpToolCallEvent types in translator.rs to bridge ACP and codex types - Add ResponseEvent::Acp variant for streaming ACP events from model client - Add AcpEventMsg in protocol for TUI event communication - Handle tool calls on Pending status when title is available ## Test Plan - [x] Added E2E PTY test `test_acp_tool_call_displayed` that verifies gear emoji appears - [x] Mock ACP agent sends tool call sequence (pending → in_progress → completed) - [x] All existing tests pass Share Nori with your team: https://www.npmjs.com/package/nori-ai
1 parent 6dcc3ad commit 4795a91

23 files changed

Lines changed: 755 additions & 17 deletions

codex-rs/acp/src/connection.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -530,10 +530,10 @@ mod tests {
530530
// Should have received responses
531531
let mut messages = Vec::new();
532532
while let Ok(update) = rx.try_recv() {
533-
if let acp::SessionUpdate::AgentMessageChunk(chunk) = update {
534-
if let acp::ContentBlock::Text(text) = chunk.content {
535-
messages.push(text.text);
536-
}
533+
if let acp::SessionUpdate::AgentMessageChunk(chunk) = update
534+
&& let acp::ContentBlock::Text(text) = chunk.content
535+
{
536+
messages.push(text.text);
537537
}
538538
}
539539

codex-rs/acp/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ pub use registry::AcpAgentConfig;
1313
pub use registry::AcpProviderInfo;
1414
pub use registry::get_agent_config;
1515
pub use tracing_setup::init_file_tracing;
16+
pub use translator::AcpToolCallContent;
17+
pub use translator::AcpToolCallEvent;
18+
pub use translator::AcpToolCallLocation;
19+
pub use translator::AcpToolCallUpdateEvent;
20+
pub use translator::AcpToolKind;
21+
pub use translator::AcpToolStatus;
22+
pub use translator::TranslatedEvent;
23+
pub use translator::translate_session_update;
1624

1725
// Re-export commonly used types from agent-client-protocol
1826
pub use agent_client_protocol::Agent;

codex-rs/acp/src/translator.rs

Lines changed: 209 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,200 @@
66
use agent_client_protocol as acp;
77
use codex_protocol::models::ContentItem;
88
use codex_protocol::models::ResponseItem;
9+
use std::path::PathBuf;
10+
11+
/// Tool kind categories for ACP tool calls.
12+
/// Maps to agent_client_protocol::ToolKind but owned by codex.
13+
#[derive(Debug, Clone, PartialEq, Eq)]
14+
pub enum AcpToolKind {
15+
Read,
16+
Edit,
17+
Delete,
18+
Move,
19+
Search,
20+
Execute,
21+
Think,
22+
Fetch,
23+
SwitchMode,
24+
Other,
25+
}
26+
27+
impl From<&acp::ToolKind> for AcpToolKind {
28+
fn from(kind: &acp::ToolKind) -> Self {
29+
match kind {
30+
acp::ToolKind::Read => AcpToolKind::Read,
31+
acp::ToolKind::Edit => AcpToolKind::Edit,
32+
acp::ToolKind::Delete => AcpToolKind::Delete,
33+
acp::ToolKind::Move => AcpToolKind::Move,
34+
acp::ToolKind::Search => AcpToolKind::Search,
35+
acp::ToolKind::Execute => AcpToolKind::Execute,
36+
acp::ToolKind::Think => AcpToolKind::Think,
37+
acp::ToolKind::Fetch => AcpToolKind::Fetch,
38+
acp::ToolKind::SwitchMode => AcpToolKind::SwitchMode,
39+
acp::ToolKind::Other => AcpToolKind::Other,
40+
}
41+
}
42+
}
43+
44+
impl std::fmt::Display for AcpToolKind {
45+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46+
match self {
47+
AcpToolKind::Read => write!(f, "read"),
48+
AcpToolKind::Edit => write!(f, "edit"),
49+
AcpToolKind::Delete => write!(f, "delete"),
50+
AcpToolKind::Move => write!(f, "move"),
51+
AcpToolKind::Search => write!(f, "search"),
52+
AcpToolKind::Execute => write!(f, "execute"),
53+
AcpToolKind::Think => write!(f, "think"),
54+
AcpToolKind::Fetch => write!(f, "fetch"),
55+
AcpToolKind::SwitchMode => write!(f, "switch_mode"),
56+
AcpToolKind::Other => write!(f, "other"),
57+
}
58+
}
59+
}
60+
61+
/// Tool call execution status.
62+
/// Maps to agent_client_protocol::ToolCallStatus but owned by codex.
63+
#[derive(Debug, Clone, PartialEq, Eq)]
64+
pub enum AcpToolStatus {
65+
Pending,
66+
InProgress,
67+
Completed,
68+
Failed,
69+
}
70+
71+
impl From<&acp::ToolCallStatus> for AcpToolStatus {
72+
fn from(status: &acp::ToolCallStatus) -> Self {
73+
match status {
74+
acp::ToolCallStatus::Pending => AcpToolStatus::Pending,
75+
acp::ToolCallStatus::InProgress => AcpToolStatus::InProgress,
76+
acp::ToolCallStatus::Completed => AcpToolStatus::Completed,
77+
acp::ToolCallStatus::Failed => AcpToolStatus::Failed,
78+
}
79+
}
80+
}
81+
82+
impl std::fmt::Display for AcpToolStatus {
83+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84+
match self {
85+
AcpToolStatus::Pending => write!(f, "pending"),
86+
AcpToolStatus::InProgress => write!(f, "in_progress"),
87+
AcpToolStatus::Completed => write!(f, "completed"),
88+
AcpToolStatus::Failed => write!(f, "failed"),
89+
}
90+
}
91+
}
92+
93+
/// Content produced by a tool call.
94+
#[derive(Debug, Clone)]
95+
pub enum AcpToolCallContent {
96+
/// Text content
97+
Text(String),
98+
/// File diff
99+
Diff {
100+
path: PathBuf,
101+
old_text: Option<String>,
102+
new_text: String,
103+
},
104+
/// Terminal reference
105+
Terminal { terminal_id: String },
106+
}
107+
108+
/// A file location affected by a tool call.
109+
#[derive(Debug, Clone)]
110+
pub struct AcpToolCallLocation {
111+
pub path: PathBuf,
112+
pub line: Option<u32>,
113+
}
114+
115+
/// An ACP tool call event with all relevant information.
116+
#[derive(Debug, Clone)]
117+
pub struct AcpToolCallEvent {
118+
pub call_id: String,
119+
pub title: String,
120+
pub kind: AcpToolKind,
121+
pub status: AcpToolStatus,
122+
pub content: Vec<AcpToolCallContent>,
123+
pub locations: Vec<AcpToolCallLocation>,
124+
pub raw_input: Option<serde_json::Value>,
125+
pub raw_output: Option<serde_json::Value>,
126+
}
127+
128+
impl From<&acp::ToolCall> for AcpToolCallEvent {
129+
fn from(tc: &acp::ToolCall) -> Self {
130+
AcpToolCallEvent {
131+
call_id: tc.id.0.to_string(),
132+
title: tc.title.clone(),
133+
kind: AcpToolKind::from(&tc.kind),
134+
status: AcpToolStatus::from(&tc.status),
135+
content: tc.content.iter().filter_map(convert_tool_content).collect(),
136+
locations: tc.locations.iter().map(convert_tool_location).collect(),
137+
raw_input: tc.raw_input.clone(),
138+
raw_output: tc.raw_output.clone(),
139+
}
140+
}
141+
}
142+
143+
/// An update to an existing ACP tool call.
144+
#[derive(Debug, Clone)]
145+
pub struct AcpToolCallUpdateEvent {
146+
pub call_id: String,
147+
pub title: Option<String>,
148+
pub kind: Option<AcpToolKind>,
149+
pub status: Option<AcpToolStatus>,
150+
pub content: Option<Vec<AcpToolCallContent>>,
151+
pub locations: Option<Vec<AcpToolCallLocation>>,
152+
pub raw_input: Option<serde_json::Value>,
153+
pub raw_output: Option<serde_json::Value>,
154+
}
155+
156+
impl From<&acp::ToolCallUpdate> for AcpToolCallUpdateEvent {
157+
fn from(update: &acp::ToolCallUpdate) -> Self {
158+
let fields = &update.fields;
159+
AcpToolCallUpdateEvent {
160+
call_id: update.id.0.to_string(),
161+
title: fields.title.clone(),
162+
kind: fields.kind.as_ref().map(AcpToolKind::from),
163+
status: fields.status.as_ref().map(AcpToolStatus::from),
164+
content: fields
165+
.content
166+
.as_ref()
167+
.map(|c| c.iter().filter_map(convert_tool_content).collect()),
168+
locations: fields
169+
.locations
170+
.as_ref()
171+
.map(|l| l.iter().map(convert_tool_location).collect()),
172+
raw_input: fields.raw_input.clone(),
173+
raw_output: fields.raw_output.clone(),
174+
}
175+
}
176+
}
177+
178+
/// Convert ACP ToolCallContent to our internal representation.
179+
fn convert_tool_content(content: &acp::ToolCallContent) -> Option<AcpToolCallContent> {
180+
match content {
181+
acp::ToolCallContent::Content { content } => match content {
182+
acp::ContentBlock::Text(text) => Some(AcpToolCallContent::Text(text.text.clone())),
183+
_ => None, // Non-text content not yet supported
184+
},
185+
acp::ToolCallContent::Diff { diff } => Some(AcpToolCallContent::Diff {
186+
path: diff.path.clone(),
187+
old_text: diff.old_text.clone(),
188+
new_text: diff.new_text.clone(),
189+
}),
190+
acp::ToolCallContent::Terminal { terminal_id } => Some(AcpToolCallContent::Terminal {
191+
terminal_id: terminal_id.0.to_string(),
192+
}),
193+
}
194+
}
195+
196+
/// Convert ACP ToolCallLocation to our internal representation.
197+
fn convert_tool_location(loc: &acp::ToolCallLocation) -> AcpToolCallLocation {
198+
AcpToolCallLocation {
199+
path: loc.path.clone(),
200+
line: loc.line,
201+
}
202+
}
9203

10204
/// Convert codex ResponseItems to ACP ContentBlocks for prompting.
11205
///
@@ -58,12 +252,16 @@ pub fn text_to_content_block(text: &str) -> acp::ContentBlock {
58252
}
59253

60254
/// Represents an event translated from an ACP SessionUpdate.
61-
#[derive(Debug)]
255+
#[derive(Debug, Clone)]
62256
pub enum TranslatedEvent {
63257
/// Text content from the agent
64258
TextDelta(String),
65259
/// Agent completed the message with a stop reason
66260
Completed(acp::StopReason),
261+
/// A new tool call has been initiated by the ACP agent
262+
ToolCall(AcpToolCallEvent),
263+
/// An existing tool call has been updated
264+
ToolCallUpdate(AcpToolCallUpdateEvent),
67265
}
68266

69267
/// Translate an ACP SessionUpdate to a list of TranslatedEvents.
@@ -103,14 +301,17 @@ pub fn translate_session_update(update: acp::SessionUpdate) -> Vec<TranslatedEve
103301
}
104302
}
105303
}
106-
acp::SessionUpdate::ToolCall(_tool_call) => {
107-
// Tool calls are complex - for now, we just note them
108-
// The agent will send updates about tool execution via ToolCallUpdate
109-
vec![]
304+
acp::SessionUpdate::ToolCall(tool_call) => {
305+
// Convert ACP ToolCall to our internal representation
306+
vec![TranslatedEvent::ToolCall(AcpToolCallEvent::from(
307+
&tool_call,
308+
))]
110309
}
111-
acp::SessionUpdate::ToolCallUpdate(_update) => {
112-
// Tool call results - could be mapped to function call outputs
113-
vec![]
310+
acp::SessionUpdate::ToolCallUpdate(update) => {
311+
// Convert ACP ToolCallUpdate to our internal representation
312+
vec![TranslatedEvent::ToolCallUpdate(
313+
AcpToolCallUpdateEvent::from(&update),
314+
)]
114315
}
115316
acp::SessionUpdate::Plan(_plan) => {
116317
// Plans are agent-internal state

codex-rs/core/src/chat_completions.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,11 @@ where
931931
Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))) => {
932932
return Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item))));
933933
}
934+
Poll::Ready(Some(Ok(ResponseEvent::Acp(acp_event)))) => {
935+
// ACP events should not appear in chat completions streams,
936+
// but forward them if they somehow do.
937+
return Poll::Ready(Some(Ok(ResponseEvent::Acp(acp_event))));
938+
}
934939
}
935940
}
936941
}

codex-rs/core/src/client.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use crate::auth::CodexAuth;
3535
use crate::auth::RefreshTokenError;
3636
use crate::chat_completions::AggregateStreamExt;
3737
use crate::chat_completions::stream_chat_completions;
38+
use crate::client_common::AcpResponseEvent;
3839
use crate::client_common::Prompt;
3940
use crate::client_common::Reasoning;
4041
use crate::client_common::ResponseEvent;
@@ -1175,6 +1176,30 @@ async fn stream_acp_internal(
11751176
TranslatedEvent::Completed(_) => {
11761177
// Completion is handled when the prompt returns
11771178
}
1179+
TranslatedEvent::ToolCall(tool_call) => {
1180+
// Forward tool call event to the client
1181+
if tx_clone
1182+
.send(Ok(ResponseEvent::Acp(AcpResponseEvent::ToolCall(
1183+
tool_call,
1184+
))))
1185+
.await
1186+
.is_err()
1187+
{
1188+
return;
1189+
}
1190+
}
1191+
TranslatedEvent::ToolCallUpdate(update) => {
1192+
// Forward tool call update event to the client
1193+
if tx_clone
1194+
.send(Ok(ResponseEvent::Acp(AcpResponseEvent::ToolCallUpdate(
1195+
update,
1196+
))))
1197+
.await
1198+
.is_err()
1199+
{
1200+
return;
1201+
}
1202+
}
11781203
}
11791204
}
11801205
}

codex-rs/core/src/client_common.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use crate::error::Result;
33
use crate::model_family::ModelFamily;
44
use crate::protocol::RateLimitSnapshot;
55
use crate::protocol::TokenUsage;
6+
use codex_acp::AcpToolCallEvent;
7+
use codex_acp::AcpToolCallUpdateEvent;
68
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
79
use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig;
810
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -193,6 +195,18 @@ fn strip_total_output_header(output: &str) -> Option<&str> {
193195
Some(remainder)
194196
}
195197

198+
/// ACP-specific response events.
199+
///
200+
/// This enum encapsulates all ACP-related events to minimize changes
201+
/// to the parent `ResponseEvent` enum.
202+
#[derive(Debug)]
203+
pub enum AcpResponseEvent {
204+
/// A new tool call has been initiated by the ACP agent
205+
ToolCall(AcpToolCallEvent),
206+
/// An existing tool call has been updated
207+
ToolCallUpdate(AcpToolCallUpdateEvent),
208+
}
209+
196210
#[derive(Debug)]
197211
pub enum ResponseEvent {
198212
Created,
@@ -215,6 +229,8 @@ pub enum ResponseEvent {
215229
summary_index: i64,
216230
},
217231
RateLimits(RateLimitSnapshot),
232+
/// ACP-specific events (tool calls, updates, etc.)
233+
Acp(AcpResponseEvent),
218234
}
219235

220236
#[derive(Debug, Serialize)]

0 commit comments

Comments
 (0)