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
This commit is contained in:
Eric Traut
2026-11-05 05:54:31 +00:00
committed by copyberry
parent f732dce447
commit 695961ee88
14 changed files with 184 additions and 107 deletions
+4 -27
View File
@@ -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<crate::pets::ImageProtocol> {
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<crate::pets::ImageProtocol> {
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()
+3 -3
View File
@@ -201,7 +201,7 @@ impl App {
tui: &mut tui::Tui,
screen_size: Size,
) -> Result<Rect> {
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.
+27
View File
@@ -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() {
+1 -1
View File
@@ -138,7 +138,7 @@ impl ChatWidget {
pub(crate) fn pet_picker_preview_draw(&self) -> Option<crate::pets::AmbientPetDraw> {
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()?
@@ -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
@@ -3219,7 +3219,7 @@ async fn slash_pets_in_tmux_shows_notice_and_preserves_draft() {
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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));
+13 -1
View File
@@ -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
+6
View File
@@ -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,
+35 -2
View File
@@ -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<ImageProtocol> {
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,
+39 -43
View File
@@ -131,7 +131,9 @@ pub(crate) fn render_ambient_pet_image(
state: &mut PetImageRenderState,
request: Option<AmbientPetDraw>,
) -> 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<AmbientPetDraw>,
) -> 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<SixelClearArea>,
last_protocol: Option<image_protocol::ImageProtocol>,
last_kitty_id: Option<u32>,
}
fn render_pet_image(
writer: &mut impl Write,
state: &mut PetImageRenderState,
image_id: u32,
pane_slot: u32,
request: Option<AmbientPetDraw>,
) -> 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<u8>),
}
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()
};
+5 -2
View File
@@ -67,8 +67,11 @@ impl PetPickerPreviewState {
});
}
pub(crate) fn area(&self) -> Option<Rect> {
self.inner.lock().ok().and_then(|inner| inner.last_area)
pub(crate) fn take_area(&self) -> Option<Rect> {
self.inner
.lock()
.ok()
.and_then(|mut inner| inner.last_area.take())
}
fn update(&self, f: impl FnOnce(&mut PetPickerPreviewInner)) {
+2 -16
View File
@@ -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)
}
+26 -3
View File
@@ -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<u32> {
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(
@@ -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);
}