From 04420335e262a87369b41cff7a8510ae69d621a4 Mon Sep 17 00:00:00 2001 From: Charles Cunningham Date: Fri, 6 Feb 2026 14:00:18 -0800 Subject: [PATCH] tui: preserve external images across resume/backtrack --- codex-rs/tui/src/app.rs | 151 +++++++++- codex-rs/tui/src/app_backtrack.rs | 95 +++++- codex-rs/tui/src/bottom_pane/chat_composer.rs | 34 ++- codex-rs/tui/src/bottom_pane/mod.rs | 31 ++ .../src/bottom_pane/pending_remote_images.rs | 188 ++++++++++++ codex-rs/tui/src/chatwidget.rs | 113 ++++++- codex-rs/tui/src/chatwidget/tests.rs | 276 +++++++++++++++++- codex-rs/tui/src/history_cell.rs | 226 ++++++++++++-- 8 files changed, 1074 insertions(+), 40 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/pending_remote_images.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 718615237d..59338ac971 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2582,6 +2582,7 @@ impl App { #[cfg(test)] mod tests { use super::*; + use crate::app_backtrack::BacktrackSelection; use crate::app_backtrack::BacktrackState; use crate::app_backtrack::user_count; use crate::chatwidget::tests::make_chatwidget_manual_with_sender; @@ -2605,6 +2606,8 @@ mod tests { use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; + use codex_protocol::user_input::UserInput; + use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; @@ -2980,12 +2983,14 @@ mod tests { let user_cell = |text: &str, text_elements: Vec, - local_image_paths: Vec| + local_image_paths: Vec, + remote_image_urls: Vec| -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), text_elements, local_image_paths, + remote_image_urls, }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -3030,17 +3035,18 @@ mod tests { // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), - user_cell("first question", Vec::new(), Vec::new()), + user_cell("first question", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer first"), - user_cell("follow-up", Vec::new(), Vec::new()), + user_cell("follow-up", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer follow-up"), make_header(false), - user_cell("first question", Vec::new(), Vec::new()), + user_cell("first question", Vec::new(), Vec::new(), Vec::new()), agent_cell("answer first"), user_cell( &edited_text, edited_text_elements.clone(), edited_local_image_paths.clone(), + vec!["https://example.com/backtrack.png".to_string()], ), agent_cell("answer edited"), ]; @@ -3078,8 +3084,16 @@ mod tests { assert_eq!(selection.prefill, edited_text); assert_eq!(selection.text_elements, edited_text_elements); assert_eq!(selection.local_image_paths, edited_local_image_paths); + assert_eq!( + selection.remote_image_urls, + vec!["https://example.com/backtrack.png".to_string()] + ); app.apply_backtrack_rollback(selection); + assert_eq!( + app.chat_widget.pending_non_editable_image_urls(), + vec!["https://example.com/backtrack.png".to_string()] + ); let mut rollback_turns = None; while let Ok(op) = op_rx.try_recv() { @@ -3091,6 +3105,135 @@ mod tests { assert_eq!(rollback_turns, Some(1)); } + #[tokio::test] + async fn backtrack_remote_image_only_selection_clears_existing_composer_draft() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "original".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc]; + app.chat_widget + .set_composer_text("stale draft".to_string(), Vec::new(), Vec::new()); + + let remote_image_url = "https://example.com/remote-only.png".to_string(); + app.apply_backtrack_rollback(BacktrackSelection { + nth_user_message: 0, + prefill: String::new(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec![remote_image_url.clone()], + }); + + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + assert_eq!( + app.chat_widget.pending_non_editable_image_urls(), + vec![remote_image_url] + ); + + let mut rollback_turns = None; + while let Ok(op) = op_rx.try_recv() { + if let Op::ThreadRollback { num_turns } = op { + rollback_turns = Some(num_turns); + } + } + assert_eq!(rollback_turns, Some(1)); + } + + #[tokio::test] + async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { + let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; + + let thread_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: Some(PathBuf::new()), + }), + }); + + let data_image_url = "data:image/png;base64,abc123".to_string(); + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "please inspect this".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec![data_image_url.clone()], + }) as Arc]; + + app.apply_backtrack_rollback(BacktrackSelection { + nth_user_message: 0, + prefill: "please inspect this".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec![data_image_url.clone()], + }); + + app.chat_widget + .handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let mut saw_rollback = false; + let mut submitted_items: Option> = None; + while let Ok(op) = op_rx.try_recv() { + match op { + Op::ThreadRollback { .. } => saw_rollback = true, + Op::UserTurn { items, .. } => submitted_items = Some(items), + _ => {} + } + } + + assert!(saw_rollback); + let items = submitted_items.expect("expected user turn after backtrack resubmit"); + assert!(items.iter().any(|item| { + matches!( + item, + UserInput::Image { image_url } if image_url == &data_image_url + ) + })); + } + + #[tokio::test] + async fn replayed_thread_rollback_trims_transcript_without_pending_backtrack() { + let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await; + + app.transcript_cells = vec![ + Arc::new(UserHistoryCell { + message: "before rollback".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("assistant response")], + false, + )) as Arc, + ]; + app.backtrack.pending_rollback = None; + + app.handle_codex_event_replay(Event { + id: String::new(), + msg: EventMsg::ThreadRolledBack(codex_core::protocol::ThreadRolledBackEvent { + num_turns: 1, + }), + }); + + assert!(app.transcript_cells.is_empty()); + assert!(app.backtrack_render_pending); + } + #[tokio::test] async fn new_session_requests_shutdown_for_previous_conversation() { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 256355dd05..94b024e43d 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -84,6 +84,8 @@ pub(crate) struct BacktrackSelection { pub(crate) text_elements: Vec, /// Local image paths associated with the selected user message. pub(crate) local_image_paths: Vec, + /// Remote image URLs associated with the selected user message. + pub(crate) remote_image_urls: Vec, } /// An in-flight rollback requested from core. @@ -206,12 +208,20 @@ impl App { let prefill = selection.prefill.clone(); let text_elements = selection.text_elements.clone(); let local_image_paths = selection.local_image_paths.clone(); + let remote_image_urls = selection.remote_image_urls.clone(); + let has_remote_image_urls = !remote_image_urls.is_empty(); self.backtrack.pending_rollback = Some(PendingBacktrackRollback { selection, thread_id: self.chat_widget.thread_id(), }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - if !prefill.is_empty() || !text_elements.is_empty() || !local_image_paths.is_empty() { + self.chat_widget + .set_pending_non_editable_image_urls(remote_image_urls); + if !prefill.is_empty() + || !text_elements.is_empty() + || !local_image_paths.is_empty() + || has_remote_image_urls + { self.chat_widget .set_composer_text(prefill, text_elements, local_image_paths); } @@ -453,7 +463,18 @@ impl App { pub(crate) fn handle_backtrack_event(&mut self, event: &EventMsg) { match event { - EventMsg::ThreadRolledBack(_) => self.finish_pending_backtrack(), + EventMsg::ThreadRolledBack(rollback) => { + if self.backtrack.pending_rollback.is_some() { + self.finish_pending_backtrack(); + } else if trim_transcript_cells_drop_last_n_user_turns( + &mut self.transcript_cells, + rollback.num_turns, + ) { + // Keep inline-mode scrollback synced when rollback came from replay + // or another client rather than this UI's pending backtrack flow. + self.backtrack_render_pending = true; + } + } EventMsg::Error(ErrorEvent { codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed), .. @@ -487,7 +508,7 @@ impl App { return None; } - let (prefill, text_elements, local_image_paths) = + let (prefill, text_elements, local_image_paths, remote_image_urls) = nth_user_position(&self.transcript_cells, nth_user_message) .and_then(|idx| self.transcript_cells.get(idx)) .and_then(|cell| cell.as_any().downcast_ref::()) @@ -496,15 +517,17 @@ impl App { cell.message.clone(), cell.text_elements.clone(), cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), ) }) - .unwrap_or_else(|| (String::new(), Vec::new(), Vec::new())); + .unwrap_or_else(|| (String::new(), Vec::new(), Vec::new(), Vec::new())); Some(BacktrackSelection { nth_user_message, prefill, text_elements, local_image_paths, + remote_image_urls, }) } @@ -527,6 +550,30 @@ fn trim_transcript_cells_to_nth_user( } } +fn trim_transcript_cells_drop_last_n_user_turns( + transcript_cells: &mut Vec>, + num_turns: u32, +) -> bool { + if num_turns == 0 { + return false; + } + + let user_positions: Vec = user_positions_iter(transcript_cells).collect(); + let Some(&first_user_idx) = user_positions.first() else { + return false; + }; + + let turns_from_end = usize::try_from(num_turns).unwrap_or(usize::MAX); + let cut_idx = if turns_from_end >= user_positions.len() { + first_user_idx + } else { + user_positions[user_positions.len() - turns_from_end] + }; + let original_len = transcript_cells.len(); + transcript_cells.truncate(cut_idx); + transcript_cells.len() != original_len +} + pub(crate) fn user_count(cells: &[Arc]) -> usize { user_positions_iter(cells).count() } @@ -574,6 +621,7 @@ mod tests { message: "first user".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true)) as Arc, @@ -592,6 +640,7 @@ mod tests { message: "first".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("after")], false)) as Arc, @@ -622,6 +671,7 @@ mod tests { message: "first".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("between")], false)) as Arc, @@ -629,6 +679,7 @@ mod tests { message: "second".to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }) as Arc, Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false)) as Arc, @@ -666,4 +717,40 @@ mod tests { .collect(); assert_eq!(between_text, " between"); } + + #[test] + fn trim_drop_last_n_user_turns_applies_rollback_semantics() { + let mut cells: Vec> = vec![ + Arc::new(UserHistoryCell { + message: "first".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("after first")], + false, + )) as Arc, + Arc::new(UserHistoryCell { + message: "second".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc, + Arc::new(AgentMessageCell::new( + vec![Line::from("after second")], + false, + )) as Arc, + ]; + + let changed = trim_transcript_cells_drop_last_n_user_turns(&mut cells, 1); + + assert!(changed); + assert_eq!(cells.len(), 2); + let first_user = cells[0] + .as_any() + .downcast_ref::() + .expect("first user"); + assert_eq!(first_user.message, "first"); + } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4afbfea01a..12a374847e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -101,6 +101,7 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -131,6 +132,7 @@ use super::footer::single_line_footer_layout; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::pending_remote_images::PendingRemoteImages; use super::skill_popup::MentionItem; use super::skill_popup::SkillPopup; use super::slash_commands; @@ -284,6 +286,7 @@ pub(crate) struct ChatComposer { custom_prompts: Vec, footer_mode: FooterMode, footer_hint_override: Option>, + pending_remote_images: PendingRemoteImages, footer_flash: Option, context_window_percent: Option, context_window_used_tokens: Option, @@ -376,6 +379,7 @@ impl ChatComposer { custom_prompts: Vec::new(), footer_mode: FooterMode::ComposerEmpty, footer_hint_override: None, + pending_remote_images: PendingRemoteImages::new(), footer_flash: None, context_window_percent: None, context_window_used_tokens: None, @@ -481,7 +485,13 @@ impl ChatComposer { }; let [composer_rect, popup_rect] = Layout::vertical([Constraint::Min(3), popup_constraint]).areas(area); - let textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); + let mut textarea_rect = composer_rect.inset(Insets::tlbr(1, LIVE_PREFIX_COLS, 1, 1)); + let reserved_height = self + .pending_remote_images + .panel_height(textarea_rect.width) + .min(textarea_rect.height.saturating_sub(1)); + textarea_rect.y = textarea_rect.y.saturating_add(reserved_height); + textarea_rect.height = textarea_rect.height.saturating_sub(reserved_height); [composer_rect, textarea_rect, popup_rect] } @@ -713,6 +723,10 @@ impl ChatComposer { self.footer_hint_override = items; } + pub(crate) fn set_pending_non_editable_image_urls(&mut self, urls: Vec) { + self.pending_remote_images.urls = urls; + } + #[cfg(test)] pub(crate) fn show_footer_flash(&mut self, line: Line<'static>, duration: Duration) { let expires_at = Instant::now() @@ -3068,8 +3082,10 @@ impl Renderable for ChatComposer { let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - self.textarea - .desired_height(width.saturating_sub(COLS_WITH_MARGIN)) + let inner_width = width.saturating_sub(COLS_WITH_MARGIN); + let pending_remote_images_height = self.pending_remote_images.panel_height(inner_width); + self.textarea.desired_height(inner_width) + + pending_remote_images_height + 2 + match &self.active_popup { ActivePopup::None => footer_total_height, @@ -3307,6 +3323,18 @@ impl ChatComposer { } let style = user_message_style(); Block::default().style(style).render_ref(composer_rect, buf); + let pending_remote_image_lines = self.pending_remote_images.lines(textarea_rect.width); + if !pending_remote_image_lines.is_empty() { + let pending_rect = Rect { + x: textarea_rect.x, + y: composer_rect.y.saturating_add(1), + width: textarea_rect.width, + height: pending_remote_image_lines.len() as u16, + }; + Paragraph::new(pending_remote_image_lines) + .style(style) + .render_ref(pending_rect, buf); + } if !textarea_rect.is_empty() { let prompt = if self.input_enabled { "›".bold() diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b9c77fc72d..ec1f363c27 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -80,6 +80,7 @@ pub(crate) use skills_toggle_view::SkillsToggleView; pub(crate) use status_line_setup::StatusLineItem; pub(crate) use status_line_setup::StatusLineSetupView; mod paste_burst; +mod pending_remote_images; pub mod popup_consts; mod queued_user_messages; mod scroll_state; @@ -500,6 +501,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_pending_non_editable_image_urls(&mut self, urls: Vec) { + self.composer.set_pending_non_editable_image_urls(urls); + self.request_redraw(); + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. @@ -1225,6 +1231,31 @@ mod tests { ); } + #[test] + fn pending_remote_images_render_above_composer() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + frame_requester: FrameRequester::test_dummy(), + has_input_focus: true, + enhanced_keys_supported: false, + placeholder_text: "Ask Codex to do anything".to_string(), + disable_paste_burst: false, + animations_enabled: true, + skills: Some(Vec::new()), + }); + + pane.set_pending_non_editable_image_urls(vec![ + "https://example.com/one.png".to_string(), + "data:image/png;base64,aGVsbG8=".to_string(), + ]); + + let rendered = render_snapshot(&pane, Rect::new(0, 0, 96, pane.desired_height(96))); + assert!(rendered.contains("[external image 1] https://example.com/one.png")); + assert!(rendered.contains("[external image 2] image/png data URL (5 bytes)")); + } + #[test] fn esc_with_skill_popup_does_not_interrupt_task() { let (tx_raw, mut rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/pending_remote_images.rs b/codex-rs/tui/src/bottom_pane/pending_remote_images.rs new file mode 100644 index 0000000000..b008c2fe8e --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/pending_remote_images.rs @@ -0,0 +1,188 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; + +use crate::render::renderable::Renderable; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_lines; + +/// Widget that displays pending non-editable remote image URLs above the composer. +pub(crate) struct PendingRemoteImages { + pub urls: Vec, +} + +impl PendingRemoteImages { + pub(crate) fn new() -> Self { + Self { urls: Vec::new() } + } + + pub(crate) fn lines(&self, width: u16) -> Vec> { + if self.urls.is_empty() || width < 4 { + return Vec::new(); + } + + let total_remote_images = self.urls.len(); + let mut lines = word_wrap_lines( + self.urls.iter().enumerate().map(|(idx, url)| { + remote_image_display_line(url, idx.saturating_add(1), total_remote_images) + }), + RtOptions::new(width as usize) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")), + ); + lines.push(Line::from("")); + lines + } + + pub(crate) fn panel_height(&self, width: u16) -> u16 { + self.lines(width).len() as u16 + } + + fn as_renderable(&self, width: u16) -> Box { + let lines = self.lines(width); + if lines.is_empty() { + return Box::new(()); + } + + Paragraph::new(lines).into() + } +} + +impl Renderable for PendingRemoteImages { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + self.as_renderable(area.width).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + self.as_renderable(width).desired_height(width) + } +} + +fn inline_data_url_summary(url: &str) -> String { + let Some(data_url_body) = url.strip_prefix("data:") else { + return "image data URL (size unavailable)".to_string(); + }; + let Some((meta, payload)) = data_url_body.split_once(',') else { + return "image data URL (size unavailable)".to_string(); + }; + let media_type = meta + .split(';') + .next() + .filter(|media_type| !media_type.is_empty()) + .unwrap_or("image"); + let Some(payload_bytes) = data_url_payload_size_bytes(meta, payload) else { + return format!("{media_type} data URL (size unavailable)"); + }; + format!("{media_type} data URL ({payload_bytes} bytes)") +} + +fn data_url_payload_size_bytes(meta: &str, payload: &str) -> Option { + if meta + .split(';') + .any(|part| part.eq_ignore_ascii_case("base64")) + { + return base64_decoded_len(payload); + } + percent_decoded_len(payload) +} + +fn base64_decoded_len(payload: &str) -> Option { + let mut data_len = 0usize; + let mut padding = 0usize; + let mut saw_padding = false; + for byte in payload.bytes() { + if byte.is_ascii_whitespace() { + continue; + } + if byte == b'=' { + saw_padding = true; + padding = padding.saturating_add(1); + continue; + } + if saw_padding { + return None; + } + if is_base64_char(byte) { + data_len = data_len.saturating_add(1); + } else { + return None; + } + } + if padding > 2 { + return None; + } + let total_len = data_len.saturating_add(padding); + if !total_len.is_multiple_of(4) { + return None; + } + let decoded_len = (total_len / 4).saturating_mul(3).saturating_sub(padding); + Some(decoded_len) +} + +fn is_base64_char(byte: u8) -> bool { + matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' | b'-' | b'_') +} + +fn percent_decoded_len(payload: &str) -> Option { + let bytes = payload.as_bytes(); + let mut idx = 0usize; + let mut decoded_len = 0usize; + while idx < bytes.len() { + if bytes[idx] == b'%' { + if idx + 2 >= bytes.len() { + return None; + } + if !bytes[idx + 1].is_ascii_hexdigit() || !bytes[idx + 2].is_ascii_hexdigit() { + return None; + } + decoded_len = decoded_len.saturating_add(1); + idx = idx.saturating_add(3); + } else { + decoded_len = decoded_len.saturating_add(1); + idx = idx.saturating_add(1); + } + } + Some(decoded_len) +} + +fn remote_image_display_label(index: usize, total: usize) -> String { + if total > 1 { + format!("[external image {index}] ") + } else { + "[external image] ".to_string() + } +} + +fn remote_image_display_line(url: &str, index: usize, total: usize) -> Line<'static> { + let label = remote_image_display_label(index, total); + if url.starts_with("data:") { + vec![label.dim(), inline_data_url_summary(url).dim()].into() + } else { + vec![label.dim(), url.to_string().cyan().underlined()].into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn desired_height_empty() { + let images = PendingRemoteImages::new(); + assert_eq!(images.desired_height(40), 0); + } + + #[test] + fn desired_height_with_images() { + let images = PendingRemoteImages { + urls: vec!["https://example.com/a.png".to_string()], + }; + assert_eq!(images.desired_height(60), 2); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 43755c1f41..cf566766f6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -550,6 +550,9 @@ pub(crate) struct ChatWidget { suppress_session_configured_redraw: bool, // User messages queued while a turn is in progress queued_user_messages: VecDeque, + // Remote image URLs carried with the current draft (for example from backtrack replay). + // These are read-only in the composer and are applied to the next user submission. + pending_non_editable_image_urls: Vec, // Pending notification to show when unfocused on next Draw pending_notification: Option, /// When `Some`, the user has pressed a quit shortcut and the second press @@ -638,6 +641,7 @@ pub(crate) struct ActiveCellTranscriptKey { pub(crate) struct UserMessage { text: String, local_images: Vec, + remote_image_urls: Vec, text_elements: Vec, mention_paths: HashMap, } @@ -647,6 +651,7 @@ impl From for UserMessage { Self { text, local_images: Vec::new(), + remote_image_urls: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), mention_paths: HashMap::new(), @@ -659,6 +664,7 @@ impl From<&str> for UserMessage { Self { text: text.to_string(), local_images: Vec::new(), + remote_image_urls: Vec::new(), // Plain text conversion has no UI element ranges. text_elements: Vec::new(), mention_paths: HashMap::new(), @@ -686,6 +692,7 @@ pub(crate) fn create_initial_user_message( Some(UserMessage { text, local_images, + remote_image_urls: Vec::new(), text_elements, mention_paths: HashMap::new(), }) @@ -701,6 +708,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text, text_elements, local_images, + remote_image_urls, mention_paths, } = message; if local_images.is_empty() { @@ -708,6 +716,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) text, text_elements, local_images, + remote_image_urls, mention_paths, }; } @@ -762,6 +771,7 @@ fn remap_placeholders_for_message(message: UserMessage, next_label: &mut usize) UserMessage { text: rebuilt, local_images: remapped_images, + remote_image_urls, text_elements: rebuilt_elements, mention_paths, } @@ -1666,11 +1676,15 @@ impl ChatWidget { text: self.bottom_pane.composer_text(), text_elements: self.bottom_pane.composer_text_elements(), local_images: self.bottom_pane.composer_local_images(), + remote_image_urls: self.pending_non_editable_image_urls.clone(), mention_paths: HashMap::new(), }; let mut to_merge: Vec = self.queued_user_messages.drain(..).collect(); - if !existing_message.text.is_empty() || !existing_message.local_images.is_empty() { + if !existing_message.text.is_empty() + || !existing_message.local_images.is_empty() + || !existing_message.remote_image_urls.is_empty() + { to_merge.push(existing_message); } @@ -1678,6 +1692,7 @@ impl ChatWidget { text: String::new(), text_elements: Vec::new(), local_images: Vec::new(), + remote_image_urls: Vec::new(), mention_paths: HashMap::new(), }; let mut combined_offset = 0usize; @@ -1700,6 +1715,7 @@ impl ChatWidget { elem })); combined.local_images.extend(message.local_images); + combined.remote_image_urls.extend(message.remote_image_urls); combined.mention_paths.extend(message.mention_paths); } @@ -1710,10 +1726,12 @@ impl ChatWidget { let UserMessage { text, local_images, + remote_image_urls, text_elements, mention_paths: _, } = user_message; let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); + self.set_pending_non_editable_image_urls(remote_image_urls); self.bottom_pane .set_composer_text(text, text_elements, local_image_paths); } @@ -2559,6 +2577,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_non_editable_image_urls: Vec::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2725,6 +2744,7 @@ impl ChatWidget { plan_delta_buffer: String::new(), plan_item_active: false, queued_user_messages: VecDeque::new(), + pending_non_editable_image_urls: Vec::new(), show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, @@ -2872,6 +2892,7 @@ impl ChatWidget { thread_name: None, forked_from: None, queued_user_messages: VecDeque::new(), + pending_non_editable_image_urls: Vec::new(), show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, @@ -3021,6 +3042,7 @@ impl ChatWidget { local_images: self .bottom_pane .take_recent_submission_images_with_placeholders(), + remote_image_urls: self.take_pending_non_editable_image_urls(), text_elements, mention_paths: self.bottom_pane.take_mention_paths(), }; @@ -3044,6 +3066,7 @@ impl ChatWidget { local_images: self .bottom_pane .take_recent_submission_images_with_placeholders(), + remote_image_urls: self.take_pending_non_editable_image_urls(), text_elements, mention_paths: self.bottom_pane.take_mention_paths(), }; @@ -3055,7 +3078,33 @@ impl ChatWidget { InputResult::CommandWithArgs(cmd, args, text_elements) => { self.dispatch_command_with_args(cmd, args, text_elements); } - InputResult::None => {} + InputResult::None => { + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Enter + && !self.pending_non_editable_image_urls.is_empty() + && self + .bottom_pane + .composer_text_with_pending() + .trim() + .is_empty() + { + let user_message = UserMessage { + text: String::new(), + local_images: Vec::new(), + remote_image_urls: self.take_pending_non_editable_image_urls(), + text_elements: Vec::new(), + mention_paths: self.bottom_pane.take_mention_paths(), + }; + if self.is_session_configured() { + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); + self.set_status_header(String::from("Working")); + self.submit_user_message(user_message); + } else { + self.queue_user_message(user_message); + } + } + } }, } } @@ -3413,6 +3462,7 @@ impl ChatWidget { local_images: self .bottom_pane .take_recent_submission_images_with_placeholders(), + remote_image_urls: self.take_pending_non_editable_image_urls(), text_elements: prepared_elements, mention_paths: self.bottom_pane.take_mention_paths(), }; @@ -3550,14 +3600,23 @@ impl ChatWidget { let UserMessage { text, local_images, + remote_image_urls, text_elements, mention_paths, } = user_message; - if text.is_empty() && local_images.is_empty() { + if text.is_empty() && local_images.is_empty() && remote_image_urls.is_empty() { return; } - if !local_images.is_empty() && !self.current_model_supports_images() { - self.restore_blocked_image_submission(text, text_elements, local_images, mention_paths); + if (!local_images.is_empty() || !remote_image_urls.is_empty()) + && !self.current_model_supports_images() + { + self.restore_blocked_image_submission( + text, + text_elements, + local_images, + remote_image_urls, + mention_paths, + ); return; } @@ -3581,6 +3640,12 @@ impl ChatWidget { return; } + for image_url in &remote_image_urls { + items.push(UserInput::Image { + image_url: image_url.clone(), + }); + } + for image in &local_images { items.push(UserInput::LocalImage { path: image.path.clone(), @@ -3661,13 +3726,21 @@ impl ChatWidget { }); } - // Only show the text portion in conversation history. + // Show replayable user content in conversation history. if !text.is_empty() { let local_image_paths = local_images.into_iter().map(|img| img.path).collect(); self.add_to_history(history_cell::new_user_prompt( text, text_elements, local_image_paths, + remote_image_urls, + )); + } else if !remote_image_urls.is_empty() { + self.add_to_history(history_cell::new_user_prompt( + String::new(), + Vec::new(), + Vec::new(), + remote_image_urls, )); } @@ -3686,10 +3759,12 @@ impl ChatWidget { text: String, text_elements: Vec, local_images: Vec, + remote_image_urls: Vec, mention_paths: HashMap, ) { // Preserve the user's composed payload so they can retry after changing models. let local_image_paths = local_images.iter().map(|img| img.path.clone()).collect(); + self.set_pending_non_editable_image_urls(remote_image_urls); self.bottom_pane.set_composer_text_with_mention_paths( text, text_elements, @@ -3957,11 +4032,16 @@ impl ChatWidget { } fn on_user_message_event(&mut self, event: UserMessageEvent) { - if !event.message.trim().is_empty() { + let remote_image_urls = event.images.unwrap_or_default(); + if !event.message.trim().is_empty() + || !event.local_images.is_empty() + || !remote_image_urls.is_empty() + { self.add_to_history(history_cell::new_user_prompt( event.message, event.text_elements, event.local_images, + remote_image_urls, )); } @@ -6435,6 +6515,7 @@ impl ChatWidget { let user_message = UserMessage { text, local_images: Vec::new(), + remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_paths: HashMap::new(), }; @@ -6467,6 +6548,24 @@ impl ChatWidget { .set_composer_text(text, text_elements, local_image_paths); } + pub(crate) fn set_pending_non_editable_image_urls(&mut self, remote_image_urls: Vec) { + self.pending_non_editable_image_urls = remote_image_urls.clone(); + self.bottom_pane + .set_pending_non_editable_image_urls(remote_image_urls); + } + + fn take_pending_non_editable_image_urls(&mut self) -> Vec { + let urls = std::mem::take(&mut self.pending_non_editable_image_urls); + self.bottom_pane + .set_pending_non_editable_image_urls(Vec::new()); + urls + } + + #[cfg(test)] + pub(crate) fn pending_non_editable_image_urls(&self) -> Vec { + self.pending_non_editable_image_urls.clone() + } + pub(crate) fn show_esc_backtrack_hint(&mut self) { self.bottom_pane.show_esc_backtrack_hint(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6d7df44119..508dd9a948 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,16 +235,124 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { cell.message.clone(), cell.text_elements.clone(), cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), )); break; } } - let (stored_message, stored_elements, stored_images) = + let (stored_message, stored_elements, stored_images, stored_remote_image_urls) = user_cell.expect("expected a replayed user history cell"); assert_eq!(stored_message, message); assert_eq!(stored_elements, text_elements); assert_eq!(stored_images, local_images); + assert!(stored_remote_image_urls.is_empty()); +} + +#[tokio::test] +async fn replayed_user_message_preserves_remote_image_urls() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let message = "replayed with remote image".to_string(); + let remote_image_urls = vec!["https://example.com/image.png".to_string()]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: message.clone(), + images: Some(remote_image_urls.clone()), + text_elements: Vec::new(), + local_images: Vec::new(), + })]), + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), + )); + break; + } + } + + let (stored_message, stored_local_images, stored_remote_image_urls) = + user_cell.expect("expected a replayed user history cell"); + assert_eq!(stored_message, message); + assert!(stored_local_images.is_empty()); + assert_eq!(stored_remote_image_urls, remote_image_urls); +} + +#[tokio::test] +async fn replayed_user_message_with_only_remote_images_renders_history_cell() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()]; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: Some(vec![EventMsg::UserMessage(UserMessageEvent { + message: String::new(), + images: Some(remote_image_urls.clone()), + text_elements: Vec::new(), + local_images: Vec::new(), + })]), + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone())); + break; + } + } + + let (stored_message, stored_remote_image_urls) = + user_cell.expect("expected a replayed remote-image-only user history cell"); + assert!(stored_message.is_empty()); + assert_eq!(stored_remote_image_urls, remote_image_urls); } #[tokio::test] @@ -378,16 +486,169 @@ async fn submission_preserves_text_elements_and_local_images() { cell.message.clone(), cell.text_elements.clone(), cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), )); break; } } - let (stored_message, stored_elements, stored_images) = + let (stored_message, stored_elements, stored_images, stored_remote_image_urls) = user_cell.expect("expected submitted user history cell"); assert_eq!(stored_message, text); assert_eq!(stored_elements, text_elements); assert_eq!(stored_images, local_images); + assert!(stored_remote_image_urls.is_empty()); +} + +#[tokio::test] +async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let remote_url = "https://example.com/remote.png".to_string(); + chat.set_pending_non_editable_image_urls(vec![remote_url.clone()]); + + let placeholder = "[Image #1]"; + let text = format!("{placeholder} submit mixed"); + let text_elements = vec![TextElement::new( + (0..placeholder.len()).into(), + Some(placeholder.to_string()), + )]; + let local_images = vec![PathBuf::from("/tmp/submitted-mixed.png")]; + + chat.bottom_pane + .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!(items.len(), 3); + assert_eq!( + items[0], + UserInput::Image { + image_url: remote_url.clone(), + } + ); + assert_eq!( + items[1], + UserInput::LocalImage { + path: local_images[0].clone(), + } + ); + assert_eq!( + items[2], + UserInput::Text { + text: text.clone(), + text_elements: text_elements.clone(), + } + ); + assert_eq!(text_elements[0].placeholder(&text), Some("[Image #1]")); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some(( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + cell.remote_image_urls.clone(), + )); + break; + } + } + + let (stored_message, stored_elements, stored_images, stored_remote_image_urls) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, text); + assert_eq!(stored_elements, text_elements); + assert_eq!(stored_images, local_images); + assert_eq!(stored_remote_image_urls, vec![remote_url]); +} + +#[tokio::test] +async fn enter_with_only_pending_remote_images_submits_user_turn() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + let conversation_id = ThreadId::new(); + let rollout_file = NamedTempFile::new().unwrap(); + let configured = codex_core::protocol::SessionConfiguredEvent { + session_id: conversation_id, + forked_from_id: None, + thread_name: None, + model: "test-model".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, + cwd: PathBuf::from("/home/user/project"), + reasoning_effort: Some(ReasoningEffortConfig::default()), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: Some(rollout_file.path().to_path_buf()), + }; + chat.handle_codex_event(Event { + id: "initial".into(), + msg: EventMsg::SessionConfigured(configured), + }); + drain_insert_history(&mut rx); + + let remote_url = "https://example.com/remote-only.png".to_string(); + chat.set_pending_non_editable_image_urls(vec![remote_url.clone()]); + + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + let items = match next_submit_op(&mut op_rx) { + Op::UserTurn { items, .. } => items, + other => panic!("expected Op::UserTurn, got {other:?}"), + }; + assert_eq!( + items, + vec![UserInput::Image { + image_url: remote_url.clone(), + }] + ); + assert!(chat.pending_non_editable_image_urls().is_empty()); + + let mut user_cell = None; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = ev + && let Some(cell) = cell.as_any().downcast_ref::() + { + user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone())); + break; + } + } + + let (stored_message, stored_remote_image_urls) = + user_cell.expect("expected submitted user history cell"); + assert_eq!(stored_message, String::new()); + assert_eq!(stored_remote_image_urls, vec![remote_url]); } #[tokio::test] @@ -411,6 +672,7 @@ async fn blocked_image_restore_preserves_mention_paths() { text.clone(), text_elements.clone(), local_images.clone(), + Vec::new(), mention_paths.clone(), ); @@ -467,6 +729,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { placeholder: first_placeholder.to_string(), path: first_images[0].clone(), }], + remote_image_urls: Vec::new(), text_elements: first_elements, mention_paths: HashMap::new(), }); @@ -476,6 +739,7 @@ async fn interrupted_turn_restores_queued_messages_with_images_and_elements() { placeholder: second_placeholder.to_string(), path: second_images[0].clone(), }], + remote_image_urls: Vec::new(), text_elements: second_elements, mention_paths: HashMap::new(), }); @@ -544,6 +808,7 @@ async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { chat.queued_user_messages.push_back(UserMessage { text: "Implement the plan.".to_string(), local_images: Vec::new(), + remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_paths: HashMap::new(), }); @@ -605,6 +870,7 @@ async fn remap_placeholders_uses_attachment_labels() { text, text_elements: elements, local_images: attachments, + remote_image_urls: vec!["https://example.com/a.png".to_string()], mention_paths: HashMap::new(), }; let mut next_label = 3usize; @@ -637,6 +903,10 @@ async fn remap_placeholders_uses_attachment_labels() { }, ] ); + assert_eq!( + remapped.remote_image_urls, + vec!["https://example.com/a.png".to_string()] + ); } #[tokio::test] @@ -666,6 +936,7 @@ async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { text, text_elements: elements, local_images: attachments, + remote_image_urls: Vec::new(), mention_paths: HashMap::new(), }; let mut next_label = 3usize; @@ -991,6 +1262,7 @@ async fn make_chatwidget_manual( frame_requester: FrameRequester::test_dummy(), show_welcome_banner: true, queued_user_messages: VecDeque::new(), + pending_non_editable_image_urls: Vec::new(), suppress_session_configured_redraw: false, pending_notification: None, quit_shortcut_expires_at: None, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9f27bc9cf1..4a28f1f922 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -168,6 +168,7 @@ pub(crate) struct UserHistoryCell { pub text_elements: Vec, #[allow(dead_code)] pub local_image_paths: Vec, + pub remote_image_urls: Vec, } /// Build logical lines for a user message with styled text elements. @@ -236,6 +237,110 @@ fn build_user_message_lines_with_elements( raw_lines } +fn inline_data_url_summary(url: &str) -> String { + let Some(data_url_body) = url.strip_prefix("data:") else { + return "image data URL (size unavailable)".to_string(); + }; + let Some((meta, payload)) = data_url_body.split_once(',') else { + return "image data URL (size unavailable)".to_string(); + }; + let media_type = meta + .split(';') + .next() + .filter(|media_type| !media_type.is_empty()) + .unwrap_or("image"); + let Some(payload_bytes) = data_url_payload_size_bytes(meta, payload) else { + return format!("{media_type} data URL (size unavailable)"); + }; + format!("{media_type} data URL ({payload_bytes} bytes)") +} + +fn data_url_payload_size_bytes(meta: &str, payload: &str) -> Option { + if meta + .split(';') + .any(|part| part.eq_ignore_ascii_case("base64")) + { + return base64_decoded_len(payload); + } + percent_decoded_len(payload) +} + +fn base64_decoded_len(payload: &str) -> Option { + let mut data_len = 0usize; + let mut padding = 0usize; + let mut saw_padding = false; + for byte in payload.bytes() { + if byte.is_ascii_whitespace() { + continue; + } + if byte == b'=' { + saw_padding = true; + padding = padding.saturating_add(1); + continue; + } + if saw_padding { + return None; + } + if is_base64_char(byte) { + data_len = data_len.saturating_add(1); + } else { + return None; + } + } + if padding > 2 { + return None; + } + let total_len = data_len.saturating_add(padding); + if !total_len.is_multiple_of(4) { + return None; + } + let decoded_len = (total_len / 4).saturating_mul(3).saturating_sub(padding); + Some(decoded_len) +} + +fn is_base64_char(byte: u8) -> bool { + matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' | b'-' | b'_') +} + +fn percent_decoded_len(payload: &str) -> Option { + let bytes = payload.as_bytes(); + let mut idx = 0usize; + let mut decoded_len = 0usize; + while idx < bytes.len() { + if bytes[idx] == b'%' { + if idx + 2 >= bytes.len() { + return None; + } + if !bytes[idx + 1].is_ascii_hexdigit() || !bytes[idx + 2].is_ascii_hexdigit() { + return None; + } + decoded_len = decoded_len.saturating_add(1); + idx = idx.saturating_add(3); + } else { + decoded_len = decoded_len.saturating_add(1); + idx = idx.saturating_add(1); + } + } + Some(decoded_len) +} + +fn remote_image_display_label(index: usize, total: usize) -> String { + if total > 1 { + format!("[external image {index}] ") + } else { + "[external image] ".to_string() + } +} + +fn remote_image_display_line(url: &str, style: Style, index: usize, total: usize) -> Line<'static> { + let label = remote_image_display_label(index, total); + if url.starts_with("data:") { + Line::from(vec![label.dim(), inline_data_url_summary(url).dim()]).style(style) + } else { + Line::from(vec![label.dim(), url.to_string().cyan().underlined()]).style(style) + } +} + impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); @@ -249,30 +354,56 @@ impl HistoryCell for UserHistoryCell { let style = user_message_style(); let element_style = style.fg(Color::Cyan); - let wrapped = if self.text_elements.is_empty() { - word_wrap_lines( - self.message.split('\n').map(|l| Line::from(l).style(style)), - // Wrap algorithm matches textarea.rs. + if !self.remote_image_urls.is_empty() { + let total_remote_images = self.remote_image_urls.len(); + let wrapped_remote_images = word_wrap_lines( + self.remote_image_urls.iter().enumerate().map(|(idx, url)| { + remote_image_display_line( + url, + style, + idx.saturating_add(1), + total_remote_images, + ) + }), RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ) - } else { - let raw_lines = build_user_message_lines_with_elements( - &self.message, - &self.text_elements, - style, - element_style, ); - word_wrap_lines( - raw_lines, - RtOptions::new(usize::from(wrap_width)) - .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), - ) - }; + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines( + wrapped_remote_images, + " ".into(), + " ".into(), + )); + } - lines.push(Line::from("").style(style)); - lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); - lines.push(Line::from("").style(style)); + if !self.message.is_empty() || !self.text_elements.is_empty() { + let wrapped = if self.text_elements.is_empty() { + word_wrap_lines( + self.message.split('\n').map(|l| Line::from(l).style(style)), + // Wrap algorithm matches textarea.rs. + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + } else { + let raw_lines = build_user_message_lines_with_elements( + &self.message, + &self.text_elements, + style, + element_style, + ); + word_wrap_lines( + raw_lines, + RtOptions::new(usize::from(wrap_width)) + .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), + ) + }; + + lines.push(Line::from("").style(style)); + lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into())); + } + if !lines.is_empty() { + lines.push(Line::from("").style(style)); + } lines } } @@ -1018,11 +1149,13 @@ pub(crate) fn new_user_prompt( message: String, text_elements: Vec, local_image_paths: Vec, + remote_image_urls: Vec, ) -> UserHistoryCell { UserHistoryCell { message, text_elements, local_image_paths, + remote_image_urls, } } @@ -3359,6 +3492,7 @@ mod tests { message: msg.to_string(), text_elements: Vec::new(), local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), }; // Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space. @@ -3369,6 +3503,58 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn user_history_cell_renders_remote_image_urls() { + let cell = UserHistoryCell { + message: "describe these".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec!["https://example.com/example.png".to_string()], + }; + + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + + assert!(rendered.contains("[external image]")); + assert!(rendered.contains("https://example.com/example.png")); + assert!(rendered.contains("describe these")); + } + + #[test] + fn user_history_cell_summarizes_inline_data_urls() { + let cell = UserHistoryCell { + message: "describe inline image".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec!["data:image/png;base64,aGVsbG8=".to_string()], + }; + + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + + assert!(rendered.contains("[external image]")); + assert!(rendered.contains("image/png data URL (5 bytes)")); + assert!(rendered.contains("describe inline image")); + } + + #[test] + fn user_history_cell_numbers_multiple_remote_images() { + let cell = UserHistoryCell { + message: "describe both".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: vec![ + "https://example.com/one.png".to_string(), + "https://example.com/two.png".to_string(), + ], + }; + + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + + assert!(rendered.contains("[external image 1]")); + assert!(rendered.contains("[external image 2]")); + assert!(rendered.contains("https://example.com/one.png")); + assert!(rendered.contains("https://example.com/two.png")); + } + #[test] fn plan_update_with_note_and_wrapping_snapshot() { // Long explanation forces wrapping; include long step text to verify step wrapping and alignment.