Compare commits

...

1 Commits

Author SHA1 Message Date
Charles Cunningham
04420335e2 tui: preserve external images across resume/backtrack 2026-02-06 14:00:18 -08:00
8 changed files with 1074 additions and 40 deletions

View File

@@ -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<TextElement>,
local_image_paths: Vec<PathBuf>|
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>|
-> Arc<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements,
local_image_paths,
remote_image_urls,
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
@@ -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<dyn HistoryCell>];
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<dyn HistoryCell>];
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<Vec<UserInput>> = 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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("assistant response")],
false,
)) as Arc<dyn HistoryCell>,
];
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;

View File

@@ -84,6 +84,8 @@ pub(crate) struct BacktrackSelection {
pub(crate) text_elements: Vec<TextElement>,
/// Local image paths associated with the selected user message.
pub(crate) local_image_paths: Vec<PathBuf>,
/// Remote image URLs associated with the selected user message.
pub(crate) remote_image_urls: Vec<String>,
}
/// 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::<UserHistoryCell>())
@@ -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<Arc<dyn crate::history_cell::HistoryCell>>,
num_turns: u32,
) -> bool {
if num_turns == 0 {
return false;
}
let user_positions: Vec<usize> = 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<dyn crate::history_cell::HistoryCell>]) -> 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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("assistant")], true))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("after")], false))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("between")], false))
as Arc<dyn HistoryCell>,
@@ -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<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(vec![Line::from("tail")], false))
as Arc<dyn HistoryCell>,
@@ -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<Arc<dyn HistoryCell>> = vec![
Arc::new(UserHistoryCell {
message: "first".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after first")],
false,
)) as Arc<dyn HistoryCell>,
Arc::new(UserHistoryCell {
message: "second".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>,
Arc::new(AgentMessageCell::new(
vec![Line::from("after second")],
false,
)) as Arc<dyn HistoryCell>,
];
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::<UserHistoryCell>()
.expect("first user");
assert_eq!(first_user.message, "first");
}
}

View File

@@ -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<CustomPrompt>,
footer_mode: FooterMode,
footer_hint_override: Option<Vec<(String, String)>>,
pending_remote_images: PendingRemoteImages,
footer_flash: Option<FooterFlash>,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
@@ -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<String>) {
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()

View File

@@ -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<String>) {
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::<AppEvent>();
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::<AppEvent>();

View File

@@ -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<String>,
}
impl PendingRemoteImages {
pub(crate) fn new() -> Self {
Self { urls: Vec::new() }
}
pub(crate) fn lines(&self, width: u16) -> Vec<Line<'static>> {
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<dyn Renderable> {
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<usize> {
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<usize> {
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<usize> {
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);
}
}

View File

@@ -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<UserMessage>,
// 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<String>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// 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<LocalImageAttachment>,
remote_image_urls: Vec<String>,
text_elements: Vec<TextElement>,
mention_paths: HashMap<String, String>,
}
@@ -647,6 +651,7 @@ impl From<String> 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<UserMessage> = 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<TextElement>,
local_images: Vec<LocalImageAttachment>,
remote_image_urls: Vec<String>,
mention_paths: HashMap<String, String>,
) {
// 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<String>) {
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<String> {
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<String> {
self.pending_non_editable_image_urls.clone()
}
pub(crate) fn show_esc_backtrack_hint(&mut self) {
self.bottom_pane.show_esc_backtrack_hint();
}

View File

@@ -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::<UserHistoryCell>()
{
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::<UserHistoryCell>()
{
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::<UserHistoryCell>()
{
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::<UserHistoryCell>()
{
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,

View File

@@ -168,6 +168,7 @@ pub(crate) struct UserHistoryCell {
pub text_elements: Vec<TextElement>,
#[allow(dead_code)]
pub local_image_paths: Vec<PathBuf>,
pub remote_image_urls: Vec<String>,
}
/// 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<usize> {
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<usize> {
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<usize> {
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<Line<'static>> {
let mut lines: Vec<Line<'static>> = 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<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> 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.