From 695961ee88a2c84c0919814a4345b94a2d10f3ca Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Thu, 5 Nov 2026 05:37:38 +0000 Subject: [PATCH] Enable pets in fullscreen tmux panes with Kitty passthrough (#59580) ## Why Pets were unavailable in tmux even when fullscreen Codex could render Kitty images through passthrough. ## What changed - Enable pet images and picker previews in fullscreen tmux panes with Kitty passthrough, and update the unavailable notice to recommend fullscreen mode with `allow-passthrough all`. - Use pane-specific image IDs and virtual placements for pets and previews, reserving separate IDs from transcript images. Repaint underlying text when pet placeholders move or disappear. - Draw pets and previews when refreshing the fullscreen transcript, and consume preview areas once per render. ## Testing Extend the owned-screen lifecycle test to verify that preparing fullscreen enables the Kitty protocol in tmux and returning to scrollback disables it. Update `/pets` notice assertions and the popup snapshot. GitOrigin-RevId: 02c838ceb06abcbb45314e1d38ff4cde77221426 --- codex-rs/tui/src/app.rs | 31 +------ codex-rs/tui/src/app/owned_transcript.rs | 6 +- codex-rs/tui/src/app/pets.rs | 27 ++++++ codex-rs/tui/src/chatwidget/pets.rs | 2 +- ...tests__slash_pets_unavailable_tmux_40.snap | 12 +-- .../src/chatwidget/tests/slash_commands.rs | 9 +- codex-rs/tui/src/custom_terminal.rs | 14 +++- codex-rs/tui/src/pets/ambient.rs | 6 ++ codex-rs/tui/src/pets/image_protocol.rs | 37 ++++++++- codex-rs/tui/src/pets/mod.rs | 82 +++++++++---------- codex-rs/tui/src/pets/preview.rs | 7 +- codex-rs/tui/src/transcript_image.rs | 18 +--- codex-rs/tui/src/tui.rs | 29 ++++++- codex-rs/tui/src/tui/owned_screen_tests.rs | 11 +++ 14 files changed, 184 insertions(+), 107 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0435cd59a0..6d0ec6ae5c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1289,24 +1289,7 @@ impl App { { self.discard_startup_modal_input(tui)?; } - if self.chat_widget.ambient_pet_image_enabled() { - 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()), - ) { - self.handle_ambient_pet_image_render_error(tui, err)?; - } - } - if let Some(request) = self.chat_widget.pet_picker_preview_draw() { - if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) { - self.handle_pet_picker_preview_image_render_error(tui, err)?; - } - } else if self.chat_widget.should_clear_pet_picker_preview_image() - && let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None) - { - self.handle_pet_picker_preview_image_render_error(tui, err)?; - } + self.draw_pet_images(tui, screen_size, rendered_area)?; if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { self.chat_widget .set_external_editor_state(ExternalEditorState::Active); @@ -1374,14 +1357,8 @@ impl App { Ok(()) } - fn prepare_frame_images(&mut self, tui: &tui::Tui) -> Option { - let tmux_kitty = tui.is_owned_screen() && tui.terminal.tmux_kitty_image_base.is_some(); - let protocol = self - .chat_widget - .pet_image_support() - .protocol() - .or(tmux_kitty.then_some(crate::pets::ImageProtocol::Kitty)); - let protocol = protocol.filter(|_| { + fn prepare_frame_images(&mut self) -> Option { + let protocol = self.chat_widget.pet_image_support().protocol().filter(|_| { !self.app_server_target.uses_remote_workspace() && crate::projectless::has_only_local_environments(&self.environment_manager) }); @@ -1395,7 +1372,7 @@ impl App { if tui.is_owned_screen() { return self.render_owned_transcript(tui, screen_size); } - self.prepare_frame_images(tui); + self.prepare_frame_images(); self.chat_widget .empty_state_animation .borrow_mut() diff --git a/codex-rs/tui/src/app/owned_transcript.rs b/codex-rs/tui/src/app/owned_transcript.rs index 508d66e2e1..c48b731375 100644 --- a/codex-rs/tui/src/app/owned_transcript.rs +++ b/codex-rs/tui/src/app/owned_transcript.rs @@ -201,7 +201,7 @@ impl App { tui: &mut tui::Tui, screen_size: Size, ) -> Result { - self.transcript_view.image_protocol = self.prepare_frame_images(tui); + self.transcript_view.image_protocol = self.prepare_frame_images(); let frames = tui.frame_requester(); if self.conversation_frame.side_pane.is_some() { // Recompute our own inset on every frame, including the first narrow frame. @@ -581,8 +581,8 @@ impl App { 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(()) + let rendered_area = self.render_owned_transcript(tui, size)?; + self.draw_pet_images(tui, size, rendered_area) } /// Keep modal input ownership while allowing selection, copying, and disclosure in the visible transcript. diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index 4253fe9563..a39862d4f9 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -3,6 +3,33 @@ use super::*; impl App { + pub(super) fn draw_pet_images( + &mut self, + tui: &mut tui::Tui, + screen_size: Size, + rendered_area: Rect, + ) -> Result<()> { + if self.chat_widget.ambient_pet_image_enabled() { + 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()), + ) { + self.handle_ambient_pet_image_render_error(tui, err)?; + } + } + if let Some(request) = self.chat_widget.pet_picker_preview_draw() { + if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } + } else if self.chat_widget.should_clear_pet_picker_preview_image() + && let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None) + { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } + Ok(()) + } + pub(super) fn disable_ambient_pet_before_shutdown(&mut self, tui: &mut tui::Tui) -> Result<()> { self.chat_widget.disable_ambient_pet_for_session(); if let Err(clear_err) = tui.clear_ambient_pet_image() { diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs index aec83e79e9..a46b013bf9 100644 --- a/codex-rs/tui/src/chatwidget/pets.rs +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -138,7 +138,7 @@ impl ChatWidget { pub(crate) fn pet_picker_preview_draw(&self) -> Option { self.bottom_pane .selected_index_for_active_view(crate::pets::PET_PICKER_VIEW_ID)?; - let area = self.pet_picker_preview_state.area()?; + let area = self.pet_picker_preview_state.take_area()?; let request = self .pet_picker_preview_pet .as_ref()? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_unavailable_tmux_40.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_unavailable_tmux_40.snap index 2d2c99adac..bd47fb00b1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_unavailable_tmux_40.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_unavailable_tmux_40.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/slash_commands.rs -expression: "render_bottom_popup(&chat, width)" +assertion_line: 3212 +expression: "render_bottom_popup(&chat, 40)" --- Pets unavailable - Pets are disabled in tmux. Terminal - images don’t stay pane-local in tmux - and can corrupt scrollback or move - between panes. Run Codex outside - tmux to use pets. + Pets are unavailable in this tmux + pane. Use fullscreen Codex with + allow-passthrough all, or run Codex + outside tmux. › 1. Close diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 98273ce95a..be33b2e5f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -3219,7 +3219,7 @@ async fn slash_pets_in_tmux_shows_notice_and_preserves_draft() { .map(|lines| lines_to_single_string(lines)) .collect::>() .join("\n"); - assert!(rendered.contains("Pets are disabled in tmux.")); + assert!(rendered.contains("Pets are unavailable in this tmux pane.")); assert!(rendered.contains("outside tmux")); chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); @@ -3238,14 +3238,17 @@ async fn slash_pets_with_arg_on_unsupported_terminal_shows_notice_without_select chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); assert!(chat.bottom_pane.has_active_view()); - assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Pets are disabled in tmux.")); + assert!( + render_bottom_popup(&chat, /*width*/ 80) + .contains("Pets are unavailable in this tmux pane.") + ); let cells = drain_insert_history_transcript(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::>() .join("\n"); - assert!(rendered.contains("Pets are disabled in tmux.")); + assert!(rendered.contains("Pets are unavailable in this tmux pane.")); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 84267a08ad..1b0bc18157 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -401,7 +401,7 @@ where let cursor_style = frame.cursor_style; let mut images = std::mem::take(&mut frame.image_placements); let first_id = if let Some(base) = self.tmux_kitty_image_base { - images.truncate(256); + images.truncate(254); // The last two pane IDs belong to the pet and picker. base } else { crate::transcript_image::FIRST_IMAGE_ID @@ -542,6 +542,18 @@ where Ok(()) } + // The pet draws after the text frame. Mark its placeholder cells so the next diff + // restores the underlying text when the sprite moves or disappears. + pub(crate) fn mark_pet_placeholders(&mut self, request: Option<&crate::pets::AmbientPetDraw>) { + if self.tmux_kitty_image_base.is_some() + && let Some(sprite) = request + { + for pos in sprite.area().intersection(self.viewport_area).positions() { + self.previous_buffer_mut()[pos].set_diff_option(CellDiffOption::AlwaysUpdate); + } + } + } + /// Force the next draw pass to repaint the entire viewport after raw terminal /// operations move screen content outside ratatui's knowledge. Resetting the /// diff buffer alone would leave default-style spaces equal to their previous diff --git a/codex-rs/tui/src/pets/ambient.rs b/codex-rs/tui/src/pets/ambient.rs index e5507ce5c9..714f83b34b 100644 --- a/codex-rs/tui/src/pets/ambient.rs +++ b/codex-rs/tui/src/pets/ambient.rs @@ -123,6 +123,12 @@ pub(crate) struct AmbientPetDraw { pub(crate) sixel_dir: PathBuf, } +impl AmbientPetDraw { + pub(crate) fn area(&self) -> Rect { + Rect::new(self.x, self.y, self.columns, self.rows) + } +} + #[derive(Debug)] pub(crate) struct AmbientPet { pet: Pet, diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index d8def24e78..38f387eb1e 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -1,5 +1,6 @@ use std::env; use std::fs; +use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -13,7 +14,10 @@ use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; +use crossterm::cursor::MoveTo; +use crossterm::queue; use image::imageops::FilterType; +use ratatui::layout::Rect; use super::sixel; @@ -40,6 +44,9 @@ impl PetImageSupport { pub(crate) fn protocol(self) -> Option { match self { Self::Supported(protocol) => Some(protocol), + Self::Unsupported(PetImageUnsupportedReason::Tmux) => { + crate::tui::tmux_fullscreen_image_base().map(|_| ImageProtocol::Kitty) + } Self::Unsupported(_) => None, } } @@ -47,7 +54,7 @@ impl PetImageSupport { pub(crate) fn unsupported_message(self) -> Option<&'static str> { match self { Self::Supported(_) => None, - Self::Unsupported(reason) => Some(reason.message()), + Self::Unsupported(reason) => self.protocol().is_none().then(|| reason.message()), } } } @@ -64,7 +71,7 @@ impl PetImageUnsupportedReason { fn message(self) -> &'static str { match self { Self::Tmux => { - "Pets are disabled in tmux. Terminal images don’t stay pane-local in tmux and can corrupt scrollback or move between panes. Run Codex outside tmux to use pets." + "Pets are unavailable in this tmux pane. Use fullscreen Codex with allow-passthrough all, or run Codex outside tmux." } Self::Zellij => { "Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets." @@ -226,6 +233,32 @@ pub(crate) enum KittyPlacement { Virtual(u32), } +impl KittyPlacement { + pub(crate) fn draw( + self, + writer: &mut impl Write, + area: Rect, + payload: &[u8], + ) -> std::io::Result<()> { + queue!(writer, MoveTo(area.x, area.y))?; + writer.write_all(payload)?; + if let KittyPlacement::Virtual(id) = self { + // tmux moves these cells with the pane. Kitty inherits subsequent columns in each row. + let [_, r, g, b] = id.to_be_bytes(); + write!(writer, "\x1b[38;2;{r};{g};{b}m")?; + let remaining_cells = "\u{10eeee}".repeat(usize::from(area.width.saturating_sub(1))); + for (y, mark) in (area.y..area.bottom()).zip([ + '\u{305}', '\u{30d}', '\u{30e}', '\u{310}', '\u{312}', '\u{33d}', + ]) { + queue!(writer, MoveTo(area.x, y))?; + write!(writer, "\u{10eeee}{mark}{remaining_cells}")?; + } + writer.write_all(b"\x1b[39m")?; + } + Ok(()) + } +} + pub fn kitty_transmit_png_with_id( path: &Path, columns: u16, diff --git a/codex-rs/tui/src/pets/mod.rs b/codex-rs/tui/src/pets/mod.rs index 884a7edf5f..540799f8c6 100644 --- a/codex-rs/tui/src/pets/mod.rs +++ b/codex-rs/tui/src/pets/mod.rs @@ -131,7 +131,9 @@ pub(crate) fn render_ambient_pet_image( state: &mut PetImageRenderState, request: Option, ) -> std::result::Result<(), PetImageRenderError> { - render_pet_image(writer, state, /*image_id*/ 0xC0DE, request) + render_pet_image( + writer, state, /*image_id*/ 0xC0DE, /*pane_slot*/ 254, request, + ) } pub(crate) fn render_pet_picker_preview_image( @@ -139,29 +141,31 @@ pub(crate) fn render_pet_picker_preview_image( state: &mut PetImageRenderState, request: Option, ) -> std::result::Result<(), PetImageRenderError> { - render_pet_image(writer, state, /*image_id*/ 0xC0DF, request) + render_pet_image( + writer, state, /*image_id*/ 0xC0DF, /*pane_slot*/ 255, request, + ) } #[derive(Debug, Default)] pub(crate) struct PetImageRenderState { last_sixel_clear_area: Option, - last_protocol: Option, + last_kitty_id: Option, } fn render_pet_image( writer: &mut impl Write, state: &mut PetImageRenderState, image_id: u32, + pane_slot: u32, request: Option, ) -> std::result::Result<(), PetImageRenderError> { - use crossterm::cursor::MoveTo; use crossterm::cursor::RestorePosition; use crossterm::cursor::SavePosition; use crossterm::queue; use image_protocol::ImageProtocol; let Some(request) = request else { - if state.last_protocol.take().is_some_and(is_kitty_protocol) { + if let Some(image_id) = state.last_kitty_id.take() { write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; } if let Some(area) = state.last_sixel_clear_area.take() { @@ -173,40 +177,41 @@ fn render_pet_image( return Ok(()); }; - if state.last_protocol.take().is_some_and(is_kitty_protocol) - || is_kitty_protocol(request.protocol) - { - write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; + let pane_base = crate::tui::tmux_fullscreen_image_base(); + let image_id = pane_base.map_or(image_id, |base| base + pane_slot); + let placement = if pane_base.is_some() { + KittyPlacement::Virtual(image_id) + } else { + KittyPlacement::Cursor(image_id) + }; + let current_id = is_kitty_protocol(request.protocol).then_some(image_id); + if let Some(id) = std::mem::replace(&mut state.last_kitty_id, current_id).or(current_id) { + write!(writer, "{}", image_protocol::kitty_delete_image(id))?; } - state.last_protocol = Some(request.protocol); - let payload = match request.protocol { - ImageProtocol::Kitty => AmbientPetPayload::Text( - image_protocol::kitty_transmit_png_with_id( - &request.frame, - request.columns, - request.rows, - KittyPlacement::Cursor(image_id), - ) - .map_err(PetImageRenderError::Asset)?, - ), - ImageProtocol::KittyLocalFile => AmbientPetPayload::Text( - image_protocol::kitty_transmit_png_file_with_id( - &request.frame, - request.columns, - request.rows, - KittyPlacement::Cursor(image_id), - ) - .map_err(PetImageRenderError::Asset)?, - ), + ImageProtocol::Kitty => image_protocol::kitty_transmit_png_with_id( + &request.frame, + request.columns, + request.rows, + placement, + ) + .map_err(PetImageRenderError::Asset)? + .into_bytes(), + ImageProtocol::KittyLocalFile => image_protocol::kitty_transmit_png_file_with_id( + &request.frame, + request.columns, + request.rows, + placement, + ) + .map_err(PetImageRenderError::Asset)? + .into_bytes(), ImageProtocol::Sixel => { let path = image_protocol::sixel_frame(&request.frame, &request.sixel_dir, request.height_px) .map_err(PetImageRenderError::Asset)?; - let sixel = std::fs::read(&path) + std::fs::read(&path) .with_context(|| format!("read {}", path.display())) - .map_err(PetImageRenderError::Asset)?; - AmbientPetPayload::Bytes(sixel) + .map_err(PetImageRenderError::Asset)? } }; @@ -225,21 +230,12 @@ fn render_pet_image( clear_sixel_area(writer, area)?; state.last_sixel_clear_area = Some(area); } - queue!(writer, MoveTo(request.x, request.y))?; - match payload { - AmbientPetPayload::Text(payload) => write!(writer, "{payload}")?, - AmbientPetPayload::Bytes(payload) => writer.write_all(&payload)?, - } + placement.draw(writer, request.area(), &payload)?; queue!(writer, RestorePosition)?; writer.flush()?; Ok(()) } -enum AmbientPetPayload { - Text(String), - Bytes(Vec), -} - fn is_kitty_protocol(protocol: image_protocol::ImageProtocol) -> bool { matches!( protocol, @@ -485,7 +481,7 @@ mod tests { let mut writer = FailingWriter; let mut state = PetImageRenderState { - last_protocol: Some(ImageProtocol::Kitty), + last_kitty_id: Some(0xC0DE), ..Default::default() }; diff --git a/codex-rs/tui/src/pets/preview.rs b/codex-rs/tui/src/pets/preview.rs index b4239840fb..98b8127762 100644 --- a/codex-rs/tui/src/pets/preview.rs +++ b/codex-rs/tui/src/pets/preview.rs @@ -67,8 +67,11 @@ impl PetPickerPreviewState { }); } - pub(crate) fn area(&self) -> Option { - self.inner.lock().ok().and_then(|inner| inner.last_area) + pub(crate) fn take_area(&self) -> Option { + self.inner + .lock() + .ok() + .and_then(|mut inner| inner.last_area.take()) } fn update(&self, f: impl FnOnce(&mut PetPickerPreviewInner)) { diff --git a/codex-rs/tui/src/transcript_image.rs b/codex-rs/tui/src/transcript_image.rs index 82a55be839..0e141e9fda 100644 --- a/codex-rs/tui/src/transcript_image.rs +++ b/codex-rs/tui/src/transcript_image.rs @@ -11,7 +11,6 @@ use std::path::Path; use std::sync::Arc; use anyhow::Result; -use crossterm::cursor::MoveTo; use crossterm::cursor::RestorePosition; use crossterm::cursor::SavePosition; use crossterm::queue; @@ -186,21 +185,8 @@ impl TranscriptImage { let Ok(payload) = payload else { return Ok(None); }; - queue!(writer, SavePosition, MoveTo(area.x, area.y))?; - writer.write_all(&payload)?; - if let KittyPlacement::Virtual(id) = placement { - // tmux moves these cells with the pane. Kitty inherits subsequent columns in each row. - let [_, r, g, b] = id.to_be_bytes(); - write!(writer, "\x1b[38;2;{r};{g};{b}m")?; - let remaining_cells = "\u{10eeee}".repeat(usize::from(area.width.saturating_sub(1))); - for (y, mark) in (area.y..area.bottom()).zip([ - '\u{305}', '\u{30d}', '\u{30e}', '\u{310}', '\u{312}', '\u{33d}', - ]) { - queue!(writer, MoveTo(area.x, y))?; - write!(writer, "\u{10eeee}{mark}{remaining_cells}")?; - } - writer.write_all(b"\x1b[39m")?; - } + queue!(writer, SavePosition)?; + placement.draw(writer, area, &payload)?; queue!(writer, RestorePosition)?; Ok(file) } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 8e11eac94d..30002e8540 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -11,6 +11,7 @@ use std::panic; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU32; use std::sync::atomic::Ordering; use std::time::Duration; @@ -697,6 +698,14 @@ impl MouseCapture { } } +// Zero denotes scrollback mode or a pane without Kitty passthrough. +static TMUX_FULLSCREEN_IMAGE_BASE: AtomicU32 = AtomicU32::new(0); + +pub(crate) fn tmux_fullscreen_image_base() -> Option { + std::num::NonZeroU32::new(TMUX_FULLSCREEN_IMAGE_BASE.load(Ordering::Relaxed)) + .map(std::num::NonZeroU32::get) +} + pub struct Tui { pub(crate) link_hover: link_pointer::LinkHover, frame_requester: FrameRequester, @@ -815,10 +824,22 @@ impl Tui { self.alt_screen_enabled = enabled; } + fn record_owned_screen(&mut self, owned: bool) { + self.owned_screen = owned; + TMUX_FULLSCREEN_IMAGE_BASE.store( + if owned { + self.terminal.tmux_kitty_image_base.unwrap_or_default() + } else { + 0 + }, + Ordering::Relaxed, + ); + } + /// Defer a fresh fullscreen launch until its first synchronized frame is ready. pub(crate) fn prepare_owned_screen(&mut self, owned: bool) -> Result<()> { if owned && self.alt_screen_enabled && !self.is_alt_screen_active() { - self.owned_screen = true; + self.record_owned_screen(/*owned*/ true); self.frame_requester.schedule_frame(); Ok(()) } else { @@ -847,11 +868,11 @@ impl Tui { self.leave_alt_screen_for_handoff() }; if let Err(error) = transition { - self.owned_screen = self.is_alt_screen_active(); + self.record_owned_screen(self.is_alt_screen_active()); self.terminal.invalidate_viewport(); return Err(error); } - self.owned_screen = owned; + self.record_owned_screen(owned); self.frame_requester.schedule_frame(); Ok(()) } @@ -1428,6 +1449,7 @@ impl Tui { } let terminal = &mut self.terminal; + terminal.mark_pet_placeholders(request.as_ref()); let state = &mut self.ambient_pet_image_state; stdout().sync_update(|_| { match crate::pets::render_ambient_pet_image(terminal.backend_mut(), state, request) { @@ -1447,6 +1469,7 @@ impl Tui { } let terminal = &mut self.terminal; + terminal.mark_pet_placeholders(request.as_ref()); let state = &mut self.pet_picker_preview_image_state; stdout().sync_update(|_| { match crate::pets::render_pet_picker_preview_image( diff --git a/codex-rs/tui/src/tui/owned_screen_tests.rs b/codex-rs/tui/src/tui/owned_screen_tests.rs index 0833707e3f..7e189bdd89 100644 --- a/codex-rs/tui/src/tui/owned_screen_tests.rs +++ b/codex-rs/tui/src/tui/owned_screen_tests.rs @@ -205,4 +205,15 @@ async fn owned_screen_lifecycle_child() { tui.set_owned_screen(/*owned*/ false) .expect("release session screen"); assert_eq!(tui.terminal.viewport_area, inline); + + // A fresh fullscreen launch defers terminal entry until its first frame. + tui.terminal.tmux_kitty_image_base = Some(0x80_0100); + let support = + crate::pets::PetImageSupport::Unsupported(crate::pets::PetImageUnsupportedReason::Tmux); + tui.prepare_owned_screen(/*owned*/ true) + .expect("prepare fullscreen"); + assert_eq!(support.protocol(), Some(crate::pets::ImageProtocol::Kitty)); + tui.set_owned_screen(/*owned*/ false) + .expect("return to scrollback"); + assert_eq!(support.protocol(), None); }