diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index ca672355dc..4d887ee03e 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -45,6 +45,22 @@ use tokio_stream::Stream; /// A type alias for the terminal type used in this application pub type Terminal = CustomTerminal>; +/// Enable the terminal capabilities the Codex TUI depends on. +/// +/// - Enables bracketed paste so multi-line submissions reach [`ChatComposer`] as a single +/// payload. +/// - Switches to raw mode to expose low-level key events without line buffering. +/// - Attempts to push Crossterm keyboard enhancement flags so modifier-aware keys are visible. +/// [`ChatComposer`] listens for modifier-rich enter presses, so we best-effort enable the +/// extension even when consoles may ignore it. +/// - Enables focus reporting so +/// [`ChatWidget::maybe_post_pending_notification`][ChatWidgetNotif] can gate alerts. +/// +/// Ratatui leaves these switches to callers; centralizing them here guarantees the inline viewport +/// starts from a consistent configuration. +/// +/// [`ChatComposer`]: crate::bottom_pane::chat_composer::ChatComposer +/// [ChatWidgetNotif]: crate::chatwidget::ChatWidget::maybe_post_pending_notification pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; @@ -68,6 +84,9 @@ pub fn set_modes() -> Result<()> { Ok(()) } +/// Crossterm command that enables "alternate scroll" (SGR/DECSET 1007) so mouse wheels translate +/// into arrow keys while the alt screen is active. See the xterm control sequence reference +/// () for details. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct EnableAlternateScroll; @@ -89,6 +108,8 @@ impl Command for EnableAlternateScroll { } } +/// Crossterm command that disables SGR/DECSET 1007 so scroll wheels go back to native terminal +/// behavior; refer to the same xterm control sequence documentation for details. #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct DisableAlternateScroll; @@ -111,7 +132,15 @@ impl Command for DisableAlternateScroll { } /// Restore the terminal to its original state. -/// Inverse of `set_modes`. +/// +/// Undo the side effects of [`set_modes`]. +/// +/// - Pops any keyboard enhancement flags that were pushed. +/// - Disables bracketed paste and focus tracking. +/// - Leaves raw mode and ensures the cursor is visible. +/// +/// The disable calls are best-effort because some terminals refuse the matching sequences, +/// especially after a suspend/resume cycle. pub fn restore() -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); @@ -122,7 +151,18 @@ pub fn restore() -> Result<()> { Ok(()) } -/// Initialize the terminal (inline viewport; history stays in normal scrollback) +/// Initialize the inline viewport while preserving scrollback. +/// +/// - Rejects initialization if stdout is not a TTY. +/// - Enables the raw-mode feature set via [`set_modes`]. +/// - Installs a panic hook that restores the terminal before unwinding. +/// +/// Existing terminal contents are left untouched so scrollback stays intact, even on terminals that +/// treat `Clear` as destructive. +/// +/// Unlike [`ratatui::Terminal::new`], this uses our `CustomTerminal` wrapper so the interactive +/// viewport stays inline and history remains accessible in the native scrollback buffer—the main +/// architectural difference from Ratatui's default alt-screen workflow. pub fn init() -> Result { if !stdout().is_terminal() { return Err(std::io::Error::other("stdout is not a terminal")); @@ -136,6 +176,11 @@ pub fn init() -> Result { Ok(tui) } +/// Ensure panics drop the terminal back to cooked mode before surfacing. +/// +/// Ratatui defers this to the consumer; we override the default panic hook so that even unexpected +/// crashes clear raw mode and cursor hiding. The hook delegates to the previous handler after +/// restoration so panic reporting (and test harness output) remains unchanged. fn set_panic_hook() { let hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { @@ -146,25 +191,71 @@ fn set_panic_hook() { #[derive(Debug)] pub enum TuiEvent { + /// Raw `crossterm` key event, including modifier-rich variants unlocked by [`set_modes`]. Key(KeyEvent), + /// Bracketed paste payload delivered as a single string. Paste(String), + /// Request to redraw the UI, typically due to focus changes, resizes, or coalesced frame + /// requests. Draw, } +/// Drives the Codex UI on top of an inline [`CustomTerminal`]. +/// +/// Light fork of Ratatui's terminal that adapts to Codex requirements. +/// +/// - Keeps transcript history in the native scrollback while reserving a configurable inline +/// viewport for interactive UI using [`crate::insert_history::insert_history_lines`]. +/// - Owns the scheduler that coalesces draw requests to avoid redundant rendering work, exposing +/// handles via [`FrameRequester`]. +/// - Manages alt-screen transitions so overlays can temporarily take over the full display through +/// [`enter_alt_screen`] and [`leave_alt_screen`]. +/// - Caches capability probes such as keyboard enhancement support and default palette refreshes. +/// +/// [`FrameRequester`]: crate::tui::FrameRequester +/// [`enter_alt_screen`]: Tui::enter_alt_screen +/// [`leave_alt_screen`]: Tui::leave_alt_screen pub struct Tui { + /// Channel used to schedule future frames; owned by the background coalescer task and cloned + /// into every [`FrameRequester`] handed to widgets. frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, + + /// Broadcast channel that delivers draw notifications to the event stream so the UI loop can + /// wake without holding mutable access to the terminal. draw_tx: tokio::sync::broadcast::Sender<()>, + + /// Inline terminal wrapper that keeps the active viewport and history buffers in sync with + /// Ratatui widgets. pub(crate) terminal: Terminal, + + /// History lines waiting to be spliced above the viewport on the next draw; populated by + /// background tasks that stream transcript updates. pending_history_lines: Vec>, + + /// Saved viewport rectangle from inline mode so alt-screen overlays can be restored to the + /// exact scroll position users left. alt_saved_viewport: Option, + + /// Pending resume action recorded by the event loop when `Ctrl+Z` is processed; applied during + /// the next synchronized update to avoid cursor-query races. #[cfg(unix)] - resume_pending: Arc, // Stores a ResumeAction + resume_pending: Arc, + + /// Cached cursor row where the inline viewport ends, ensuring the shell prompt lands beneath + /// the UI after a suspend. #[cfg(unix)] - suspend_cursor_y: Arc, // Bottom line of inline viewport - // True when overlay alt-screen UI is active + suspend_cursor_y: Arc, + + /// Tracks whether an alt-screen overlay currently owns the terminal so mouse wheel handling and + /// viewport restoration behave correctly. alt_screen_active: Arc, - // True when terminal/tab is focused; updated internally from crossterm events + + /// Reflects the window focus state based on Crossterm events; used to gate OSC 9 notifications + /// and palette refreshes. terminal_focused: Arc, + + /// Whether the terminal acknowledged Crossterm's keyboard enhancement flags; controls key hint + /// rendering and modifier handling in the composer. enhanced_keys_supported: bool, } @@ -172,17 +263,28 @@ pub struct Tui { #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u8)] enum ResumeAction { + /// No post-resume work is required. None = 0, + /// Recenter the inline viewport around the cursor the shell left behind. RealignInline = 1, + /// Re-enter the alternate screen for overlays active when suspend happened. RestoreAlt = 2, } #[cfg(unix)] enum PreparedResumeAction { + /// Restore the alternate screen overlay and refresh its viewport. RestoreAltScreen, + /// Realign the inline viewport to the provided area. RealignViewport(ratatui::layout::Rect), } +/// Swap the pending resume action out of the atomic flag. +/// +/// The event loop records the desired action (realign inline viewport versus restore the alt +/// screen) before invoking [`suspend`]. We clear the flag with relaxed ordering because only the +/// main thread reads it during draw. The integer representation keeps the atomic size small when +/// shared across threads. #[cfg(unix)] fn take_resume_action(pending: &AtomicU8) -> ResumeAction { match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) { @@ -192,14 +294,33 @@ fn take_resume_action(pending: &AtomicU8) -> ResumeAction { } } +/// Handle that lets subsystems ask for a redraw without locking the terminal. +/// +/// Requests are queued onto an unbounded channel and coalesced by the background task spawned in +/// [`Tui::new`]. `schedule_frame_in` exists so call sites can defer work—for example to debounce +/// status updates in [`BottomPane`]. +/// +/// [`BottomPane`]: crate::bottom_pane::BottomPane #[derive(Clone, Debug)] pub struct FrameRequester { + /// Handle to the shared frame scheduler; sending instants through this channel triggers draws + /// once the coalescer decides the deadline has arrived. frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, } + impl FrameRequester { + /// Request an immediate redraw. + /// + /// The scheduler collapses concurrent requests into a single `Draw`, so callers can + /// fire-and-forget without coordinating. pub fn schedule_frame(&self) { let _ = self.frame_schedule_tx.send(Instant::now()); } + /// Request a redraw no earlier than `dur` in the future. + /// + /// Callers use this to debounce follow-up frames (for example, to animate a spinner while + /// waiting on the network). The scheduler still collapses multiple pending deadlines to the + /// earliest instant. pub fn schedule_frame_in(&self, dur: Duration) { let _ = self.frame_schedule_tx.send(Instant::now() + dur); } @@ -217,8 +338,11 @@ impl FrameRequester { } impl Tui { - /// Emit a desktop notification now if the terminal is unfocused. - /// Returns true if a notification was posted. + /// Emit an OSC 9 desktop notification if the terminal pane lacks focus. + /// + /// Returns `true` when a notification escape sequence was written. We only notify when the pane + /// is unfocused so OSC 9 terminals (iTerm2, Kitty, WezTerm) avoid redundant alerts. Terminals + /// that ignore OSC 9 fail silently. pub fn notify(&mut self, message: impl AsRef) -> bool { if !self.terminal_focused.load(Ordering::Relaxed) { let _ = execute!(stdout(), PostNotification(message.as_ref().to_string())); @@ -227,6 +351,15 @@ impl Tui { false } } + + /// Construct the controller around an already-configured terminal backend. + /// + /// - Spawns the background task that coalesces frame requests into `Draw` events, avoiding + /// flicker when history lines arrive in bursts. + /// - Primes capability checks that require talking to the terminal driver so they do not race + /// the async event reader. + /// - Differs from Ratatui's `Terminal::new`, which performs these probes lazily and assumes + /// exclusive control of the event loop. pub fn new(terminal: Terminal) -> Self { let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); let (draw_tx, _) = tokio::sync::broadcast::channel(1); @@ -295,16 +428,35 @@ impl Tui { } } + /// Return a cloneable handle for requesting future draws. + /// + /// Widgets hold onto this so they can redraw from async callbacks without needing mutable + /// access to the `Tui`. The returned handle is cheap to clone because it only contains the + /// underlying channel sender. pub fn frame_requester(&self) -> FrameRequester { FrameRequester { frame_schedule_tx: self.frame_schedule_tx.clone(), } } + /// Returns whether the current terminal reported support for Crossterm's keyboard enhancement + /// flags. + /// + /// Consumers use this to decide whether to show modifier-specific key hints. We cache the probe + /// result because the underlying API talks to the terminal driver, and repeated checks would + /// contend with the event reader. pub fn enhanced_keys_supported(&self) -> bool { self.enhanced_keys_supported } + /// Build the async stream that drives the UI loop. + /// + /// - Merges raw `crossterm` events with draw notifications from the frame scheduler. + /// - Handles suspend/resume (`Ctrl+Z`) without exposing implementation details to callers. + /// - Translates focus events into redraws so palette refreshes take effect immediately. + /// - Surfaces bracketed paste as a first-class variant instead of leaving it to widgets. + /// - Disables the alternate scroll escape on exit from alt-screen mode so the shell prompt + /// behaves normally. pub fn event_stream(&self) -> Pin + Send + 'static>> { use tokio_stream::StreamExt; let mut crossterm_events = crossterm::event::EventStream::new(); @@ -337,9 +489,15 @@ impl Tui { // Disable alternate scroll when suspending from alt-screen let _ = execute!(stdout(), DisableAlternateScroll); let _ = execute!(stdout(), LeaveAlternateScreen); - resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed); + resume_pending.store( + ResumeAction::RestoreAlt as u8, + Ordering::Relaxed, + ); } else { - resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed); + resume_pending.store( + ResumeAction::RealignInline as u8, + Ordering::Relaxed, + ); } #[cfg(unix)] { @@ -376,7 +534,7 @@ impl Tui { yield TuiEvent::Draw; } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - // We dropped one or more draw notifications; coalesce to a single draw. + // We dropped draw notifications; merge the backlog into one draw. yield TuiEvent::Draw; } Err(tokio::sync::broadcast::error::RecvError::Closed) => { @@ -389,6 +547,12 @@ impl Tui { }; Box::pin(event_stream) } + + /// Suspend the process after restoring terminal modes. + /// + /// Triggered internally when the user presses `Ctrl+Z`. The cursor is moved below the inline + /// viewport before the signal so the shell prompt appears beneath the UI once the process + /// stops. On resume [`Tui::draw`] applies any queued viewport adjustments. #[cfg(unix)] fn suspend() -> Result<()> { restore()?; @@ -397,6 +561,12 @@ impl Tui { Ok(()) } + /// Figure out what needs to happen after a suspend before we enter the synchronized update. + /// + /// We determine whether to realign the inline viewport or re-enter the alt-screen outside of + /// the synchronized update lock because querying the cursor while holding the lock can hang in + /// some terminals (WezTerm in particular). Errors from `get_cursor_position` are tolerated so + /// that resume never panics; we fall back to the last known coordinates instead. #[cfg(unix)] fn prepare_resume_action( &mut self, @@ -424,6 +594,11 @@ impl Tui { } } + /// Apply the previously prepared post-resume action inside the synchronized update. + /// + /// Replaying the action here ensures the viewport changes happen atomically with the frame + /// render. When resuming an alt-screen overlay we re-enable the alternate scroll escape so + /// mouse wheels keep mapping to arrow keys. #[cfg(unix)] fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> { match prepared { @@ -448,8 +623,12 @@ impl Tui { Ok(()) } - /// Enter alternate screen and expand the viewport to full terminal size, saving the current - /// inline viewport for restoration when leaving. + /// Enter the alternate screen, expanding the viewport to the full terminal. + /// + /// We snapshot the inline viewport bounds so that leaving the alt screen can restore the inline + /// history view exactly where it left off. Alternate scroll support is enabled here so mouse + /// wheels map to arrow presses while the overlay is active—a deliberate deviation from + /// Ratatui, where alt screen mode is the default rather than an opt-in overlay. pub fn enter_alt_screen(&mut self) -> Result<()> { let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); // Enable "alternate scroll" so terminals may translate wheel to arrows @@ -468,7 +647,11 @@ impl Tui { Ok(()) } - /// Leave alternate screen and restore the previously saved inline viewport, if any. + /// Leave the alternate screen and restore the inline viewport, if present. + /// + /// Alternate scroll is disabled before dropping back to the inline viewport so that shells do + /// not inherit the mapping. If we resumed from a suspend while in the alt screen, the viewport + /// coordinates were already updated in [`prepare_resume_action`]. pub fn leave_alt_screen(&mut self) -> Result<()> { // Disable alternate scroll when leaving alt-screen let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll); @@ -480,11 +663,37 @@ impl Tui { Ok(()) } + /// Queue history lines to be spliced above the inline viewport. + /// + /// The lines are copied into a pending buffer and applied immediately before the next draw (see + /// [`draw`]). This matches our approach of letting the terminal own the transcript so selection + /// and scrollback behave like a regular terminal log. Callers such as + /// [`App::handle_event`] use it whenever new transcript cells render. + /// + /// [`App::handle_event`]: crate::app::App::handle_event pub fn insert_history_lines(&mut self, lines: Vec>) { self.pending_history_lines.extend(lines); self.frame_requester().schedule_frame(); } + /// Render a frame inside the managed viewport. + /// + /// - `height` caps how tall the inline viewport may grow for this frame; the draw closure + /// receives the same `Frame` type that Ratatui exposes. + /// - Gathers cursor-dependent state (notably viewport alignment) before the synchronized update + /// lock so terminals such as WezTerm avoid cursor-query deadlocks. + /// - Applies suspend/resume bookkeeping so `Ctrl+Z` returns to the same viewport layout. + /// - Updates the viewport, splices pending history lines, refreshes the suspend cursor marker, + /// and finally delegates to the caller's draw logic. + /// + /// Compared to Ratatui's stock `Terminal::draw`, the key differences are inline viewport + /// management and history injection; the rest of the rendering pipeline remains unchanged. + /// Primary callers include [`App::handle_tui_event`] for the main chat loop and overlay flows + /// such as [`run_update_prompt_if_needed`] and [`run_resume_picker`]. + /// + /// [`App::handle_tui_event`]: crate::app::App::handle_tui_event + /// [`run_update_prompt_if_needed`]: crate::update_prompt::run_update_prompt_if_needed + /// [`run_resume_picker`]: crate::resume_picker::run_resume_picker pub fn draw( &mut self, height: u16, @@ -572,8 +781,14 @@ impl Tui { } /// Command that emits an OSC 9 desktop notification with a message. +/// +/// Only a subset of terminals (iTerm2, Kitty, WezTerm) honor OSC 9; others ignore the escape +/// sequence, which is acceptable because [`Tui::notify`] treats write errors as non-fatal. #[derive(Debug, Clone)] -pub struct PostNotification(pub String); +pub struct PostNotification( + /// Message to surface via the OSC 9 escape sequence. + pub String, +); impl Command for PostNotification { fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {