From 45936f8fbd761dbeaf75c8357d29dc89d53b9dc6 Mon Sep 17 00:00:00 2001
From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com>
Date: Thu, 2 Oct 2025 11:36:03 -0700
Subject: [PATCH] show "Viewed Image" when the model views an image (#4475)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
codex-rs/core/src/codex.rs | 11 +++++++++-
codex-rs/core/src/rollout/policy.rs | 1 +
.../src/event_processor_with_human_output.rs | 8 ++++++++
codex-rs/mcp-server/src/codex_tool_runner.rs | 1 +
codex-rs/protocol/src/protocol.rs | 11 ++++++++++
codex-rs/tui/src/chatwidget.rs | 11 ++++++++++
...cal_image_attachment_history_snapshot.snap | 6 ++++++
codex-rs/tui/src/chatwidget/tests.rs | 20 +++++++++++++++++++
codex-rs/tui/src/diff_render.rs | 4 +++-
codex-rs/tui/src/history_cell.rs | 12 +++++++++++
10 files changed, 83 insertions(+), 2 deletions(-)
create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 9f6b02a5..3114fc30 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -109,6 +109,7 @@ use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
+use crate::protocol::ViewImageToolCallEvent;
use crate::protocol::WebSearchBeginEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
@@ -2469,13 +2470,21 @@ async fn handle_function_call(
))
})?;
let abs = turn_context.resolve_path(Some(args.path));
- sess.inject_input(vec![InputItem::LocalImage { path: abs }])
+ sess.inject_input(vec![InputItem::LocalImage { path: abs.clone() }])
.await
.map_err(|_| {
FunctionCallError::RespondToModel(
"unable to attach image (no active task)".to_string(),
)
})?;
+ sess.send_event(Event {
+ id: sub_id.clone(),
+ msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
+ call_id: call_id.clone(),
+ path: abs,
+ }),
+ })
+ .await;
Ok("attached local image path".to_string())
}
diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs
index 2fd0efb0..8fc39e79 100644
--- a/codex-rs/core/src/rollout/policy.rs
+++ b/codex-rs/core/src/rollout/policy.rs
@@ -70,6 +70,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
+ | EventMsg::ViewImageToolCall(_)
| EventMsg::ConversationPath(_) => false,
}
}
diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs
index 4f231bab..72c31c29 100644
--- a/codex-rs/exec/src/event_processor_with_human_output.rs
+++ b/codex-rs/exec/src/event_processor_with_human_output.rs
@@ -580,6 +580,14 @@ impl EventProcessor for EventProcessorWithHumanOutput {
EventMsg::ListCustomPromptsResponse(_) => {
// Currently ignored in exec output.
}
+ EventMsg::ViewImageToolCall(view) => {
+ ts_println!(
+ self,
+ "{} {}",
+ "viewed image".style(self.magenta),
+ view.path.display()
+ );
+ }
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted");
diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs
index bcbfdf44..f09dc98c 100644
--- a/codex-rs/mcp-server/src/codex_tool_runner.rs
+++ b/codex-rs/mcp-server/src/codex_tool_runner.rs
@@ -280,6 +280,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::ConversationPath(_)
| EventMsg::UserMessage(_)
| EventMsg::ShutdownComplete
+ | EventMsg::ViewImageToolCall(_)
| EventMsg::EnteredReviewMode(_)
| EventMsg::ExitedReviewMode(_) => {
// For now, we do not do anything extra for these
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 98e218b9..17a4a844 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -477,6 +477,9 @@ pub enum EventMsg {
ExecCommandEnd(ExecCommandEndEvent),
+ /// Notification that the agent attached a local image via the view_image tool.
+ ViewImageToolCall(ViewImageToolCallEvent),
+
ExecApprovalRequest(ExecApprovalRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
@@ -1074,6 +1077,14 @@ pub struct ExecCommandEndEvent {
pub formatted_output: String,
}
+#[derive(Debug, Clone, Deserialize, Serialize, TS)]
+pub struct ViewImageToolCallEvent {
+ /// Identifier for the originating tool call.
+ pub call_id: String,
+ /// Local filesystem path provided to the tool.
+ pub path: PathBuf,
+}
+
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecOutputStream {
diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs
index 32566b3e..48382354 100644
--- a/codex-rs/tui/src/chatwidget.rs
+++ b/codex-rs/tui/src/chatwidget.rs
@@ -39,6 +39,7 @@ use codex_core::protocol::TokenUsageInfo;
use codex_core::protocol::TurnAbortReason;
use codex_core::protocol::TurnDiffEvent;
use codex_core::protocol::UserMessageEvent;
+use codex_core::protocol::ViewImageToolCallEvent;
use codex_core::protocol::WebSearchBeginEvent;
use codex_core::protocol::WebSearchEndEvent;
use codex_protocol::ConversationId;
@@ -538,6 +539,15 @@ impl ChatWidget {
));
}
+ fn on_view_image_tool_call(&mut self, event: ViewImageToolCallEvent) {
+ self.flush_answer_stream_with_separator();
+ self.add_to_history(history_cell::new_view_image_tool_call(
+ event.path,
+ &self.config.cwd,
+ ));
+ self.request_redraw();
+ }
+
fn on_patch_apply_end(&mut self, event: codex_core::protocol::PatchApplyEndEvent) {
let ev2 = event.clone();
self.defer_or_handle(
@@ -1398,6 +1408,7 @@ impl ChatWidget {
EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev),
EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev),
EventMsg::ExecCommandEnd(ev) => self.on_exec_command_end(ev),
+ EventMsg::ViewImageToolCall(ev) => self.on_view_image_tool_call(ev),
EventMsg::McpToolCallBegin(ev) => self.on_mcp_tool_call_begin(ev),
EventMsg::McpToolCallEnd(ev) => self.on_mcp_tool_call_end(ev),
EventMsg::WebSearchBegin(ev) => self.on_web_search_begin(ev),
diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap
new file mode 100644
index 00000000..cf4c6943
--- /dev/null
+++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__local_image_attachment_history_snapshot.snap
@@ -0,0 +1,6 @@
+---
+source: tui/src/chatwidget/tests.rs
+expression: combined
+---
+• Viewed Image
+ └ example.png
diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs
index 77cde195..e175e180 100644
--- a/codex-rs/tui/src/chatwidget/tests.rs
+++ b/codex-rs/tui/src/chatwidget/tests.rs
@@ -35,6 +35,7 @@ use codex_core::protocol::ReviewRequest;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::protocol::TaskStartedEvent;
+use codex_core::protocol::ViewImageToolCallEvent;
use codex_protocol::ConversationId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -794,6 +795,25 @@ fn custom_prompt_enter_empty_does_not_send() {
assert!(rx.try_recv().is_err(), "no app event should be sent");
}
+#[test]
+fn view_image_tool_call_adds_history_cell() {
+ let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
+ let image_path = chat.config.cwd.join("example.png");
+
+ chat.handle_codex_event(Event {
+ id: "sub-image".into(),
+ msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent {
+ call_id: "call-image".into(),
+ path: image_path,
+ }),
+ });
+
+ let cells = drain_insert_history(&mut rx);
+ assert_eq!(cells.len(), 1, "expected a single history cell");
+ let combined = lines_to_single_string(&cells[0]);
+ assert_snapshot!("local_image_attachment_history_snapshot", combined);
+}
+
// Snapshot test: interrupting a running exec finalizes the active cell with a red ✗
// marker (replacing the spinner) and flushes it into history.
#[test]
diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs
index 033fd092..6f133fee 100644
--- a/codex-rs/tui/src/diff_render.rs
+++ b/codex-rs/tui/src/diff_render.rs
@@ -268,7 +268,9 @@ pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
let chosen = if path_in_same_repo {
pathdiff::diff_paths(path, cwd).unwrap_or_else(|| path.to_path_buf())
} else {
- relativize_to_home(path).unwrap_or_else(|| path.to_path_buf())
+ relativize_to_home(path)
+ .map(|p| PathBuf::from_iter([Path::new("~"), p.as_path()]))
+ .unwrap_or_else(|| path.to_path_buf())
};
chosen.display().to_string()
}
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index 4121a983..4bab8afb 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -1,4 +1,5 @@
use crate::diff_render::create_diff_summary;
+use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
@@ -1037,6 +1038,17 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
+pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
+ let display_path = display_path_for(&path, cwd);
+
+ let lines: Vec> = vec![
+ vec!["• ".dim(), "Viewed Image".bold()].into(),
+ vec![" └ ".dim(), display_path.dim()].into(),
+ ];
+
+ PlainHistoryCell { lines }
+}
+
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,