tui: preserve remote image attachments across resume/backtrack (#10590)

## Summary
This PR makes app-server-provided image URLs first-class attachments in
TUI, so they survive resume/backtrack/history recall and are resubmitted
correctly.

<img width="715" height="491" alt="Screenshot 2026-02-12 at 8 27 08 PM"
src="https://github.com/user-attachments/assets/226cbd35-8f0c-4e51-a13e-459ef5dd1927"
/>

Can delete the attached image upon backtracking:
<img width="716" height="301" alt="Screenshot 2026-02-12 at 8 27 31 PM"
src="https://github.com/user-attachments/assets/4558d230-f1bd-4eed-a093-8e1ab9c6db27"
/>

In both history and composer, remote images are rendered as normal
`[Image #N]` placeholders, with numbering unified with local images.

## What changed
- Plumb remote image URLs through TUI message state:
  - `UserHistoryCell`
  - `BacktrackSelection`
  - `ChatComposerHistory::HistoryEntry`
  - `ChatWidget::UserMessage`
- Show remote images as placeholder rows inside the composer box (above
textarea), and in history cells.
- Support keyboard selection/deletion for remote image rows in composer
(`Up`/`Down`, `Delete`/`Backspace`).
- Preserve remote-image-only turns in local composer history (Up/Down
recall), including restore after backtrack.
- Ensure submit/queue/backtrack resubmit include remote images in model
input (`UserInput::Image`), and keep request shape stable for
remote-image-only turns.
- Keep image numbering contiguous across remote + local images:
  - remote images occupy `[Image #1]..[Image #M]`
  - local images start at `[Image #M+1]`
  - deletion renumbers consistently.
- In protocol conversion, increment shared image index for remote images
too, so mixed remote/local image tags stay in a single sequence.
- Simplify restore logic to trust in-memory attachment order (no
placeholder-number parsing path).
- Backtrack/replay rollback handling now queues trims through
`AppEvent::ApplyThreadRollback` and syncs transcript overlay/deferred
lines after trims, so overlay/transcript state stays consistent.
- Trim trailing blank rendered lines from user history rendering to
avoid oversized blank padding.

## Docs + tests
- Updated: `docs/tui-chat-composer.md` (remote image flow,
selection/deletion, numbering offsets)
- Added/updated tests across `tui/src/chatwidget/tests.rs`,
`tui/src/app.rs`, `tui/src/app_backtrack.rs`, `tui/src/history_cell.rs`,
and `tui/src/bottom_pane/chat_composer.rs`
- Added snapshot coverage for remote image composer states, including
deleting the first of two remote images.

## Validation
- `just fmt`
- `cargo test -p codex-tui`

## Codex author
`codex fork 019c2636-1571-74a1-8471-15a3b1c3f49d`
This commit is contained in:
Charley Cunningham
2026-02-13 14:54:06 -08:00
committed by GitHub
parent 395729910c
commit 26a7cd21e2
15 changed files with 1713 additions and 92 deletions

View File

@@ -250,6 +250,7 @@ impl BottomPane {
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
let _ = self.take_remote_image_urls();
let _ = self.take_recent_submission_mention_bindings();
let _ = self.take_mention_bindings();
}
@@ -520,6 +521,21 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.composer.set_remote_image_urls(urls);
self.request_redraw();
}
pub(crate) fn remote_image_urls(&self) -> Vec<String> {
self.composer.remote_image_urls()
}
pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> {
let urls = self.composer.take_remote_image_urls();
self.request_redraw();
urls
}
/// 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.
@@ -1315,6 +1331,58 @@ mod tests {
);
}
#[test]
fn remote_images_render_above_composer_text() {
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_remote_image_urls(vec![
"https://example.com/one.png".to_string(),
"data:image/png;base64,aGVsbG8=".to_string(),
]);
assert_eq!(pane.composer_text(), "");
let width = 48;
let height = pane.desired_height(width);
let area = Rect::new(0, 0, width, height);
let snapshot = render_snapshot(&pane, area);
assert!(snapshot.contains("[Image #1]"));
assert!(snapshot.contains("[Image #2]"));
}
#[test]
fn drain_pending_submission_state_clears_remote_image_urls() {
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_remote_image_urls(vec!["https://example.com/one.png".to_string()]);
assert_eq!(pane.remote_image_urls().len(), 1);
pane.drain_pending_submission_state();
assert!(pane.remote_image_urls().is_empty());
}
#[test]
fn esc_with_skill_popup_does_not_interrupt_task() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();