mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
Compare commits
1 Commits
aibrahim/s
...
support-ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04420335e2 |
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>();
|
||||
|
||||
188
codex-rs/tui/src/bottom_pane/pending_remote_images.rs
Normal file
188
codex-rs/tui/src/bottom_pane/pending_remote_images.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user