Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/src/tui_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
175 changes: 175 additions & 0 deletions crates/warp_tui/src/alt_screen_view.rs
Original file line number Diff line number Diff line change
@@ -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<FairMutex<TerminalModel>>,
}

impl AltScreenElement {
pub(crate) fn new(model: Arc<FairMutex<TerminalModel>>) -> 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-<x>, 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<Vec<u8>> {
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+<letter>` combo. The shared legacy encoder
/// only emits C0 bytes for a small `Ctrl+<number>`/`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<Vec<u8>> {
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;
61 changes: 61 additions & 0 deletions crates/warp_tui/src/alt_screen_view_tests.rs
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions crates/warp_tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
mod agent_block;
mod agent_block_sections;
mod alt_screen_view;
mod autoupdate;
mod clipboard;
pub mod input;
Expand Down
33 changes: 32 additions & 1 deletion crates/warp_tui/src/terminal_block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 _;
Expand Down Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions crates/warp_tui/src/terminal_session_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<u8>),
}

/// The authenticated terminal/session surface rendered inside [`RootTuiView`].
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -532,6 +556,7 @@ impl TuiTerminalSessionView {
ai_input_model,
terminal_model: model,
transient_hint: TransientHint::default(),
alt_screen_focus_active: false,
}
}

Expand Down Expand Up @@ -1109,6 +1134,12 @@ impl TuiView for TuiTerminalSessionView {
}

fn render(&self, ctx: &AppContext) -> Box<dyn TuiElement> {
// 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);
Expand Down Expand Up @@ -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,
});
}
}
}
}
Expand Down
Loading