mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
## Why The Codex App has animated pets, but the TUI had no equivalent ambient companion surface. This brings that experience into terminal Codex while keeping the main chat flow usable: the pet should feel present, but it cannot cover transcript text, composer input, approvals, or picker content. The feature also needs to be terminal-aware. Different terminals support different image protocols, tmux can interfere with image rendering, and some users will want pets disabled entirely or anchored differently depending on their layout. <table> <tr><td> <img width="4110" height="2584" alt="CleanShot 2026-05-05 at 12 41 45@2x" src="https://github.com/user-attachments/assets/68a1fcbc-2104-48d6-b834-69c6aaa95cdf" /> <p align="center">macOS - Ghostty, iTerm2 and WezTerm with Custom Pet</p> </td></tr> <tr><td> ![Uploading CleanShot 2026-05-10 at 20.28.30.png…]() <p align="center">Windows Terminal</p> </td></tr> <tr><td> <img width="3902" height="2752" alt="CleanShot 2026-05-05 at 12 39 02@2x" src="https://github.com/user-attachments/assets/300e2931-6b00-467e-91cb-ab8e28470500" /> <p align="center">Linux - WezTerm and Ghostty</p> </td></tr> </table> ## What Changed - Add a TUI ambient pet renderer in `codex-rs/tui/src/pets/`. - Port the app-style pet animation states so the sprite changes with task status, waiting-for-input states, review/ready states, and failures. - Add `/pets` selection UI with a preview pane, loading state, built-in pet choices, and a first-row `Disable terminal pets` option. - Download built-in pet spritesheets on demand from the same public CDN path already used by Android, under `https://persistent.oaistatic.com/codex/pets/v1/...`, and cache them locally under `~/.codex/cache/tui-pets/`. - Keep custom pets local. - Add config support for pet selection, disabling pets, and choosing whether the pet follows the composer bottom or anchors to the terminal bottom. - Reserve layout space around the pet so transcript wrapping, live responses, and composer input do not render underneath the sprite. - Gate image rendering by terminal capability, disable image pets under tmux, and support both Kitty Graphics and SIXEL terminals. - Add redraw cleanup for terminal image artifacts, including sixel cell clearing. ## Current Scope - This is an initial TUI version of ambient pets, not full App parity. - It focuses on ambient sprite rendering, `/pets` selection, custom pets, terminal capability gating, and on-demand CDN-backed built-in assets. - The ambient text overlay is currently disabled, so the TUI renders the pet sprite without extra status text beside it. ## How to Test 1. Start Codex TUI in a terminal with image support. 2. Run `/pets`. 3. Confirm the picker shows built-in pets plus custom pets, and the first item is `Disable terminal pets`. 4. On a fresh `~/.codex/cache/tui-pets/`, move onto a built-in pet and confirm the first preview downloads the spritesheet from the shared Codex pets CDN and renders successfully. 5. Move through the pet list and confirm subsequent built-in previews use the local cache. 6. Select a pet, then send and receive messages. Confirm transcript and composer text wrap before the pet instead of rendering underneath the sprite. 7. Change the pet anchor setting and confirm the pet can either follow the composer bottom or sit at the terminal bottom. 8. Return to `/pets`, choose `Disable terminal pets`, and confirm the sprite disappears cleanly. Targeted tests: - `cargo test -p codex-tui ambient_pet_` - `cargo test -p codex-tui resize_reflow_wraps_transcript_early_when_pet_is_enabled` - `cargo insta pending-snapshots`
1001 lines
35 KiB
Rust
1001 lines
35 KiB
Rust
use std::fmt;
|
|
use std::future::Future;
|
|
use std::io::IsTerminal;
|
|
use std::io::Result;
|
|
use std::io::Stdout;
|
|
use std::io::Write;
|
|
use std::io::stdin;
|
|
use std::io::stdout;
|
|
use std::panic;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use crossterm::Command;
|
|
use crossterm::SynchronizedUpdate;
|
|
use crossterm::cursor::SetCursorStyle;
|
|
use crossterm::event::DisableBracketedPaste;
|
|
use crossterm::event::DisableFocusChange;
|
|
use crossterm::event::EnableBracketedPaste;
|
|
use crossterm::event::EnableFocusChange;
|
|
use crossterm::event::KeyEvent;
|
|
use crossterm::terminal::EnterAlternateScreen;
|
|
use crossterm::terminal::LeaveAlternateScreen;
|
|
#[cfg(not(unix))]
|
|
use crossterm::terminal::supports_keyboard_enhancement;
|
|
use ratatui::backend::Backend;
|
|
use ratatui::backend::CrosstermBackend;
|
|
use ratatui::crossterm::execute;
|
|
use ratatui::crossterm::terminal::disable_raw_mode;
|
|
use ratatui::crossterm::terminal::enable_raw_mode;
|
|
use ratatui::layout::Offset;
|
|
use ratatui::layout::Position;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::layout::Size;
|
|
use ratatui::text::Line;
|
|
use tokio::sync::broadcast;
|
|
use tokio_stream::Stream;
|
|
|
|
pub use self::frame_requester::FrameRequester;
|
|
use crate::custom_terminal;
|
|
use crate::custom_terminal::Terminal as CustomTerminal;
|
|
use crate::insert_history::HistoryLineWrapPolicy;
|
|
use crate::notifications::DesktopNotificationBackend;
|
|
use crate::notifications::detect_backend;
|
|
use crate::tui::event_stream::EventBroker;
|
|
use crate::tui::event_stream::TuiEventStream;
|
|
#[cfg(unix)]
|
|
use crate::tui::job_control::SuspendContext;
|
|
use codex_config::types::NotificationCondition;
|
|
use codex_config::types::NotificationMethod;
|
|
|
|
mod event_stream;
|
|
mod frame_rate_limiter;
|
|
mod frame_requester;
|
|
#[cfg(unix)]
|
|
mod job_control;
|
|
mod keyboard_modes;
|
|
|
|
/// Target frame interval for UI redraw scheduling.
|
|
pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL;
|
|
|
|
/// A type alias for the terminal type used in this application
|
|
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
|
|
|
pub(crate) fn running_in_vscode_terminal() -> bool {
|
|
keyboard_modes::running_in_vscode_terminal()
|
|
}
|
|
|
|
fn should_emit_notification(condition: NotificationCondition, terminal_focused: bool) -> bool {
|
|
match condition {
|
|
NotificationCondition::Unfocused => !terminal_focused,
|
|
NotificationCondition::Always => true,
|
|
}
|
|
}
|
|
|
|
impl Drop for Tui {
|
|
fn drop(&mut self) {
|
|
if let Err(err) = self.clear_ambient_pet_image() {
|
|
tracing::debug!(error = %err, "failed to clear ambient pet image on TUI drop");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Write as _;
|
|
|
|
use super::clear_for_viewport_change;
|
|
use super::should_emit_notification;
|
|
use crate::custom_terminal::Terminal as CustomTerminal;
|
|
use crate::test_backend::VT100Backend;
|
|
use codex_config::types::NotificationCondition;
|
|
use ratatui::layout::Position;
|
|
use ratatui::layout::Rect;
|
|
|
|
#[test]
|
|
fn unfocused_notification_condition_is_suppressed_when_focused() {
|
|
assert!(!should_emit_notification(
|
|
NotificationCondition::Unfocused,
|
|
/*terminal_focused*/ true
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn always_notification_condition_emits_when_focused() {
|
|
assert!(should_emit_notification(
|
|
NotificationCondition::Always,
|
|
/*terminal_focused*/ true
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn unfocused_notification_condition_emits_when_unfocused() {
|
|
assert!(should_emit_notification(
|
|
NotificationCondition::Unfocused,
|
|
/*terminal_focused*/ false
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn first_viewport_change_clears_from_new_viewport_when_old_viewport_is_empty() {
|
|
let width = 12;
|
|
let height = 4;
|
|
let backend = VT100Backend::new(width, height);
|
|
let mut terminal =
|
|
CustomTerminal::with_options_and_cursor_position(backend, Position { x: 0, y: 1 })
|
|
.expect("terminal");
|
|
write!(
|
|
terminal.backend_mut(),
|
|
"shell line\r\nstale cells\r\nmore stale"
|
|
)
|
|
.expect("prefill terminal");
|
|
|
|
clear_for_viewport_change(
|
|
&mut terminal,
|
|
Rect::new(
|
|
/*x*/ 0,
|
|
/*y*/ 1,
|
|
/*width*/ width,
|
|
/*height*/ height - 1,
|
|
),
|
|
)
|
|
.expect("clear transition");
|
|
|
|
let rows: Vec<String> = terminal
|
|
.backend()
|
|
.vt100()
|
|
.screen()
|
|
.rows(/*start*/ 0, width)
|
|
.collect();
|
|
assert!(
|
|
rows[0].contains("shell line"),
|
|
"expected content before the viewport to remain visible, rows: {rows:?}"
|
|
);
|
|
assert!(
|
|
!rows.iter().skip(1).any(|row| row.contains("stale")),
|
|
"expected stale cells inside the new viewport to be cleared, rows: {rows:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn set_modes() -> Result<()> {
|
|
execute!(stdout(), EnableBracketedPaste)?;
|
|
|
|
enable_raw_mode()?;
|
|
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
|
|
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
|
|
// to create a new line that require this.
|
|
// Some terminals (notably legacy Windows consoles) do not support
|
|
// keyboard enhancement flags. Attempt to enable them, but continue
|
|
// gracefully if unsupported.
|
|
keyboard_modes::enable_keyboard_enhancement();
|
|
|
|
let _ = execute!(stdout(), EnableFocusChange);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct EnableAlternateScroll;
|
|
|
|
impl Command for EnableAlternateScroll {
|
|
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
|
write!(f, "\x1b[?1007h")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn execute_winapi(&self) -> Result<()> {
|
|
Err(std::io::Error::other(
|
|
"tried to execute EnableAlternateScroll using WinAPI; use ANSI instead",
|
|
))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_ansi_code_supported(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
struct DisableAlternateScroll;
|
|
|
|
impl Command for DisableAlternateScroll {
|
|
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
|
write!(f, "\x1b[?1007l")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn execute_winapi(&self) -> Result<()> {
|
|
Err(std::io::Error::other(
|
|
"tried to execute DisableAlternateScroll using WinAPI; use ANSI instead",
|
|
))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_ansi_code_supported(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum RawModeRestore {
|
|
Disable,
|
|
Keep,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum KeyboardRestore {
|
|
PopStack,
|
|
ResetAfterExit,
|
|
}
|
|
|
|
fn restore_common(
|
|
raw_mode_restore: RawModeRestore,
|
|
keyboard_restore: KeyboardRestore,
|
|
) -> Result<()> {
|
|
match keyboard_restore {
|
|
KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(),
|
|
KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(),
|
|
}
|
|
|
|
let mut first_error = execute!(stdout(), DisableBracketedPaste).err();
|
|
let _ = execute!(stdout(), DisableFocusChange);
|
|
if matches!(raw_mode_restore, RawModeRestore::Disable)
|
|
&& let Err(err) = disable_raw_mode()
|
|
{
|
|
first_error.get_or_insert(err);
|
|
}
|
|
if let Err(err) = execute!(
|
|
stdout(),
|
|
SetCursorStyle::DefaultUserShape,
|
|
crossterm::cursor::Show
|
|
) {
|
|
first_error.get_or_insert(err);
|
|
}
|
|
match first_error {
|
|
Some(err) => Err(err),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Restore the terminal to its original state.
|
|
/// Inverse of `set_modes`.
|
|
pub fn restore() -> Result<()> {
|
|
restore_common(RawModeRestore::Disable, KeyboardRestore::PopStack)
|
|
}
|
|
|
|
/// Restore the terminal after Codex is exiting.
|
|
///
|
|
/// Uses a stronger keyboard reset than [`restore`] so the parent shell recovers even if a
|
|
/// terminal missed the stack pop that normally pairs with [`set_modes`].
|
|
pub fn restore_after_exit() -> Result<()> {
|
|
restore_common(RawModeRestore::Disable, KeyboardRestore::ResetAfterExit)
|
|
}
|
|
|
|
/// Restore the terminal to its original state, but keep raw mode enabled.
|
|
pub fn restore_keep_raw() -> Result<()> {
|
|
restore_common(RawModeRestore::Keep, KeyboardRestore::PopStack)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RestoreMode {
|
|
#[allow(dead_code)]
|
|
Full, // Fully restore the terminal (disables raw mode).
|
|
KeepRaw, // Restore the terminal but keep raw mode enabled.
|
|
}
|
|
|
|
impl RestoreMode {
|
|
fn restore(self) -> Result<()> {
|
|
match self {
|
|
RestoreMode::Full => restore(),
|
|
RestoreMode::KeepRaw => restore_keep_raw(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Flush the underlying stdin buffer to clear any input that may be buffered at the terminal level.
|
|
/// For example, clears any user input that occurred while the crossterm EventStream was dropped.
|
|
#[cfg(unix)]
|
|
fn flush_terminal_input_buffer() {
|
|
// Safety: flushing the stdin queue is safe and does not move ownership.
|
|
let result = unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) };
|
|
if result != 0 {
|
|
let err = std::io::Error::last_os_error();
|
|
tracing::warn!("failed to tcflush stdin: {err}");
|
|
}
|
|
}
|
|
|
|
/// Flush the underlying stdin buffer to clear any input that may be buffered at the terminal level.
|
|
/// For example, clears any user input that occurred while the crossterm EventStream was dropped.
|
|
#[cfg(windows)]
|
|
fn flush_terminal_input_buffer() {
|
|
use windows_sys::Win32::Foundation::GetLastError;
|
|
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
|
|
use windows_sys::Win32::System::Console::FlushConsoleInputBuffer;
|
|
use windows_sys::Win32::System::Console::GetStdHandle;
|
|
use windows_sys::Win32::System::Console::STD_INPUT_HANDLE;
|
|
|
|
let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) };
|
|
if handle == INVALID_HANDLE_VALUE || handle == 0 {
|
|
let err = unsafe { GetLastError() };
|
|
tracing::warn!("failed to get stdin handle for flush: error {err}");
|
|
return;
|
|
}
|
|
|
|
let result = unsafe { FlushConsoleInputBuffer(handle) };
|
|
if result == 0 {
|
|
let err = unsafe { GetLastError() };
|
|
tracing::warn!("failed to flush stdin buffer: error {err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(not(any(unix, windows)))]
|
|
pub(crate) fn flush_terminal_input_buffer() {}
|
|
|
|
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
|
pub fn init() -> Result<Terminal> {
|
|
if !stdin().is_terminal() {
|
|
return Err(std::io::Error::other("stdin is not a terminal"));
|
|
}
|
|
if !stdout().is_terminal() {
|
|
return Err(std::io::Error::other("stdout is not a terminal"));
|
|
}
|
|
set_modes()?;
|
|
|
|
flush_terminal_input_buffer();
|
|
|
|
set_panic_hook();
|
|
|
|
#[cfg(unix)]
|
|
let backend = CrosstermBackend::new(stdout());
|
|
|
|
#[cfg(unix)]
|
|
let cursor_pos =
|
|
match crate::terminal_probe::cursor_position(crate::terminal_probe::DEFAULT_TIMEOUT) {
|
|
Ok(Some(pos)) => pos,
|
|
Ok(None) => {
|
|
tracing::warn!("initial cursor position probe timed out; defaulting to origin");
|
|
Position { x: 0, y: 0 }
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
"failed to read initial cursor position; defaulting to origin: {err}"
|
|
);
|
|
Position { x: 0, y: 0 }
|
|
}
|
|
};
|
|
|
|
#[cfg(not(unix))]
|
|
let mut backend = CrosstermBackend::new(stdout());
|
|
|
|
#[cfg(not(unix))]
|
|
let cursor_pos = cursor_position_with_crossterm(&mut backend);
|
|
|
|
let tui = CustomTerminal::with_options_and_cursor_position(backend, cursor_pos)?;
|
|
Ok(tui)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn cursor_position_with_crossterm(backend: &mut CrosstermBackend<Stdout>) -> Position {
|
|
backend.get_cursor_position().unwrap_or_else(|err| {
|
|
tracing::warn!("failed to read initial cursor position; defaulting to origin: {err}");
|
|
Position { x: 0, y: 0 }
|
|
})
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn detect_keyboard_enhancement_supported() -> bool {
|
|
crate::terminal_probe::keyboard_enhancement_supported(crate::terminal_probe::DEFAULT_TIMEOUT)
|
|
.unwrap_or(/*default*/ None)
|
|
.unwrap_or(/*default*/ false)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn detect_keyboard_enhancement_supported() -> bool {
|
|
// Non-Unix startup keeps the existing crossterm path because the bounded probe implementation
|
|
// relies on Unix file descriptors and `/dev/tty` semantics.
|
|
supports_keyboard_enhancement().unwrap_or(/*default*/ false)
|
|
}
|
|
|
|
fn set_panic_hook() {
|
|
let hook = panic::take_hook();
|
|
panic::set_hook(Box::new(move |panic_info| {
|
|
let _ = restore_after_exit(); // ignore any errors as we are already failing
|
|
hook(panic_info);
|
|
}));
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum TuiEvent {
|
|
/// A terminal key event after focus, paste, and protocol bookkeeping has been handled.
|
|
Key(KeyEvent),
|
|
/// A bracketed paste payload normalized by the app layer before it reaches the composer.
|
|
Paste(String),
|
|
/// A terminal size notification that should be handled as resize-sensitive draw work.
|
|
///
|
|
/// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without
|
|
/// changing the default draw path for scheduled frames.
|
|
Resize,
|
|
/// A scheduled repaint that does not necessarily correspond to a terminal size change.
|
|
Draw,
|
|
}
|
|
|
|
pub struct Tui {
|
|
frame_requester: FrameRequester,
|
|
draw_tx: broadcast::Sender<()>,
|
|
event_broker: Arc<EventBroker>,
|
|
pub(crate) terminal: Terminal,
|
|
pending_history_lines: Vec<PendingHistoryLines>,
|
|
ambient_pet_image_state: crate::pets::PetImageRenderState,
|
|
pet_picker_preview_image_state: crate::pets::PetImageRenderState,
|
|
alt_saved_viewport: Option<ratatui::layout::Rect>,
|
|
#[cfg(unix)]
|
|
suspend_context: SuspendContext,
|
|
// True when overlay alt-screen UI is active
|
|
alt_screen_active: Arc<AtomicBool>,
|
|
// True when terminal/tab is focused; updated internally from crossterm events
|
|
terminal_focused: Arc<AtomicBool>,
|
|
enhanced_keys_supported: bool,
|
|
notification_backend: Option<DesktopNotificationBackend>,
|
|
notification_condition: NotificationCondition,
|
|
is_zellij: bool,
|
|
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
|
|
alt_screen_enabled: bool,
|
|
}
|
|
|
|
struct PendingHistoryLines {
|
|
lines: Vec<Line<'static>>,
|
|
wrap_policy: HistoryLineWrapPolicy,
|
|
}
|
|
|
|
fn clear_for_viewport_change<B>(terminal: &mut CustomTerminal<B>, new_area: Rect) -> Result<()>
|
|
where
|
|
B: Backend + Write,
|
|
{
|
|
let clear_position = if terminal.viewport_area.is_empty() {
|
|
new_area.as_position()
|
|
} else {
|
|
terminal.viewport_area.as_position()
|
|
};
|
|
terminal.clear_after_position(clear_position)
|
|
}
|
|
|
|
impl Tui {
|
|
pub fn new(terminal: Terminal) -> Self {
|
|
let (draw_tx, _) = broadcast::channel(1);
|
|
let frame_requester = FrameRequester::new(draw_tx.clone());
|
|
|
|
// Detect keyboard enhancement support before any EventStream is created so the
|
|
// crossterm poller can acquire its lock without contention.
|
|
let enhanced_keys_supported = !keyboard_modes::keyboard_enhancement_disabled()
|
|
&& detect_keyboard_enhancement_supported();
|
|
// Cache this to avoid contention with the event reader.
|
|
supports_color::on_cached(supports_color::Stream::Stdout);
|
|
let _ = crate::terminal_palette::default_colors();
|
|
let is_zellij = matches!(
|
|
codex_terminal_detection::terminal_info().multiplexer,
|
|
Some(codex_terminal_detection::Multiplexer::Zellij {})
|
|
);
|
|
|
|
Self {
|
|
frame_requester,
|
|
draw_tx,
|
|
event_broker: Arc::new(EventBroker::new()),
|
|
terminal,
|
|
pending_history_lines: vec![],
|
|
ambient_pet_image_state: crate::pets::PetImageRenderState::default(),
|
|
pet_picker_preview_image_state: crate::pets::PetImageRenderState::default(),
|
|
alt_saved_viewport: None,
|
|
#[cfg(unix)]
|
|
suspend_context: SuspendContext::new(),
|
|
alt_screen_active: Arc::new(AtomicBool::new(false)),
|
|
terminal_focused: Arc::new(AtomicBool::new(true)),
|
|
enhanced_keys_supported,
|
|
notification_backend: Some(detect_backend(NotificationMethod::default())),
|
|
notification_condition: NotificationCondition::default(),
|
|
is_zellij,
|
|
alt_screen_enabled: true,
|
|
}
|
|
}
|
|
|
|
/// Set whether alternate screen is enabled. When false, enter_alt_screen() becomes a no-op.
|
|
pub fn set_alt_screen_enabled(&mut self, enabled: bool) {
|
|
self.alt_screen_enabled = enabled;
|
|
}
|
|
|
|
pub fn set_notification_settings(
|
|
&mut self,
|
|
method: NotificationMethod,
|
|
condition: NotificationCondition,
|
|
) {
|
|
self.notification_backend = Some(detect_backend(method));
|
|
self.notification_condition = condition;
|
|
}
|
|
|
|
pub fn frame_requester(&self) -> FrameRequester {
|
|
self.frame_requester.clone()
|
|
}
|
|
|
|
pub fn enhanced_keys_supported(&self) -> bool {
|
|
self.enhanced_keys_supported
|
|
}
|
|
|
|
pub fn is_alt_screen_active(&self) -> bool {
|
|
self.alt_screen_active.load(Ordering::Relaxed)
|
|
}
|
|
|
|
// Drop crossterm EventStream to avoid stdin conflicts with other processes.
|
|
pub fn pause_events(&mut self) {
|
|
self.event_broker.pause_events();
|
|
}
|
|
|
|
// Resume crossterm EventStream to resume stdin polling.
|
|
// Inverse of `pause_events`.
|
|
pub fn resume_events(&mut self) {
|
|
self.event_broker.resume_events();
|
|
}
|
|
|
|
/// Temporarily restore terminal state to run an external interactive program `f`.
|
|
///
|
|
/// This pauses crossterm's stdin polling by dropping the underlying event stream, restores
|
|
/// terminal modes (optionally keeping raw mode enabled), then re-applies Codex TUI modes and
|
|
/// flushes pending stdin input before resuming events.
|
|
pub async fn with_restored<R, F, Fut>(&mut self, mode: RestoreMode, f: F) -> R
|
|
where
|
|
F: FnOnce() -> Fut,
|
|
Fut: Future<Output = R>,
|
|
{
|
|
// Pause crossterm events to avoid stdin conflicts with external program `f`.
|
|
self.pause_events();
|
|
|
|
// Leave alt screen if active to avoid conflicts with external program `f`.
|
|
let was_alt_screen = self.is_alt_screen_active();
|
|
if was_alt_screen {
|
|
let _ = self.leave_alt_screen();
|
|
}
|
|
|
|
if let Err(err) = mode.restore() {
|
|
tracing::warn!("failed to restore terminal modes before external program: {err}");
|
|
}
|
|
|
|
let output = f().await;
|
|
|
|
if let Err(err) = set_modes() {
|
|
tracing::warn!("failed to re-enable terminal modes after external program: {err}");
|
|
}
|
|
// After the external program `f` finishes, reset terminal state and flush any buffered keypresses.
|
|
flush_terminal_input_buffer();
|
|
|
|
if was_alt_screen {
|
|
let _ = self.enter_alt_screen();
|
|
}
|
|
|
|
self.resume_events();
|
|
output
|
|
}
|
|
|
|
/// Emit a desktop notification now if the terminal is unfocused.
|
|
/// Returns true if a notification was posted.
|
|
pub fn notify(&mut self, message: impl AsRef<str>) -> bool {
|
|
let terminal_focused = self.terminal_focused.load(Ordering::Relaxed);
|
|
if !should_emit_notification(self.notification_condition, terminal_focused) {
|
|
return false;
|
|
}
|
|
|
|
let Some(backend) = self.notification_backend.as_mut() else {
|
|
return false;
|
|
};
|
|
|
|
let message = message.as_ref().to_string();
|
|
match backend.notify(&message) {
|
|
Ok(()) => true,
|
|
Err(err) => {
|
|
let method = backend.method();
|
|
tracing::warn!(
|
|
error = %err,
|
|
method = %method,
|
|
"Failed to emit terminal notification; disabling future notifications"
|
|
);
|
|
self.notification_backend = None;
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn event_stream(&self) -> Pin<Box<dyn Stream<Item = TuiEvent> + Send + 'static>> {
|
|
#[cfg(unix)]
|
|
let stream = TuiEventStream::new(
|
|
self.event_broker.clone(),
|
|
self.draw_tx.subscribe(),
|
|
self.terminal_focused.clone(),
|
|
self.suspend_context.clone(),
|
|
self.alt_screen_active.clone(),
|
|
);
|
|
#[cfg(not(unix))]
|
|
let stream = TuiEventStream::new(
|
|
self.event_broker.clone(),
|
|
self.draw_tx.subscribe(),
|
|
self.terminal_focused.clone(),
|
|
);
|
|
Box::pin(stream)
|
|
}
|
|
|
|
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
|
|
/// inline viewport for restoration when leaving.
|
|
pub fn enter_alt_screen(&mut self) -> Result<()> {
|
|
if !self.alt_screen_enabled {
|
|
return Ok(());
|
|
}
|
|
let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen);
|
|
// Enable "alternate scroll" so terminals may translate wheel to arrows
|
|
let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll);
|
|
if let Ok(size) = self.terminal.size() {
|
|
self.alt_saved_viewport = Some(self.terminal.viewport_area);
|
|
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
|
|
0,
|
|
0,
|
|
size.width,
|
|
size.height,
|
|
));
|
|
let _ = self.terminal.clear();
|
|
}
|
|
self.alt_screen_active.store(true, Ordering::Relaxed);
|
|
Ok(())
|
|
}
|
|
|
|
/// Leave alternate screen and restore the previously saved inline viewport, if any.
|
|
pub fn leave_alt_screen(&mut self) -> Result<()> {
|
|
if !self.alt_screen_enabled {
|
|
return Ok(());
|
|
}
|
|
// Disable alternate scroll when leaving alt-screen
|
|
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
|
|
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);
|
|
if let Some(saved) = self.alt_saved_viewport.take() {
|
|
self.terminal.set_viewport_area(saved);
|
|
}
|
|
self.alt_screen_active.store(false, Ordering::Relaxed);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn insert_history_lines(&mut self, lines: Vec<Line<'static>>) {
|
|
self.insert_history_lines_with_wrap_policy(lines, HistoryLineWrapPolicy::PreWrap);
|
|
}
|
|
|
|
pub fn insert_history_lines_with_wrap_policy(
|
|
&mut self,
|
|
lines: Vec<Line<'static>>,
|
|
wrap_policy: HistoryLineWrapPolicy,
|
|
) {
|
|
if lines.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(last) = self.pending_history_lines.last_mut()
|
|
&& last.wrap_policy == wrap_policy
|
|
{
|
|
last.lines.extend(lines);
|
|
} else {
|
|
self.pending_history_lines
|
|
.push(PendingHistoryLines { lines, wrap_policy });
|
|
}
|
|
self.frame_requester().schedule_frame();
|
|
}
|
|
|
|
pub fn clear_pending_history_lines(&mut self) {
|
|
self.pending_history_lines.clear();
|
|
}
|
|
|
|
/// Resize the inline viewport to `height` rows, scrolling content above it if
|
|
/// the viewport would extend past the bottom of the screen. Returns `true` when
|
|
/// the caller must invalidate the diff buffer (Zellij mode), because the scroll
|
|
/// was performed with raw newlines that ratatui cannot track.
|
|
fn update_inline_viewport(
|
|
terminal: &mut Terminal,
|
|
height: u16,
|
|
is_zellij: bool,
|
|
) -> Result<bool> {
|
|
let size = terminal.size()?;
|
|
let mut needs_full_repaint = false;
|
|
|
|
let mut area = terminal.viewport_area;
|
|
area.height = height.min(size.height);
|
|
area.width = size.width;
|
|
if area.bottom() > size.height {
|
|
let scroll_by = area.bottom() - size.height;
|
|
if is_zellij {
|
|
Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?;
|
|
needs_full_repaint = true;
|
|
} else {
|
|
terminal
|
|
.backend_mut()
|
|
.scroll_region_up(0..area.top(), scroll_by)?;
|
|
}
|
|
area.y = size.height - area.height;
|
|
}
|
|
if area != terminal.viewport_area {
|
|
// On startup, the old viewport can still be empty. Clear from the
|
|
// new viewport top so stale shell cells do not show through spaces.
|
|
clear_for_viewport_change(terminal, area)?;
|
|
terminal.set_viewport_area(area);
|
|
}
|
|
|
|
Ok(needs_full_repaint)
|
|
}
|
|
|
|
/// Push content above the viewport upward by `scroll_by` rows using raw
|
|
/// newlines at the screen bottom. This is the Zellij-safe alternative to
|
|
/// `scroll_region_up`, which relies on DECSTBM sequences Zellij does not
|
|
/// support.
|
|
fn scroll_zellij_expanded_viewport(
|
|
terminal: &mut Terminal,
|
|
size: Size,
|
|
scroll_by: u16,
|
|
) -> Result<()> {
|
|
crossterm::queue!(
|
|
terminal.backend_mut(),
|
|
crossterm::cursor::MoveTo(0, size.height.saturating_sub(1))
|
|
)?;
|
|
for _ in 0..scroll_by {
|
|
crossterm::queue!(terminal.backend_mut(), crossterm::style::Print("\n"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Resize the inline viewport for the resize-reflow path.
|
|
///
|
|
/// Unlike the legacy draw path, this path does not scroll rows above the viewport when the
|
|
/// terminal shrinks. Resize reflow owns rebuilding those rows from transcript source, so
|
|
/// scrolling here would move the viewport once and then replay history into the wrong row.
|
|
fn update_inline_viewport_for_resize_reflow(
|
|
terminal: &mut Terminal,
|
|
height: u16,
|
|
is_zellij: bool,
|
|
) -> Result<bool> {
|
|
let size = terminal.size()?;
|
|
let terminal_height_shrank = size.height < terminal.last_known_screen_size.height;
|
|
let terminal_height_grew = size.height > terminal.last_known_screen_size.height;
|
|
let viewport_was_bottom_aligned =
|
|
terminal.viewport_area.bottom() == terminal.last_known_screen_size.height;
|
|
let previous_area = terminal.viewport_area;
|
|
|
|
let mut area = terminal.viewport_area;
|
|
area.height = height.min(size.height);
|
|
area.width = size.width;
|
|
let mut needs_full_repaint = false;
|
|
|
|
if area.bottom() > size.height {
|
|
let scroll_by = area.bottom() - size.height;
|
|
if !terminal_height_shrank {
|
|
if is_zellij {
|
|
Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?;
|
|
} else {
|
|
terminal
|
|
.backend_mut()
|
|
.scroll_region_up(0..area.top(), scroll_by)?;
|
|
}
|
|
}
|
|
area.y = size.height - area.height;
|
|
} else if terminal_height_grew && viewport_was_bottom_aligned {
|
|
area.y = size.height - area.height;
|
|
}
|
|
|
|
if area != terminal.viewport_area {
|
|
let clear_position = Position::new(/*x*/ 0, previous_area.y.min(area.y));
|
|
terminal.set_viewport_area(area);
|
|
terminal.clear_after_position(clear_position)?;
|
|
needs_full_repaint = true;
|
|
}
|
|
|
|
Ok(needs_full_repaint)
|
|
}
|
|
|
|
/// Write any buffered history lines above the viewport and clear the buffer.
|
|
/// Returns `true` when Zellij mode was used, signaling that the caller must
|
|
/// invalidate the diff buffer for a full repaint.
|
|
fn flush_pending_history_lines(
|
|
terminal: &mut Terminal,
|
|
pending_history_lines: &mut Vec<PendingHistoryLines>,
|
|
is_zellij: bool,
|
|
) -> Result<bool> {
|
|
if pending_history_lines.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
|
|
for batch in pending_history_lines.iter() {
|
|
crate::insert_history::insert_history_lines_with_mode_and_wrap_policy(
|
|
terminal,
|
|
batch.lines.clone(),
|
|
crate::insert_history::InsertHistoryMode::new(is_zellij),
|
|
batch.wrap_policy,
|
|
)?;
|
|
}
|
|
pending_history_lines.clear();
|
|
Ok(is_zellij)
|
|
}
|
|
|
|
pub fn draw(
|
|
&mut self,
|
|
height: u16,
|
|
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
|
) -> Result<()> {
|
|
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
|
|
// in the synchronized update.
|
|
#[cfg(unix)]
|
|
let mut prepared_resume = self
|
|
.suspend_context
|
|
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
|
|
|
|
// Precompute any viewport updates that need a cursor-position query before entering
|
|
// the synchronized update, to avoid racing with the event reader.
|
|
let mut pending_viewport_area = self.pending_viewport_area()?;
|
|
|
|
stdout().sync_update(|_| {
|
|
#[cfg(unix)]
|
|
if let Some(prepared) = prepared_resume.take() {
|
|
prepared.apply(&mut self.terminal)?;
|
|
}
|
|
|
|
let terminal = &mut self.terminal;
|
|
if let Some(new_area) = pending_viewport_area.take() {
|
|
terminal.set_viewport_area(new_area);
|
|
terminal.clear()?;
|
|
}
|
|
|
|
let mut needs_full_repaint =
|
|
Self::update_inline_viewport(terminal, height, self.is_zellij)?;
|
|
needs_full_repaint |= Self::flush_pending_history_lines(
|
|
terminal,
|
|
&mut self.pending_history_lines,
|
|
self.is_zellij,
|
|
)?;
|
|
|
|
if needs_full_repaint {
|
|
terminal.invalidate_viewport();
|
|
}
|
|
|
|
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
|
#[cfg(unix)]
|
|
{
|
|
let area = terminal.viewport_area;
|
|
let inline_area_bottom = if self.alt_screen_active.load(Ordering::Relaxed) {
|
|
self.alt_saved_viewport
|
|
.map(|r| r.bottom().saturating_sub(1))
|
|
.unwrap_or_else(|| area.bottom().saturating_sub(1))
|
|
} else {
|
|
area.bottom().saturating_sub(1)
|
|
};
|
|
self.suspend_context.set_cursor_y(inline_area_bottom);
|
|
}
|
|
|
|
terminal.draw(|frame| {
|
|
draw_fn(frame);
|
|
})
|
|
})?
|
|
}
|
|
|
|
pub fn draw_ambient_pet_image(
|
|
&mut self,
|
|
request: Option<crate::pets::AmbientPetDraw>,
|
|
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
|
|
let terminal = &mut self.terminal;
|
|
let state = &mut self.ambient_pet_image_state;
|
|
stdout().sync_update(|_| {
|
|
match crate::pets::render_ambient_pet_image(terminal.backend_mut(), state, request) {
|
|
Ok(()) => Ok(Ok(())),
|
|
Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err),
|
|
Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)),
|
|
}
|
|
})??
|
|
}
|
|
|
|
pub fn draw_pet_picker_preview_image(
|
|
&mut self,
|
|
request: Option<crate::pets::AmbientPetDraw>,
|
|
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
|
|
let terminal = &mut self.terminal;
|
|
let state = &mut self.pet_picker_preview_image_state;
|
|
stdout().sync_update(|_| {
|
|
match crate::pets::render_pet_picker_preview_image(
|
|
terminal.backend_mut(),
|
|
state,
|
|
request,
|
|
) {
|
|
Ok(()) => Ok(Ok(())),
|
|
Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err),
|
|
Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)),
|
|
}
|
|
})??
|
|
}
|
|
|
|
pub fn clear_ambient_pet_image(
|
|
&mut self,
|
|
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
|
|
crate::pets::render_ambient_pet_image(
|
|
self.terminal.backend_mut(),
|
|
&mut self.ambient_pet_image_state,
|
|
/*request*/ None,
|
|
)
|
|
}
|
|
|
|
/// Draw a frame using the resize-reflow viewport and history insertion rules.
|
|
///
|
|
/// This is the feature-gated counterpart to `draw`. It intentionally skips
|
|
/// `pending_viewport_area`, whose cursor-position heuristic is part of the legacy path, and
|
|
/// instead lets transcript reflow rebuild scrollback before the frame is rendered.
|
|
pub fn draw_with_resize_reflow(
|
|
&mut self,
|
|
height: u16,
|
|
draw_fn: impl FnOnce(&mut custom_terminal::Frame),
|
|
) -> Result<()> {
|
|
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
|
|
// in the synchronized update.
|
|
#[cfg(unix)]
|
|
let mut prepared_resume = self
|
|
.suspend_context
|
|
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
|
|
|
|
stdout().sync_update(|_| {
|
|
#[cfg(unix)]
|
|
if let Some(prepared) = prepared_resume.take() {
|
|
prepared.apply(&mut self.terminal)?;
|
|
}
|
|
|
|
let terminal = &mut self.terminal;
|
|
let mut needs_full_repaint =
|
|
Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?;
|
|
let flushed_history = Self::flush_pending_history_lines(
|
|
terminal,
|
|
&mut self.pending_history_lines,
|
|
self.is_zellij,
|
|
)?;
|
|
needs_full_repaint |= flushed_history;
|
|
|
|
if needs_full_repaint {
|
|
terminal.invalidate_viewport();
|
|
}
|
|
|
|
// Update the y position for suspending so Ctrl-Z can place the cursor correctly.
|
|
#[cfg(unix)]
|
|
{
|
|
let area = terminal.viewport_area;
|
|
let inline_area_bottom = if self.alt_screen_active.load(Ordering::Relaxed) {
|
|
self.alt_saved_viewport
|
|
.map(|r| r.bottom().saturating_sub(1))
|
|
.unwrap_or_else(|| area.bottom().saturating_sub(1))
|
|
} else {
|
|
area.bottom().saturating_sub(1)
|
|
};
|
|
self.suspend_context.set_cursor_y(inline_area_bottom);
|
|
}
|
|
|
|
terminal.draw(|frame| {
|
|
draw_fn(frame);
|
|
})
|
|
})?
|
|
}
|
|
|
|
fn pending_viewport_area(&mut self) -> Result<Option<Rect>> {
|
|
let terminal = &mut self.terminal;
|
|
let screen_size = terminal.size()?;
|
|
let last_known_screen_size = terminal.last_known_screen_size;
|
|
if screen_size != last_known_screen_size
|
|
&& let Ok(cursor_pos) = terminal.get_cursor_position()
|
|
{
|
|
let last_known_cursor_pos = terminal.last_known_cursor_pos;
|
|
// If we resized AND the cursor moved, we adjust the viewport area to keep the
|
|
// cursor in the same position. This is a heuristic that seems to work well
|
|
// at least in iTerm2.
|
|
if cursor_pos.y != last_known_cursor_pos.y {
|
|
let offset = Offset {
|
|
x: 0,
|
|
y: cursor_pos.y as i32 - last_known_cursor_pos.y as i32,
|
|
};
|
|
return Ok(Some(terminal.viewport_area.offset(offset)));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
}
|