From 8c811e8d93839103b5185d1f6324ebd2671adff1 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 1 Nov 2026 00:37:38 +0000 Subject: [PATCH] Support rendering TUI conversations within an inset area (#58718) ## What changed Split conversation rendering into preparation, painting, and post-draw steps. Use the supplied conversation bounds for transcript wrapping, composer layout, suggestion overlays, cursor placement, and the empty-state animation. Preserve surrounding text and image placements during input-triggered redraws. Keep centered dialogs relative to the full viewport and restore neighboring content when they close. ## Testing Extend regression tests and add a snapshot for conversations rendered at an offset, covering selection, link hit testing, cursor placement, scroll preservation, neighboring images, and dialog dismissal. GitOrigin-RevId: 0dbca3f8d1064f2d58856c3bc77a4dab1c099cdd --- codex-rs/tui/src/app.rs | 8 +- codex-rs/tui/src/app/event_dispatch.rs | 3 +- codex-rs/tui/src/app/footer_selection.rs | 3 +- codex-rs/tui/src/app/owned_transcript.rs | 605 +++++++++++------- .../src/app/owned_transcript_follow_tests.rs | 29 + .../src/app/owned_transcript_input_tests.rs | 34 + .../tui/src/app/owned_transcript_tests.rs | 151 +++++ ...t__tests__conversation_in_offset_rect.snap | 22 + codex-rs/tui/src/app/startup.rs | 1 + codex-rs/tui/src/app/test_support.rs | 1 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/app/turn_tips_tests.rs | 8 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 +- .../bottom_pane/chat_composer/footer_state.rs | 2 + codex-rs/tui/src/bottom_pane/view_stack.rs | 15 +- codex-rs/tui/src/empty_state_animation.rs | 2 +- .../tui/src/empty_state_animation_tests.rs | 6 +- codex-rs/tui/src/startup_draft_layout.rs | 1 + codex-rs/tui/src/transcript_image.rs | 12 + 19 files changed, 661 insertions(+), 249 deletions(-) create mode 100644 codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2bd7c36370..80459f4113 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -571,6 +571,7 @@ pub(crate) struct App { native_history: native_history::NativeHistory, turn_tips: turn_tips::TurnTips, pub(crate) transcript_view: crate::transcript_view::TranscriptView, + conversation_frame: owned_transcript::ConversationFrame, last_rendered_history_tail: Option, last_thread_usage_status_cell: Option, pub(crate) pending_thread_usage_history_refresh: bool, @@ -1210,12 +1211,7 @@ impl App { self.discard_startup_modal_input(tui)?; } if self.chat_widget.ambient_pet_image_enabled() { - let ambient_pet_area = Rect::new( - /*x*/ 0, - /*y*/ 0, - screen_size.width, - screen_size.height, - ); + let ambient_pet_area = self.conversation_area(screen_size.into()); if let Err(err) = tui.draw_ambient_pet_image( self.chat_widget .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index adb3f9eece..ba6e3f8af0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -479,8 +479,7 @@ impl App { self.chat_widget.maybe_send_next_queued_input(); } AppEvent::SelectTranscriptCopy { guard } => { - let size = tui.prepare_draw_size()?; - self.render_owned_transcript(tui, size)?; + self.refresh_owned_transcript(tui)?; if !self.transcript_view.begin_copy_mode(&self.transcript_cells, Some(guard)) { self.chat_widget.add_info_message("Nothing to copy".into(), /*hint*/ None); } diff --git a/codex-rs/tui/src/app/footer_selection.rs b/codex-rs/tui/src/app/footer_selection.rs index aaeb277303..411a476707 100644 --- a/codex-rs/tui/src/app/footer_selection.rs +++ b/codex-rs/tui/src/app/footer_selection.rs @@ -27,8 +27,7 @@ impl App { let refresh = matches!(event, TuiEvent::Mouse(mouse) if self.chat_widget.rendered_selection.borrow().needs_refresh(*mouse)); if refresh { - let size = tui.prepare_draw_size()?; - self.render_owned_transcript(tui, size)?; + self.refresh_owned_transcript(tui)?; } let action = { let mut selection = self.chat_widget.rendered_selection.borrow_mut(); diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index fa4c42782f..da20829fac 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -1,4 +1,6 @@ //! Compose the owned transcript above the composer and route their selection gestures. +//! Painting records the screen regions used by selection and pointer input. +//! Input that depends on those regions refreshes the layout before using them. //! Reserve a cleared row below activity and previews, immediately above the composer. //! Slash suggestions overlay already-painted rows so opening or closing them leaves transcript //! geometry unchanged. Turn tips remain outside transcript history; their painted rows are selectable. @@ -6,6 +8,10 @@ use super::turn_tips::TipSurface; use super::*; +use crate::bottom_pane::CommandPopupPlacement; +use crate::bottom_pane::ComposerRenderOptions; +use crate::custom_terminal::Frame; +use crate::empty_state_animation::ComposerState; use crate::history_cell::HistoryRenderMode; use crate::keymap::KeymapContext; use crate::keymap::bindings_for_action; @@ -13,9 +19,81 @@ use crate::keymap::configured_binding_for_action; use crate::keymap::keymap_action_ids; use crate::motion::MotionMode; use crate::pager_overlay::TranscriptHistoryState; +use crate::terminal_hyperlinks::HyperlinkLine; +use crate::transcript_image::ImagePlacement; +use crate::transcript_view::TranscriptView; use crate::transcript_view::ViewAction; +use crate::turn_tip::TurnTip; use ratatui::widgets::Widget; +/// Remember the last conversation bounds and neighboring content for input-triggered redraws. +#[derive(Default)] +pub(super) struct ConversationFrame { + inset: Option, + /// A dialog can obscure neighboring content in the terminal's completed buffer. + behind_dialog: Option, + caller_images: Vec, +} + +pub(super) struct PreparedConversation { + area: Rect, + transcript_width: u16, + composer: Option, + composer_hint: Option, + latest_navigation: &'static str, + motion: MotionMode, + now: Instant, + turn_tip: Option<(TipSurface, TurnTip)>, +} + +/// The composer bounds and follow-up work from a successful draw. +/// Acknowledge a tip only if it was visible in the frame that was presented. +#[derive(Default)] +pub(super) struct PaintedConversation { + image_range: std::ops::Range, + bottom_area: Rect, + shown_tip: Option, + needs_redraw: bool, + /// Delays requested by copy feedback, the empty-state animation, and the transcript. + delayed_frames: [Option; 3], +} + +impl PreparedConversation { + /// Paint the conversation foreground before a centered dialog takes over the cursor. + fn paint_foreground( + &self, + frame: &mut Frame<'_>, + chat_widget: &ChatWidget, + view: &TranscriptView, + bottom: &dyn Renderable, + bottom_area: Rect, + ) { + { + let mut selection = chat_widget.rendered_selection.borrow_mut(); + selection.register_layout(chat_widget.take_warning_body()); + if let Some((_, tip)) = &self.turn_tip + && let Some(area) = tip.rendered.get() + { + selection.register(area, /*invalidate*/ false); + } + selection.render(frame.buffer); + } + chat_widget.note_rendered_width(self.area.width); + if let Some(position) = bottom.cursor_pos(bottom_area) + && self.area.contains(position.into()) + { + frame.set_cursor_style(bottom.cursor_style(bottom_area)); + frame.set_cursor_position(position); + } + if chat_widget.no_modal_or_popup_active() { + frame.image_placements.extend(view.image_placements()); + if let Some(previews) = &chat_widget.composer_images { + frame.image_placements.extend(previews.placements.take()); + } + } + } +} + impl App { /// Copy draft selections before key shortcuts, or after mouse layout has been refreshed. pub(super) fn handle_composer_copy_event( @@ -115,6 +193,49 @@ impl App { tui: &mut tui::Tui, screen_size: Size, ) -> Result { + let frames = tui.frame_requester(); + let prepared = + self.prepare_conversation(screen_size.into(), self.conversation_frame.inset, &frames); + // An input refresh opens a fresh terminal buffer. Carry the outer owner's + // completed cells through it when only the conversation is being redrawn. + let backdrop = self.conversation_frame.inset.map(|_| { + self.conversation_frame + .behind_dialog + .take() + .unwrap_or_else(|| tui.terminal.previous_buffer().clone()) + }); + let mut painted = PaintedConversation::default(); + tui.draw(screen_size.height, |frame| { + if let Some(backdrop) = backdrop { + let viewport = frame.area(); + frame.image_placements.extend( + self.conversation_frame + .caller_images + .drain(..) + .filter(|placement| { + viewport.intersection(placement.area) == placement.area + }), + ); + for (pos, cell) in backdrop.area.positions().zip(backdrop.content) { + if viewport.contains(pos) && !prepared.area.contains(pos) { + frame.buffer[pos] = cell; + } + } + } + painted = self.paint_conversation(frame, &prepared); + self.paint_centered_dialog(frame); + })?; + self.finish_conversation(tui, painted) + } + + pub(super) fn prepare_conversation( + &mut self, + viewport: Rect, + conversation_inset: Option, + frames: &tui::FrameRequester, + ) -> PreparedConversation { + self.conversation_frame.inset = conversation_inset; + let area = self.conversation_area(viewport); self.chat_widget.sync_warnings(&self.transcript_cells); let motion = MotionMode::from_animations_enabled( self.local_settings.tui.animations && self.local_settings.tui.effects.shimmer, @@ -125,8 +246,11 @@ impl App { } else { "esc latest" }; - self.sync_owned_transcript(screen_size.width); - let transcript_width = self.chat_widget.history_wrap_width(screen_size.width); + self.sync_owned_transcript(area.width); + let transcript_width = self + .chat_widget + .history_wrap_width(area.width) + .min(area.width); let composer_hint = if self .chat_widget .rendered_selection @@ -143,256 +267,277 @@ impl App { self.composer_hint(transcript_width) }; let now = Instant::now(); - let turn_tip = self.turn_tip(transcript_width, now, &tui.frame_requester()); - let working_tip = turn_tip - .as_ref() - .filter(|(surface, _)| *surface == TipSurface::Working) - .map(|(_, tip)| tip); - let completion_tip = turn_tip - .as_ref() - .filter(|(surface, _)| *surface == TipSurface::Completion) - .map(|(_, tip)| tip); - let mut composer_gap = (!self.chat_widget.has_active_view() - && !self.chat_widget.is_external_writer_view()) - .then(crate::bottom_pane::ComposerGap::default); - let mut prompt_footer = - self.prompt_navigation_footer(screen_size.width.saturating_sub(/*rhs*/ 2)); - let chat_widget = &self.chat_widget; + let turn_tip = self.turn_tip(transcript_width, now, frames); let view = &mut self.transcript_view; - let active_key = chat_widget.active_cell_transcript_key(); view.sync_history_tail(&self.transcript_cells); view.prepare_width(transcript_width); if view.advance_search(&self.transcript_cells) { - tui.frame_requester().schedule_frame(); + frames.schedule_frame(); } - let footer_area = crate::bottom_pane::inset_footer_hint_area(Rect::new( - /*x*/ 0, - /*y*/ 0, - screen_size.width, - /*height*/ 1, - )); - let footer = view.footer_with_navigation(footer_area.width, motion, latest_navigation); - // Cap the whole composer (including padding and hints) at two-thirds of the screen, - // but allow at least 8 rows on small screens, without exceeding the screen itself. - let max_composer_height = ((u32::from(screen_size.height) * 2 / 3) as u16) + PreparedConversation { + area, + transcript_width, + composer, + composer_hint, + latest_navigation, + motion, + now, + turn_tip, + } + } + + pub(super) fn paint_conversation( + &mut self, + frame: &mut Frame<'_>, + prepared: &PreparedConversation, + ) -> PaintedConversation { + let PreparedConversation { + area, + transcript_width, + composer, + latest_navigation, + motion, + now, + .. + } = *prepared; + let (working_tip, completion_tip) = match prepared.turn_tip.as_ref() { + Some((TipSurface::Working, tip)) => (Some(tip), None), + Some((TipSurface::Completion, tip)) => (None, Some(tip)), + None => (None, None), + }; + let mut composer_gap = (!self.chat_widget.has_active_view() + && !self.chat_widget.is_external_writer_view()) + .then(crate::bottom_pane::ComposerGap::default); + let prompt_footer = self.prompt_navigation_footer(area.width.saturating_sub(/*rhs*/ 2)); + let chat_widget = &self.chat_widget; + let view = &mut self.transcript_view; + let active_key = chat_widget.active_cell_transcript_key(); + let footer_width = crate::bottom_pane::inset_footer_hint_area(area).width; + // Measure the composer to find the space available for transcript rows. + // Painting those rows tells us whether newer activity is still hidden, + // which can change the footer hint and the composer's measured height. + let footer = view.footer_with_navigation(footer_width, motion, latest_navigation); + // Allow up to two-thirds of the pane, or eight rows on short panes when they fit. + let max_composer_height = ((u32::from(area.height) * 2 / 3) as u16) .max(8) - .min(screen_size.height); - let bottom = - chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { - max_height: Some(max_composer_height), - footer: prompt_footer.as_ref().or(footer.as_ref()), - command_popup_placement: if view.has_active_interaction() { - crate::bottom_pane::CommandPopupPlacement::Hidden - } else { - crate::bottom_pane::CommandPopupPlacement::Overlay - }, - composer_gap: composer_gap.as_ref(), - working_tip, - ..Default::default() - }); - let dashboard_visible = chat_widget + .min(area.height); + let options = |interacting| ComposerRenderOptions { + max_height: Some(max_composer_height), + overlay_top: area.top(), + command_popup_placement: if interacting { + CommandPopupPlacement::Hidden + } else { + CommandPopupPlacement::Overlay + }, + working_tip, + ..Default::default() + }; + let bottom = chat_widget.bottom_pane_renderable(ComposerRenderOptions { + footer: prompt_footer.as_ref().or(footer.as_ref()), + composer_gap: composer_gap.as_ref(), + ..options(view.has_active_interaction()) + }); + let agents_overview_present = chat_widget .selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID) .is_some(); - let bottom_height = if dashboard_visible { - screen_size.height - } else { - bottom - .desired_height(screen_size.width) - .min(screen_size.height) + let measure_bottom = |bottom: &dyn Renderable| { + if agents_overview_present { + area.height + } else { + bottom.desired_height(area.width).min(area.height) + } }; + let bottom_height = measure_bottom(&bottom); drop(bottom); - let available = screen_size.height.saturating_sub(bottom_height); + let rows_above_composer = area.height - bottom_height; let mut bottom_area = Rect::new( - /*x*/ 0, - screen_size.height.saturating_sub(bottom_height), - screen_size.width, + area.x, + area.bottom().saturating_sub(bottom_height), + area.width, bottom_height, ); - let mut rendered_cursor = None; - let mut footer_height_changed = false; - let mut feedback_tick = None; - let mut blossom_tick = None; - let mut transcript_bottom = available.saturating_sub(u16::from(composer_gap.is_none())); - tui.draw(screen_size.height, |frame| { - chat_widget.rendered_selection.borrow_mut().areas.clear(); - ratatui::widgets::Clear.render( - Rect::new(/*x*/ 0, /*y*/ 0, screen_size.width, available), - frame.buffer, - ); - let mut completion_tip_area = view.render_with_turn_tip_space( - Rect::new( - /*x*/ 0, - /*y*/ 0, - transcript_width, - transcript_bottom, - ), + let mut transcript_area = Rect::new( + area.x, + area.y, + transcript_width, + rows_above_composer.saturating_sub(u16::from(composer_gap.is_none())), + ); + chat_widget.rendered_selection.borrow_mut().areas.clear(); + ratatui::widgets::Clear.render(area, frame.buffer); + let mut completion_tip_area = view.render_with_turn_tip_space( + transcript_area, + frame.buffer, + &self.transcript_cells, + completion_tip, + ); + if let Some(gap) = composer_gap.as_mut() { + gap.needs_separator = rows_above_composer > 1 + // Keep the tip under the pointer when selection hints or copy feedback appear. + && prepared.turn_tip.is_none() + && chat_widget.no_modal_or_popup_active() + && view.composer_gap_has_content(transcript_width, prepared.composer_hint.as_ref(), now); + } + // Refresh the hint using the rows just painted. + let mut footer = prompt_footer + .or_else(|| view.footer_with_navigation(footer_width, motion, latest_navigation)); + if let Some(footer) = footer.as_mut().filter(|footer| footer.is_interactive) + && let Some(items) = self + .key_chord_matcher + .pending_hint_items(&self.keymap.chords, footer_width) + && let Some(progress) = footer.text.lines.last_mut() + { + *progress = crate::bottom_pane::footer_hint_items_line(&items); + } + let bottom = chat_widget.bottom_pane_renderable(ComposerRenderOptions { + footer: footer.as_ref(), + composer_gap: composer_gap.as_ref(), + ..options(view.has_active_interaction()) + }); + let updated_bottom_height = measure_bottom(&bottom); + let mut needs_redraw = updated_bottom_height != bottom_height; + if needs_redraw && composer_gap.as_ref().is_some_and(|gap| gap.needs_separator) { + bottom_area.height = updated_bottom_height; + bottom_area.y = area.bottom().saturating_sub(bottom_area.height); + // If the separator changes the composer height, repaint the transcript now + // so its follow control lines up with the gap drawn below. Other height + // changes request another frame after this draw. + ratatui::widgets::Clear.render(bottom_area, frame.buffer); + transcript_area.height = bottom_area.y - area.y; + completion_tip_area = view.render_with_turn_tip_space( + transcript_area, frame.buffer, &self.transcript_cells, completion_tip, ); - if let Some(gap) = composer_gap.as_mut() { - gap.needs_separator = available > 1 - // Keep the tip under the pointer when selection hints or copy feedback appear. - && turn_tip.is_none() - && chat_widget.no_modal_or_popup_active() - && view.composer_gap_has_content(transcript_width, composer_hint.as_ref(), now); - } - // Rendering resolves whether new activity is still hidden. Paint that result in - // this frame so a revision change cannot flash a stale activity hint. - let mut footer = - view.footer_with_navigation(footer_area.width, motion, latest_navigation); - if let Some(footer) = prompt_footer - .as_mut() - .or(footer.as_mut()) - .filter(|footer| footer.is_interactive) - && let Some(items) = self - .key_chord_matcher - .pending_hint_items(&self.keymap.chords, footer_area.width) - && let Some(progress) = footer.text.lines.last_mut() - { - *progress = crate::bottom_pane::footer_hint_items_line(&items); - } - let bottom = - chat_widget.bottom_pane_renderable(crate::bottom_pane::ComposerRenderOptions { - max_height: Some(max_composer_height), - footer: prompt_footer.as_ref().or(footer.as_ref()), - command_popup_placement: if view.has_active_interaction() { - crate::bottom_pane::CommandPopupPlacement::Hidden - } else { - crate::bottom_pane::CommandPopupPlacement::Overlay - }, - composer_gap: composer_gap.as_ref(), - working_tip, - ..Default::default() - }); - footer_height_changed = !dashboard_visible - && bottom - .desired_height(screen_size.width) - .min(screen_size.height) - != bottom_height; - if footer_height_changed && composer_gap.as_ref().is_some_and(|gap| gap.needs_separator) - { - bottom_area.height = bottom - .desired_height(screen_size.width) - .min(screen_size.height); - bottom_area.y = screen_size.height.saturating_sub(bottom_area.height); - // Resolve controls with the compact viewport first, then make room for - // their separator. Resizing must not preserve a stale return control. - ratatui::widgets::Clear.render(bottom_area, frame.buffer); - transcript_bottom = bottom_area.y; - completion_tip_area = view.render_with_turn_tip_space( - Rect::new( - /*x*/ 0, - /*y*/ 0, - transcript_width, - transcript_bottom, - ), - frame.buffer, - &self.transcript_cells, - completion_tip, - ); - footer_height_changed = false; - } - blossom_tick = chat_widget - .empty_state_animation - .borrow_mut() - .render_first_screen( - Rect { - width: screen_size.width, - ..view.remaining_area() - }, - frame.buffer, - composer, - MotionMode::from_animations_enabled(self.local_settings.tui.animations), - ); - bottom.render(bottom_area, frame.buffer); - if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) { - tip.render(area, frame.buffer); - } - let follow_area = if let Some(gap) = composer_gap.as_ref() { - Some(Rect { - width: transcript_width, - ..gap.area.get() - }) - } else { - (available > 0).then(|| { - Rect::new( - /*x*/ 0, - available - 1, - transcript_width, - /*height*/ 1, - ) - }) - } - .filter(|_| chat_widget.no_modal_or_popup_active()); - feedback_tick = - view.render_composer_gap(follow_area, composer_hint.as_ref(), frame.buffer, now); - chat_widget - .rendered_selection - .borrow_mut() - .register_layout(chat_widget.take_warning_body()); - if let Some((_, tip)) = &turn_tip - && let Some(area) = tip.rendered.get() - { - chat_widget - .rendered_selection - .borrow_mut() - .register(area, /*invalidate*/ false); - } - chat_widget - .rendered_selection - .borrow_mut() - .render(frame.buffer); - chat_widget.note_rendered_width(screen_size.width); - let dialog = chat_widget.centered_dialog(); - let (foreground, foreground_area): (&dyn Renderable, Rect) = - if let Some(dialog) = &dialog { - let area = Rect::new( - /*x*/ 0, - /*y*/ 0, - screen_size.width, - screen_size.height, - ); - dialog.render(area, frame.buffer); - (dialog, area) - } else { - (&bottom, bottom_area) - }; - rendered_cursor = foreground.cursor_pos(foreground_area); - if let Some(position) = rendered_cursor { - frame.set_cursor_style(foreground.cursor_style(foreground_area)); - frame.set_cursor_position(position); - } - if chat_widget.no_modal_or_popup_active() { - frame.image_placements = view.image_placements(); - if let Some(previews) = &chat_widget.composer_images { - frame.image_placements.extend(previews.placements.take()); - } - } - })?; - if let Some((surface, tip)) = &turn_tip - && tip.rendered.get().is_some() - { - self.turn_tips.acknowledge(*surface); + needs_redraw = false; } - if footer_height_changed { - tui.frame_requester().schedule_frame(); + let blossom_tick = chat_widget + .empty_state_animation + .borrow_mut() + .render_first_screen( + area, + Rect { + width: area.width, + ..view.remaining_area() + }, + frame.buffer, + composer, + MotionMode::from_animations_enabled(self.local_settings.tui.animations), + ); + bottom.render(bottom_area, frame.buffer); + if let (Some(tip), Some(area)) = (completion_tip, completion_tip_area) { + tip.render(area, frame.buffer); } - if let Some(delay) = feedback_tick { - tui.frame_requester().schedule_frame_in(delay); - } - if let Some(delay) = blossom_tick { - tui.frame_requester().schedule_frame_in(delay); + let follow_area = if let Some(gap) = composer_gap.as_ref() { + Some(Rect { + width: transcript_width, + ..gap.area.get() + }) + } else { + (bottom_area.y > area.y).then(|| { + Rect::new( + area.x, + bottom_area.y - 1, + transcript_width, + /*height*/ 1, + ) + }) } + .filter(|_| chat_widget.no_modal_or_popup_active()); + let feedback_tick = view.render_composer_gap( + follow_area, + prepared.composer_hint.as_ref(), + frame.buffer, + now, + ); + let first_image = frame.image_placements.len(); + prepared.paint_foreground(frame, chat_widget, view, &bottom, bottom_area); let animating = view.is_following() && active_key.is_some_and(|key| key.animation_tick.is_some()); let loading = view.is_loading_history() && motion == MotionMode::Animated; - if animating || loading { - tui.frame_requester() - .schedule_frame_in(Duration::from_millis(/*millis*/ 50)); + PaintedConversation { + image_range: first_image..frame.image_placements.len(), + bottom_area, + shown_tip: prepared + .turn_tip + .as_ref() + .and_then(|(surface, tip)| tip.rendered.get().map(|_| *surface)), + needs_redraw, + delayed_frames: [ + feedback_tick, + blossom_tick, + (animating || loading).then_some(Duration::from_millis(50)), + ], + } + } + + /// Acknowledge only a presented frame. Keep neighboring images for the next input refresh. + pub(super) fn finish_conversation( + &mut self, + tui: &mut tui::Tui, + painted: PaintedConversation, + ) -> Result { + if self.conversation_frame.behind_dialog.is_none() { + self.conversation_frame.caller_images.clear(); + if self.conversation_frame.inset.is_some() { + self.conversation_frame.caller_images.extend( + tui.retained_images + .iter() + .enumerate() + .filter(|(index, _)| !painted.image_range.contains(index)) + .map(|(_, placement)| placement.registration()), + ); + } + } + if let Some(surface) = painted.shown_tip { + self.turn_tips.acknowledge(surface); + } + let frames = tui.frame_requester(); + if painted.needs_redraw { + frames.schedule_frame(); + } + for delay in painted.delayed_frames.into_iter().flatten() { + frames.schedule_frame_in(delay); } self.refresh_link_hover(tui)?; - Ok(bottom_area) + Ok(painted.bottom_area) + } + + pub(super) fn paint_centered_dialog(&mut self, frame: &mut Frame<'_>) { + self.conversation_frame.behind_dialog = None; + let Some(dialog) = self.chat_widget.centered_dialog() else { + return; + }; + let area = frame.area(); + if self.conversation_frame.inset.is_some() { + self.conversation_frame.behind_dialog = Some(frame.buffer.clone()); + // The conversation omits its own images while a dialog is open. Keep the + // caller's registrations so dismissal can restore graphics behind the dialog. + self.conversation_frame.caller_images = std::mem::take(&mut frame.image_placements); + let covered = dialog.area(area); + frame.image_placements.extend( + self.conversation_frame + .caller_images + .iter() + .filter(|placement| !covered.intersects(placement.area)) + .map(ImagePlacement::registration), + ); + } + dialog.render(area, frame.buffer); + frame.cursor_position = dialog.cursor_pos(area).map(Into::into); + frame.set_cursor_style(dialog.cursor_style(area)); + } + + pub(super) fn conversation_area(&self, viewport: Rect) -> Rect { + self.conversation_frame + .inset + .map_or(viewport, |area| area.intersection(viewport)) + } + + pub(super) fn refresh_owned_transcript(&mut self, tui: &mut tui::Tui) -> Result<()> { + let size = tui.prepare_draw_size()?; + self.render_owned_transcript(tui, size)?; + Ok(()) } /// Keep modal input ownership while allowing selection, copying, and disclosure in the visible transcript. @@ -458,8 +603,7 @@ impl App { if let TuiEvent::Mouse(mouse) = event { let composer_ready = self.chat_widget.prepare_composer_mouse(*mouse); if mouse.kind != crossterm::event::MouseEventKind::Moved { - let size = tui.prepare_draw_size()?; - self.render_owned_transcript(tui, size)?; + self.refresh_owned_transcript(tui)?; } if self.chat_widget.no_modal_or_popup_active() && self @@ -529,8 +673,7 @@ impl App { _ => true, }) { - let size = tui.prepare_draw_size()?; - self.render_owned_transcript(tui, size)?; + self.refresh_owned_transcript(tui)?; } if matches!(event, TuiEvent::Key(_)) || matches!(event, TuiEvent::Mouse(mouse) if matches!(mouse.kind, crossterm::event::MouseEventKind::Down(_))) diff --git a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs index 552bee01b6..5a54b2d48a 100644 --- a/codex-rs/tui/src/app/owned_transcript_follow_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_follow_tests.rs @@ -62,6 +62,35 @@ async fn follow_control_click_preserves_draft_caret_and_composer_geometry() -> R tui.screen_size_for_event(&TuiEvent::Resize(size))?; app.render_owned_transcript(&mut tui, size)?; assert_eq!(tui.terminal.last_known_cursor_pos, cursor); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let scrolled = super::tests::buffer_text(buffer); + let first_row = scrolled.lines().next().expect("visible transcript row"); + assert!(first_row.contains("transcript row")); + let area = Rect::new( + /*x*/ 4, /*y*/ 1, /*width*/ 30, /*height*/ 10, + ); + let frames = tui.frame_requester(); + let mut painted = Default::default(); + tui.draw(size.height, |frame| { + let prepared = app.prepare_conversation(frame.area(), Some(area), &frames); + painted = app.paint_conversation(frame, &prepared); + })?; + app.finish_conversation(&mut tui, painted)?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let resized = super::tests::buffer_text(buffer); + assert_eq!( + resized.lines().nth(usize::from(area.y)).map(str::trim), + Some(first_row.trim()) + ); + assert!(resized.contains("draft stays here")); + tui.screen_size_for_event(&TuiEvent::Resize(size))?; + let mut painted = Default::default(); + tui.draw(size.height, |frame| { + let prepared = + app.prepare_conversation(frame.area(), /*conversation_inset*/ None, &frames); + painted = app.paint_conversation(frame, &prepared); + })?; + app.finish_conversation(&mut tui, painted)?; for height in [24, 9, 8, 12] { let resized = Size::new(width, height); tui.screen_size_for_event(&TuiEvent::Resize(resized))?; diff --git a/codex-rs/tui/src/app/owned_transcript_input_tests.rs b/codex-rs/tui/src/app/owned_transcript_input_tests.rs index 075a625662..e622c90c9b 100644 --- a/codex-rs/tui/src/app/owned_transcript_input_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_input_tests.rs @@ -644,6 +644,40 @@ async fn enter_preserves_search_backtrack_and_modal_ownership_while_scrolled() - assert!(!app.handle_owned_transcript_event(&mut tui, &mut server, &enter)?); assert!(!app.transcript_view.is_following()); + let outer = tui.terminal.size()?; + let area = Rect::new( + /*x*/ 48, /*y*/ 2, /*width*/ 26, /*height*/ 14, + ); + let frames = tui.frame_requester(); + let mut painted = Default::default(); + tui.draw(outer.height, |frame| { + for cell in &mut frame.buffer.content { + cell.set_char('.'); + } + let prepared = app.prepare_conversation(frame.area(), Some(area), &frames); + painted = app.paint_conversation(frame, &prepared); + app.paint_centered_dialog(frame); + assert!(frame.cursor_position.is_none()); + })?; + app.finish_conversation(&mut tui, painted)?; + let dialog = screen(&tui); + let title = dialog + .lines() + .find(|line| line.contains("Enable subagents?")) + .expect("confirmation title"); + assert!(title.find("Enable subagents?").unwrap() < usize::from(area.x)); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + assert!(!app.chat_widget.has_active_modal()); + assert!(!app.transcript_view.is_following()); + + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + for pos in buffer.area.positions().filter(|pos| !area.contains(*pos)) { + assert_eq!(buffer[pos].symbol(), "."); + } + tui.set_owned_screen(/*owned*/ false)?; server.shutdown().await?; Ok(()) diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 2317862c29..422d53ff54 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -536,6 +536,157 @@ async fn owned_details_keep_the_composer_cursor_and_screen() -> Result<()> { assert!(rendered.contains("First prompt")); assert!(rendered.contains("Second prompt")); assert!(rendered.contains("preserved draft")); + app.transcript_cells + .push(Arc::new(history_cell::AgentMarkdownCell::new( + "For the complete instructions see the [manual](https://example.com/docs).".into(), + app.config.cwd.as_path(), + ))); + let area = Rect::new( + /*x*/ 5, /*y*/ 2, /*width*/ 26, /*height*/ 14, + ); + let outer = Size::new(/*width*/ 36, /*height*/ 18); + tui.screen_size_for_event(&TuiEvent::Resize(outer))?; + let dir = tempfile::tempdir()?; + let path = dir.path().join("preview.png"); + image::RgbaImage::from_pixel(8, 16, image::Rgba([80, 30, 20, 255])).save(&path)?; + let protocol = crate::pets::ImageProtocol::KittyLocalFile; + let image = Arc::new( + crate::transcript_image::TranscriptImage::load(&path, area.width, protocol).unwrap(), + ); + let neighbor = Rect::new(/*x*/ 4, /*y*/ 9, image.columns, image.rows); + let presented = |tui: &tui::Tui| { + tui.retained_images + .iter() + .map(|placement| { + assert!(placement.file.as_ref().unwrap().path().exists()); + placement.area + }) + .collect::>() + }; + let frames = tui.frame_requester(); + let mut painted = Default::default(); + tui.draw(outer.height, |frame| { + for cell in &mut frame.buffer.content { + cell.set_char('·'); + } + let prepared = app.prepare_conversation(frame.area(), Some(area), &frames); + painted = app.paint_conversation(frame, &prepared); + frame + .image_placements + .push(crate::transcript_image::ImagePlacement { + image, + area: neighbor, + first_row: 0, + file: None, + }); + })?; + app.finish_conversation(&mut tui, painted)?; + assert_eq!(presented(&tui), [neighbor]); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + let screen = crate::terminal_hyperlinks::strip_osc8(&buffer_text(buffer)); + let caret = tui.terminal.last_known_cursor_pos; + assert!(area.contains(caret)); + assert_eq!(buffer[(caret.x - 1, caret.y)].symbol(), "t"); + let (column, row) = screen + .lines() + .enumerate() + .find_map(|(row, line)| { + let prefix = line.split_once("manual")?.0; + Some((prefix.chars().count() as u16, row as u16)) + }) + .expect("visible wrapped link"); + assert_eq!( + app.transcript_view.link_at(column, row).as_deref(), + Some("https://example.com/docs") + ); + assert_eq!(app.transcript_view.link_at(area.x - 1, row), None); + let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + app.local_settings.tui.copy_on_select = CopyOnSelect::Never; + for (kind, x) in [ + (Down(Left), column), + (Drag(Left), column + 6), + (Up(Left), column + 6), + ] { + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Mouse(MouseEvent { + kind, + column: x, + row, + modifiers: KeyModifiers::NONE, + }), + ) + .await?; + assert_eq!(presented(&tui), [neighbor], "after {kind:?}"); + } + assert_eq!( + app.transcript_view + .selected_text(&app.transcript_cells) + .as_deref(), + Some("manual") + ); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + assert_eq!(presented(&tui), [neighbor]); + assert_eq!( + app.transcript_view.link_at(column, row).as_deref(), + Some("https://example.com/docs") + ); + let guard = Arc::new(crate::copy_input_guard::CopyInputGuard( + app.app_event_tx.clone(), + )); + app.handle_event( + &mut tui, + &mut server, + AppEvent::SelectTranscriptCopy { guard }, + ) + .await?; + assert_eq!(presented(&tui), [neighbor]); + assert_eq!( + app.transcript_view.link_at(column, row).as_deref(), + Some("https://example.com/docs") + ); + assert_eq!(tui.terminal.last_known_cursor_pos, caret); + let buffer = crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal); + for pos in Rect::from(outer) + .positions() + .filter(|pos| !area.contains(*pos)) + { + assert_eq!(buffer[pos].symbol(), "·"); + } + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + app.chat_widget + .set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(protocol)); + app.transcript_cells + .push(Arc::new(history_cell::new_image_generation_call( + "image-call".to_string(), + "completed", + Some("Preview".to_string()), + Some(AbsolutePathBuf::try_from(path)?), + ))); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + let images = presented(&tui); + assert_eq!(images.len(), 2); + assert_eq!(images[0], neighbor); + assert_eq!(area.intersection(images[1]), images[1]); + app.chat_widget.open_feature_enable_prompt(Feature::Collab); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + assert!(presented(&tui).is_empty()); + app.transcript_cells.pop(); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())) + .await?; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await?; + assert_eq!(presented(&tui), [neighbor]); + server.shutdown().await?; + insta::assert_snapshot!( + "conversation_in_offset_rect", + normalize_snapshot_paths(screen) + ); assert!(app.handle_owned_backtrack_event( &mut tui, &TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap new file mode 100644 index 0000000000..58eceddd64 --- /dev/null +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__owned_transcript__tests__conversation_in_offset_rect.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/app/owned_transcript_tests.rs +expression: normalize_snapshot_paths(screen) +--- +···································· +···································· +····· ····· +·····› Second prompt ····· +····· ····· +····· ····· +·····• For the complete ····· +····· instructions see the ····· +····· manual (https:// ····· +····· example.com/docs). ····· +····· ····· +····· ····· +·····› preserved draft ····· +····· ····· +····· gpt-test default · /tmp…····· +····· ····· +···································· +···································· diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index ac92bc0176..fca1ef21b6 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -999,6 +999,7 @@ See the Codex keymap documentation for supported actions and examples." native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), + conversation_frame: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index a0efb33101..3d1c608ca1 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -48,6 +48,7 @@ pub(crate) async fn make_test_app() -> Box { native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), + conversation_frame: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 24b616d56f..5c7f76f943 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6513,6 +6513,7 @@ async fn make_test_app() -> Box { native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), + conversation_frame: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, @@ -6639,6 +6640,7 @@ pub(super) async fn make_test_app_with_channels() -> ( native_history: Default::default(), turn_tips: Default::default(), transcript_view: Default::default(), + conversation_frame: Default::default(), last_rendered_history_tail: None, last_thread_usage_status_cell: None, pending_thread_usage_history_refresh: false, diff --git a/codex-rs/tui/src/app/turn_tips_tests.rs b/codex-rs/tui/src/app/turn_tips_tests.rs index 8d79bdf06a..d7f90603d0 100644 --- a/codex-rs/tui/src/app/turn_tips_tests.rs +++ b/codex-rs/tui/src/app/turn_tips_tests.rs @@ -248,7 +248,13 @@ async fn turn_tip_placements_and_completion_barrier() -> Result<()> { } } assert_eq!(saw_barrier, !working); - app.render_owned_transcript(&mut tui, size)?; + let frames = tui.frame_requester(); + let mut painted = Default::default(); + tui.draw(height, |frame| { + let prepared = app.prepare_conversation(frame.area(), Some(size.into()), &frames); + painted = app.paint_conversation(frame, &prepared); + })?; + app.finish_conversation(&mut tui, painted)?; let screen = crate::chatwidget::tests::helpers::normalize_completion_timestamps( app.transcript_cells.last().unwrap().as_ref(), buffer_text(crate::custom_terminal::test_support::last_rendered_buffer( diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e7769e3139..bcc24561d8 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -4646,11 +4646,14 @@ impl ChatComposer { && options.command_popup_placement != CommandPopupPlacement::Hidden { let popup_rect = if options.command_popup_placement == CommandPopupPlacement::Overlay { + let rows_above_composer = composer_rect + .y + .saturating_sub(options.overlay_top.max(buf.area.y)); let height = self .popups .active .required_height(composer_rect.width, /*footer_total_height*/ 0) - .min(composer_rect.y.saturating_sub(buf.area.y)); + .min(rows_above_composer); // Command and unified mention menus can render in a single row. let height = if matches!( self.popups.active, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs index a130b91b7e..85ba7e945e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/footer_state.rs @@ -82,6 +82,8 @@ pub(crate) struct ComposerRenderOptions<'a> { pub(crate) textarea_right_reserve: u16, /// Keep configured status below the composer while hints occupy the final row. pub(crate) separate_status_line: bool, + /// Topmost frame row available for suggestions overlaying the transcript. + pub(crate) overlay_top: u16, pub(crate) command_popup_placement: CommandPopupPlacement, pub(crate) footer: Option<&'a TranscriptFooter>, } diff --git a/codex-rs/tui/src/bottom_pane/view_stack.rs b/codex-rs/tui/src/bottom_pane/view_stack.rs index e300dfc83f..d660347ae9 100644 --- a/codex-rs/tui/src/bottom_pane/view_stack.rs +++ b/codex-rs/tui/src/bottom_pane/view_stack.rs @@ -72,9 +72,15 @@ impl Renderable for ViewStack<'_> { /// A dialog placed within the supplied viewport, without taking over its backdrop. pub(crate) struct CenteredView<'a>(pub(crate) &'a dyn BottomPaneView); +impl CenteredView<'_> { + pub(crate) fn area(&self, viewport: Rect) -> Rect { + dialog_area(self.0, viewport) + } +} + impl Renderable for CenteredView<'_> { fn render(&self, area: Rect, buf: &mut Buffer) { - let dialog = dialog_area(self.0, area); + let dialog = self.area(area); Clear.render(dialog, buf); // A dialog can cover cached blank cells whose color the terminal did not retain. // Paint its whole surface; let wide text rendering still reset continuation cells. @@ -87,15 +93,16 @@ impl Renderable for CenteredView<'_> { } fn desired_height(&self, width: u16) -> u16 { - dialog_area(self.0, Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)).height + self.area(Rect::new(/*x*/ 0, /*y*/ 0, width, u16::MAX)) + .height } fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - self.0.cursor_pos(dialog_area(self.0, area)) + self.0.cursor_pos(self.area(area)) } fn cursor_style(&self, area: Rect) -> SetCursorStyle { - self.0.cursor_style(dialog_area(self.0, area)) + self.0.cursor_style(self.area(area)) } } diff --git a/codex-rs/tui/src/empty_state_animation.rs b/codex-rs/tui/src/empty_state_animation.rs index 8003d2df7f..535d66481d 100644 --- a/codex-rs/tui/src/empty_state_animation.rs +++ b/codex-rs/tui/src/empty_state_animation.rs @@ -192,6 +192,7 @@ impl EmptyStateAnimation { /// on clear, without restarting or scheduling animation. pub(crate) fn render_first_screen( &mut self, + screen: Rect, available: Rect, buffer: &mut Buffer, composer: Option, @@ -199,7 +200,6 @@ impl EmptyStateAnimation { ) -> Option { const STAGE_ROWS: u16 = 21; const MIN_STAGE_ROWS: u16 = 14; - let screen = buffer.area; let width = screen.width.saturating_sub(/*rhs*/ 4).min(MAX_COLUMNS); let height = width * STAGE_ROWS / MAX_COLUMNS; if motion == MotionMode::Reduced diff --git a/codex-rs/tui/src/empty_state_animation_tests.rs b/codex-rs/tui/src/empty_state_animation_tests.rs index 496116a5e1..909369818b 100644 --- a/codex-rs/tui/src/empty_state_animation_tests.rs +++ b/codex-rs/tui/src/empty_state_animation_tests.rs @@ -22,6 +22,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() { ); assert_eq!( animation.render_first_screen( + screen, free, &mut buffer, Some(ComposerState::Empty), @@ -39,6 +40,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() { // Content which reaches the center must win; it must not move the stage down. let free = Rect::new(screen.x, /*y*/ 20, screen.width, /*height*/ 24); animation.render_first_screen( + screen, free, &mut buffer, Some(ComposerState::Empty), @@ -50,6 +52,7 @@ fn first_screen_stage_ignores_notices_and_hides_instead_of_shrinking() { let screen = Rect::new(/*x*/ 7, /*y*/ 3, width, height); let mut buffer = Buffer::empty(screen); animation.render_first_screen( + screen, screen, &mut buffer, Some(ComposerState::Empty), @@ -75,7 +78,7 @@ fn first_screen_single_click_replays_the_visible_blossom() { }; let draw = |animation: &mut EmptyStateAnimation, buffer: &mut Buffer, composer| { buffer.reset(); - animation.render_first_screen(screen, buffer, composer, MotionMode::Animated) + animation.render_first_screen(screen, screen, buffer, composer, MotionMode::Animated) }; assert_eq!( draw(&mut animation, &mut buffer, Some(ComposerState::Empty)), @@ -193,6 +196,7 @@ fn first_screen_single_click_replays_the_visible_blossom() { // Disabling motion removes both the logo and its click target. buffer.reset(); animation.render_first_screen( + screen, screen, &mut buffer, Some(ComposerState::Empty), diff --git a/codex-rs/tui/src/startup_draft_layout.rs b/codex-rs/tui/src/startup_draft_layout.rs index da417ce52b..5ff03aa8ed 100644 --- a/codex-rs/tui/src/startup_draft_layout.rs +++ b/codex-rs/tui/src/startup_draft_layout.rs @@ -69,6 +69,7 @@ impl Renderable for OwnedStartupLayout<'_> { .flatten(); self.next_frame .set(self.pump.blossom.borrow_mut().render_first_screen( + area, Rect { y: header.bottom(), height: bottom.y.saturating_sub(header.bottom()), diff --git a/codex-rs/tui/src/transcript_image.rs b/codex-rs/tui/src/transcript_image.rs index d3f91705d3..aa4e621ee5 100644 --- a/codex-rs/tui/src/transcript_image.rs +++ b/codex-rs/tui/src/transcript_image.rs @@ -33,6 +33,18 @@ pub(crate) struct ImagePlacement { pub(crate) file: Option, } +impl ImagePlacement { + /// Register the same pixels for another frame. The terminal owns the transport file. + pub(crate) fn registration(&self) -> Self { + Self { + image: Arc::clone(&self.image), + area: self.area, + first_row: self.first_row, + file: None, + } + } +} + impl PartialEq for ImagePlacement { fn eq(&self, other: &Self) -> bool { Arc::ptr_eq(&self.image, &other.image)