diff --git a/app/src/tui_export.rs b/app/src/tui_export.rs index 0879e2e2f98..28f93c139bb 100644 --- a/app/src/tui_export.rs +++ b/app/src/tui_export.rs @@ -84,6 +84,8 @@ pub use crate::terminal::model::blockgrid::BlockGrid; pub use crate::terminal::model::blocks::{ BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem, TotalIndex, }; +pub use crate::terminal::model::escape_sequences::{KeystrokeWithDetails, ToEscapeSequence}; +pub use crate::terminal::model::grid::grid_handler::{GridHandler, TermMode}; pub use crate::terminal::model::rich_content::RichContentType; pub use crate::terminal::model::session::active_session::{ActiveSession, ActiveSessionEvent}; pub use crate::terminal::model::session::Sessions; diff --git a/crates/warp_tui/src/alt_screen_view.rs b/crates/warp_tui/src/alt_screen_view.rs new file mode 100644 index 00000000000..bd60a02aa74 --- /dev/null +++ b/crates/warp_tui/src/alt_screen_view.rs @@ -0,0 +1,175 @@ +//! Full-screen alt-screen rendering + raw keyboard forwarding for the TUI. +//! +//! When a PTY app switches to the alternate screen (vim, htop, less, …), the +//! terminal model flips [`TerminalModel::is_alt_screen_active`] and populates a +//! dedicated alt-screen grid. [`TuiTerminalSessionView`] then renders this +//! element full-area instead of the block/transcript UI, and forwards +//! keystrokes straight to the PTY as escape sequences — mirroring the GUI's +//! `AltScreenElement` (`app/src/terminal/alt_screen/alt_screen_element.rs`). +//! +//! Slice 1 covers rendering, the cursor, and keyboard forwarding. Mouse +//! forwarding and resize polish are tracked as follow-ups. +//! +//! [`TuiTerminalSessionView`]: crate::terminal_session_view::TuiTerminalSessionView +//! [`TerminalModel::is_alt_screen_active`]: warp::tui_export::TerminalModel + +use std::ops::Deref as _; +use std::sync::Arc; + +use parking_lot::FairMutex; +use warp::tui_export::{KeystrokeWithDetails, TermMode, TerminalModel, ToEscapeSequence}; +use warp_terminal::model::grid::Dimensions as _; +use warpui_core::elements::tui::{ + TuiBuffer, TuiConstraint, TuiElement, TuiEvent, TuiEventContext, TuiLayoutContext, + TuiPaintContext, TuiRect, TuiSize, +}; +use warpui_core::keymap::Keystroke; +use warpui_core::AppContext; + +use crate::terminal_block::render_grid_handler; +use crate::terminal_session_view::TuiTerminalSessionAction; + +/// Renders the terminal's alt-screen grid full-area and forwards keystrokes to +/// the PTY while a full-screen app is active. +pub(crate) struct AltScreenElement { + model: Arc>, +} + +impl AltScreenElement { + pub(crate) fn new(model: Arc>) -> Self { + Self { model } + } +} + +impl TuiElement for AltScreenElement { + fn layout( + &mut self, + constraint: TuiConstraint, + _ctx: &mut TuiLayoutContext, + _app: &AppContext, + ) -> TuiSize { + // The alt-screen app owns the whole pane. + constraint.max + } + + fn render(&self, area: TuiRect, buffer: &mut TuiBuffer, _ctx: &mut TuiPaintContext) { + let model = self.model.lock(); + let colors = model.colors(); + render_grid_handler(model.alt_screen().grid_handler(), area, buffer, &colors); + } + + fn cursor_position(&self, area: TuiRect, _ctx: &mut TuiPaintContext) -> Option<(u16, u16)> { + let model = self.model.lock(); + let alt = model.alt_screen(); + if !alt.is_mode_set(TermMode::SHOW_CURSOR) { + return None; + } + let grid = alt.grid_handler(); + let point = grid.cursor_render_point(); + // The alt screen has no scrollback, but subtract history defensively so + // the cursor maps to a visible (screen-relative) row. + let row = point.row.checked_sub(grid.history_size())?; + let col = u16::try_from(point.col).ok()?; + let row = u16::try_from(row).ok()?; + if col >= area.width || row >= area.height { + return None; + } + Some((area.x.saturating_add(col), area.y.saturating_add(row))) + } + + fn dispatch_event( + &mut self, + event: &TuiEvent, + _area: TuiRect, + event_ctx: &mut TuiEventContext, + _ctx: &mut TuiLayoutContext, + _app: &AppContext, + ) -> bool { + let TuiEvent::KeyDown { + keystroke, + chars, + details, + is_composing, + } = event + else { + // Mouse forwarding is a follow-up slice. + return false; + }; + if *is_composing { + return false; + } + // Leave ctrl-c to the root/session fixed binding as a guaranteed escape + // hatch, so a full-screen app can never trap the user in the TUI. (v1; + // routing ctrl-c to the app can follow once exit UX is settled.) + if keystroke.ctrl && keystroke.key == "c" { + return false; + } + // Resolve the bytes to send to the PTY, in priority order: + // 1. Special/control keys (arrows, ctrl-, fn keys, …) that encode to + // an escape sequence via the shared terminal encoder. + // 2. Plain printable keys — the GUI routes these through a separate + // typed-characters path, which the TUI event model folds into + // `chars`; forward their UTF-8 bytes. + // 3. Named control keys the encoder doesn't cover and that carry no + // `chars` (escape, enter, tab, backspace) — map to their C0 bytes so + // e.g. Escape leaves an editor's insert mode. + let escape_sequence = { + let model = self.model.lock(); + KeystrokeWithDetails { + keystroke, + key_without_modifiers: details.key_without_modifiers.as_deref(), + chars: Some(chars.as_str()), + } + .to_escape_sequence(model.deref()) + }; + let bytes = escape_sequence + .or_else(|| ctrl_letter_c0(keystroke)) + .or_else(|| fallback_key_bytes(&keystroke.key, chars)); + let Some(bytes) = bytes else { + return false; + }; + event_ctx.dispatch_typed_action(TuiTerminalSessionAction::ForwardToPty(bytes)); + true + } +} + +/// PTY bytes for a key the shared terminal encoder didn't map to an escape +/// sequence (see [`ToEscapeSequence`]). Plain printable keys carry their text in +/// `chars` and forward its UTF-8 bytes; a few named control keys carry empty +/// `chars` and no encoder mapping, so map them to their C0 bytes (notably +/// `escape`, so it can leave an editor's insert mode). Returns `None` when there +/// is nothing to send. +fn fallback_key_bytes(key: &str, chars: &str) -> Option> { + if !chars.is_empty() { + return Some(chars.as_bytes().to_vec()); + } + match key { + "enter" => Some(vec![b'\r']), + "escape" => Some(vec![0x1b]), + "\t" => Some(vec![b'\t']), + "backspace" => Some(vec![0x7f]), + _ => None, + } +} + +/// The C0 control byte for a `Ctrl+` combo. The shared legacy encoder +/// only emits C0 bytes for a small `Ctrl+`/`Ctrl+space` set unless the +/// kitty keyboard protocol is active, while the TUI key conversion still puts +/// the printable letter in `chars` for `Ctrl+A`..`Ctrl+Z`. Without this, those +/// combos would be forwarded as plain letters (e.g. `a` instead of `0x01`), +/// breaking control shortcuts in full-screen apps. `Ctrl+C` never reaches here +/// — it's handled earlier as the TUI exit escape hatch. +fn ctrl_letter_c0(keystroke: &Keystroke) -> Option> { + if !keystroke.ctrl { + return None; + } + match keystroke.key.as_bytes() { + // `& 0x1f` folds a/A..z/Z onto 0x01..0x1A (Ctrl+A = 0x01, Ctrl+Z = 0x1A). + [byte] if byte.is_ascii_alphabetic() => Some(vec![byte.to_ascii_uppercase() & 0x1f]), + _ => None, + } +} + +#[cfg(test)] +#[path = "alt_screen_view_tests.rs"] +mod tests; diff --git a/crates/warp_tui/src/alt_screen_view_tests.rs b/crates/warp_tui/src/alt_screen_view_tests.rs new file mode 100644 index 00000000000..73001cdff1f --- /dev/null +++ b/crates/warp_tui/src/alt_screen_view_tests.rs @@ -0,0 +1,61 @@ +use warpui_core::keymap::Keystroke; + +use super::{ctrl_letter_c0, fallback_key_bytes}; + +fn ctrl(key: &str) -> Keystroke { + Keystroke { + ctrl: true, + alt: false, + shift: false, + cmd: false, + meta: false, + key: key.to_owned(), + } +} + +#[test] +fn printable_keys_forward_their_utf8_bytes() { + assert_eq!(fallback_key_bytes("a", "a"), Some(b"a".to_vec())); + assert_eq!(fallback_key_bytes("A", "A"), Some(b"A".to_vec())); + assert_eq!(fallback_key_bytes(" ", " "), Some(b" ".to_vec())); + // A non-ASCII grapheme forwards its full UTF-8 encoding. + assert_eq!(fallback_key_bytes("é", "é"), Some("é".as_bytes().to_vec())); +} + +#[test] +fn named_control_keys_map_to_c0_bytes() { + // Escape is the important one: it lets the user leave insert mode in a + // full-screen editor. Enter/tab/backspace round out the common set. + assert_eq!(fallback_key_bytes("escape", ""), Some(vec![0x1b])); + assert_eq!(fallback_key_bytes("enter", ""), Some(vec![b'\r'])); + assert_eq!(fallback_key_bytes("\t", ""), Some(vec![b'\t'])); + assert_eq!(fallback_key_bytes("backspace", ""), Some(vec![0x7f])); +} + +#[test] +fn unmapped_key_without_chars_sends_nothing() { + // Keys the encoder handles (arrows, fn keys) never reach this fallback; + // anything else with no text produces no PTY bytes. + assert_eq!(fallback_key_bytes("f5", ""), None); + assert_eq!(fallback_key_bytes("insert", ""), None); +} + +#[test] +fn ctrl_letters_map_to_c0_control_bytes() { + // Ctrl+A..Ctrl+Z must forward as 0x01..0x1A, not the printable letter + // the TUI conversion leaves in `chars`. + assert_eq!(ctrl_letter_c0(&ctrl("a")), Some(vec![0x01])); + assert_eq!(ctrl_letter_c0(&ctrl("d")), Some(vec![0x04])); + assert_eq!(ctrl_letter_c0(&ctrl("z")), Some(vec![0x1a])); + // Shifted letter (Ctrl+Shift+A) folds onto the same control byte. + assert_eq!(ctrl_letter_c0(&ctrl("A")), Some(vec![0x01])); +} + +#[test] +fn ctrl_non_letters_and_plain_keys_do_not_map() { + assert_eq!(ctrl_letter_c0(&ctrl("1")), None); + assert_eq!(ctrl_letter_c0(&ctrl("enter")), None); + let mut plain = ctrl("a"); + plain.ctrl = false; + assert_eq!(ctrl_letter_c0(&plain), None); +} diff --git a/crates/warp_tui/src/lib.rs b/crates/warp_tui/src/lib.rs index bd8a43b6079..9bb6c817cd0 100644 --- a/crates/warp_tui/src/lib.rs +++ b/crates/warp_tui/src/lib.rs @@ -9,6 +9,7 @@ mod agent_block; mod agent_block_sections; +mod alt_screen_view; mod autoupdate; mod clipboard; pub mod input; diff --git a/crates/warp_tui/src/terminal_block.rs b/crates/warp_tui/src/terminal_block.rs index 47ab3ab1268..44294e81695 100644 --- a/crates/warp_tui/src/terminal_block.rs +++ b/crates/warp_tui/src/terminal_block.rs @@ -4,7 +4,9 @@ use std::ops::Range; use std::sync::Arc; use parking_lot::FairMutex; -use warp::tui_export::{Block, BlockGrid, BlockId, BlockList, TerminalColorList, TerminalModel}; +use warp::tui_export::{ + Block, BlockGrid, BlockId, BlockList, GridHandler, TerminalColorList, TerminalModel, +}; use warp_terminal::model::ansi::{Color, NamedColor}; use warp_terminal::model::grid::cell::{Cell, Flags}; use warp_terminal::model::grid::Dimensions as _; @@ -179,6 +181,35 @@ fn render_block_rows( } } +/// Paints the visible rows of a raw [`GridHandler`] (e.g. the alt screen, +/// which has no scrollback) into `area`, reusing the same per-cell styling as +/// the block renderer. Unlike a block grid, the alt screen is a plain viewport, +/// so rows map directly to screen rows (offset past any history defensively). +pub(super) fn render_grid_handler( + grid: &GridHandler, + area: TuiRect, + buffer: &mut TuiBuffer, + colors: &TerminalColorList, +) { + let history = grid.history_size(); + let rows = grid.visible_rows().min(usize::from(area.height)); + let cols = grid.columns().min(usize::from(area.width)); + for screen_row in 0..rows { + let Some(row) = grid.row(history + screen_row) else { + continue; + }; + let y = area.y.saturating_add(screen_row as u16); + for column in 0..cols { + let cell = &row[column]; + if let Some(buffer_cell) = buffer.cell_mut((area.x.saturating_add(column as u16), y)) { + buffer_cell + .set_symbol(&sanitized_symbol(cell)) + .set_style(cell_to_style(cell, colors)); + } + } + } +} + /// Returns whether the TUI transcript should include this terminal block. pub(super) fn should_render_terminal_block(block: &Block, block_list: &BlockList) -> bool { // Agent-requested command blocks are rendered inline inside their agent diff --git a/crates/warp_tui/src/terminal_session_view.rs b/crates/warp_tui/src/terminal_session_view.rs index c4ef3beba83..a7b86304986 100644 --- a/crates/warp_tui/src/terminal_session_view.rs +++ b/crates/warp_tui/src/terminal_session_view.rs @@ -42,6 +42,7 @@ use warpui_core::{ AppContext, Entity, EntityId, ModelHandle, TuiView, TypedActionView, ViewContext, ViewHandle, }; +use crate::alt_screen_view::AltScreenElement; use crate::autoupdate::{TuiAutoupdater, TuiAutoupdaterEvent}; use crate::clipboard::copy_to_clipboard; use crate::conversation_selection::TuiConversationSelection; @@ -126,6 +127,10 @@ pub(crate) enum TuiTerminalSessionAction { /// Click on the footer's usage entry: flips the persisted credits⇄cost /// display-mode setting. ToggleUsageDisplay, + /// Raw bytes to forward to the PTY, produced by the alt-screen element + /// while a full-screen app is active (keystrokes encoded to escape + /// sequences). Delivered to the manager as a [`PtyIntent::WriteAgentInput`]. + ForwardToPty(Vec), } /// The authenticated terminal/session surface rendered inside [`RootTuiView`]. @@ -156,6 +161,11 @@ pub(crate) struct TuiTerminalSessionView { /// Transient notice shown in the footer's hint slot (e.g. a rejected /// shell submission). transient_hint: TransientHint, + /// Whether focus is currently held off the input editor because an + /// alt-screen app is active (so its raw keystrokes reach the PTY instead of + /// the input keymap). Tracks the enter/exit transition so focus is only + /// moved once per transition, not on every wakeup. + alt_screen_focus_active: bool, } /// Registers the session surface's keybindings. Called once at TUI startup @@ -497,12 +507,26 @@ impl TuiTerminalSessionView { ctx.spawn_stream_local( throttle(WAKEUP_THROTTLE_PERIOD, wakeups_rx), |view, _, ctx| { - { + let alt_screen_active = { let mut model = view.terminal_model.lock(); - if !model.is_alt_screen_active() { + let alt_screen_active = model.is_alt_screen_active(); + if !alt_screen_active { model.block_list_mut().update_background_block_height(); model.block_list_mut().update_active_block_height(); } + alt_screen_active + }; + + // Hand keyboard focus to the alt-screen app while it's active + // (so keys reach the PTY rather than the hidden input editor's + // keymap), and restore input focus when it exits. + if alt_screen_active != view.alt_screen_focus_active { + view.alt_screen_focus_active = alt_screen_active; + if alt_screen_active { + ctx.focus_self(); + } else { + ctx.focus(&view.input_view); + } } ctx.notify(); @@ -532,6 +556,7 @@ impl TuiTerminalSessionView { ai_input_model, terminal_model: model, transient_hint: TransientHint::default(), + alt_screen_focus_active: false, } } @@ -1109,6 +1134,12 @@ impl TuiView for TuiTerminalSessionView { } fn render(&self, ctx: &AppContext) -> Box { + // While a full-screen (alt-screen) app is active, hand the whole pane to + // it: render its grid and forward input, instead of the block UI. + if self.terminal_model.lock().is_alt_screen_active() { + return AltScreenElement::new(self.terminal_model.clone()).finish(); + } + let inline_menu = self.inline_menu.render(ctx); // The border takes the shell-mode accent while in shell mode. let builder = TuiUiBuilder::from_app(ctx); @@ -1216,6 +1247,14 @@ impl TypedActionView for TuiTerminalSessionView { match action { TuiTerminalSessionAction::Interrupt => self.handle_interrupt(ctx), TuiTerminalSessionAction::ToggleUsageDisplay => self.toggle_usage_display(ctx), + TuiTerminalSessionAction::ForwardToPty(bytes) => { + // Raw passthrough: the bytes are already the app's escape + // sequence, so write them to the PTY unmodified. + ctx.emit(TuiTerminalSessionEvent::WriteAgentInput { + bytes: Cow::Owned(bytes.clone()), + mode: AIAgentPtyWriteMode::Raw, + }); + } } } }