From b4a17e4346c97d837d0ee3a3a55c5ceca789a3e8 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Mon, 9 Mar 2026 14:28:32 -0700 Subject: feat: use pty proxy for rendering tui popups without clearing the terminal (#3234) It feels much, much nicer this way. This has also been asked for pretty consistently since we made inline rendering the default. Now we can have everything :) Maintains a shadow vt100 renderer so that we can restore the terminal state upon popup close. This happens on a background thread, so our impact on terminal performance should still be super minimal, if anything ## Checks - [ ] I am happy for maintainers to push small adjustments to this PR, to speed up the review cycle - [ ] I have checked that there are no existing pull requests for the same thing --- crates/atuin-ai/src/commands/debug_render.rs | 8 +- crates/atuin-ai/src/commands/inline.rs | 65 +- crates/atuin-ai/src/tui/mod.rs | 2 + crates/atuin-ai/src/tui/popup.rs | 363 ++++++++++++ crates/atuin-ai/src/tui/render.rs | 108 ++-- crates/atuin-ai/src/tui/terminal.rs | 77 ++- crates/atuin-ai/src/tui/view_model.rs | 75 ++- crates/atuin-hex/Cargo.toml | 21 + crates/atuin-hex/src/lib.rs | 465 +++++++++++++++ crates/atuin-hex/src/osc133.rs | 657 +++++++++++++++++++++ crates/atuin-shell/Cargo.toml | 24 - crates/atuin-shell/src/main.rs | 342 ----------- crates/atuin-shell/src/osc133.rs | 657 --------------------- crates/atuin/Cargo.toml | 4 +- .../atuin/src/command/client/search/interactive.rs | 271 ++++++++- crates/atuin/src/command/mod.rs | 13 + 16 files changed, 2047 insertions(+), 1105 deletions(-) create mode 100644 crates/atuin-ai/src/tui/popup.rs create mode 100644 crates/atuin-hex/Cargo.toml create mode 100644 crates/atuin-hex/src/lib.rs create mode 100644 crates/atuin-hex/src/osc133.rs delete mode 100644 crates/atuin-shell/Cargo.toml delete mode 100644 crates/atuin-shell/src/main.rs delete mode 100644 crates/atuin-shell/src/osc133.rs (limited to 'crates') diff --git a/crates/atuin-ai/src/commands/debug_render.rs b/crates/atuin-ai/src/commands/debug_render.rs index e78a418a..b35d73c9 100644 --- a/crates/atuin-ai/src/commands/debug_render.rs +++ b/crates/atuin-ai/src/commands/debug_render.rs @@ -219,6 +219,8 @@ pub async fn run(input_file: Option, format: OutputFormat) -> Result<()> anchor_col: 0, textarea: Some(&state.textarea), max_height: debug_input.height, + popup_mode: false, + render_above: false, }; terminal.draw(|frame| { @@ -245,7 +247,11 @@ fn blocks_to_json(blocks: &Blocks) -> serde_json::Value { "title": block.title, "content": block.content.iter().map(content_to_json).collect::>() }) - }).collect::>() + }).collect::>(), + "status_bar": blocks.status_bar.as_ref().map(|sb| serde_json::json!({ + "frame": sb.frame, + "text": sb.text + })) }) } diff --git a/crates/atuin-ai/src/commands/inline.rs b/crates/atuin-ai/src/commands/inline.rs index 67241574..cd670bf8 100644 --- a/crates/atuin-ai/src/commands/inline.rs +++ b/crates/atuin-ai/src/commands/inline.rs @@ -375,7 +375,7 @@ impl DebugStateLogger { .unwrap_or(0); // Calculate the actual content height needed for this state - let content_height = calculate_needed_height(state); + let content_height = calculate_needed_height(state, 0); let mut state_json = state_to_json(state); // Add dimensions for accurate replay @@ -405,7 +405,22 @@ async fn run_inline_tui( debug_state_file: Option, settings: &atuin_client::settings::Settings, ) -> Result<(Action, String)> { - // Initialize terminal guard and app state + // Detect popup mode (only on Unix where atuin-hex socket is available) + #[cfg(unix)] + let mut popup_state = crate::tui::popup::try_setup_popup(); + #[cfg(not(unix))] + let mut popup_state: Option<()> = None; + + let popup_mode = popup_state.is_some(); + + // Initialize terminal guard: popup mode uses Fixed viewport, inline uses Inline + #[cfg(unix)] + let mut guard = if let Some(ref ps) = popup_state { + TerminalGuard::new_popup(ps.current_rect, ps.saved_screen.cursor_col)? + } else { + TerminalGuard::new(keep_output)? + }; + #[cfg(not(unix))] let mut guard = TerminalGuard::new(keep_output)?; let mut app = App::new(); if let Some(prompt) = initial_prompt { @@ -451,16 +466,54 @@ async fn run_inline_tui( loop { // Ensure viewport is large enough for current content (capped at terminal height) - let needed_height = calculate_needed_height(&app.state); + // In popup mode, use the actual popup width for accurate height calculation + let card_width = if popup_mode { + #[cfg(unix)] + { + popup_state + .as_ref() + .map(|ps| { + ps.current_rect + .width + .saturating_sub(crate::tui::popup::POPUP_MARGIN * 2) + }) + .unwrap_or(0) + } + #[cfg(not(unix))] + { + 0 + } + } else { + 0 + }; + let needed_height = calculate_needed_height(&app.state, card_width); + + // Grow popup dynamically as content arrives + #[cfg(unix)] + if let Some(ref mut ps) = popup_state { + // Add vertical margin for visual separation from terminal content + let popup_height = needed_height.saturating_add(crate::tui::popup::POPUP_MARGIN * 2); + if let Some(new_rect) = ps.fit_to(popup_height) { + guard.resize_popup(new_rect)?; + } + } + let actual_height = guard.ensure_height(needed_height)?; // Render current state let anchor_col = guard.anchor_col(); + #[cfg(unix)] + let render_above = popup_state.as_ref().is_some_and(|ps| ps.render_above); + #[cfg(not(unix))] + let render_above = false; + let ctx = RenderContext { theme, anchor_col, textarea: Some(&app.state.textarea), max_height: actual_height, + popup_mode, + render_above, }; // Handle draw errors gracefully - cursor position reads can fail during resize if let Err(e) = guard.terminal().draw(|frame| { @@ -597,6 +650,12 @@ async fn run_inline_tui( } } + // Restore popup area before guard drops (guard skips cleanup in popup mode) + #[cfg(unix)] + if let Some(ref ps) = popup_state { + crate::tui::popup::restore(ps); + } + // Map exit action to return value let result = match app.state.exit_action { Some(ExitAction::Execute(cmd)) => (Action::Execute, cmd), diff --git a/crates/atuin-ai/src/tui/mod.rs b/crates/atuin-ai/src/tui/mod.rs index dbf4457b..03a9c007 100644 --- a/crates/atuin-ai/src/tui/mod.rs +++ b/crates/atuin-ai/src/tui/mod.rs @@ -1,5 +1,7 @@ pub mod app; pub mod event; +#[cfg(unix)] +pub mod popup; pub mod render; pub mod spinner; pub mod state; diff --git a/crates/atuin-ai/src/tui/popup.rs b/crates/atuin-ai/src/tui/popup.rs new file mode 100644 index 00000000..c62b0e62 --- /dev/null +++ b/crates/atuin-ai/src/tui/popup.rs @@ -0,0 +1,363 @@ +use ratatui::layout::Rect; + +/// Maximum popup height (lines). Keeps context visible around the popup. +const MAX_POPUP_HEIGHT: u16 = 24; + +/// Minimum usable popup height. +const MIN_POPUP_HEIGHT: u16 = 5; + +/// Initial popup height — just enough for input + a small response. +const INITIAL_POPUP_HEIGHT: u16 = 5; + +/// Margin around the card in popup mode. +pub(crate) const POPUP_MARGIN: u16 = 0; + +/// Screen state captured from atuin-hex's screen server. +pub struct SavedScreen { + #[allow(dead_code)] + pub rows: u16, + #[allow(dead_code)] + pub cols: u16, + pub cursor_row: u16, + pub cursor_col: u16, + /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. + pub rows_data: Vec>, +} + +/// Popup mode state: saved screen + computed placement. +pub struct PopupState { + pub saved_screen: SavedScreen, + /// Maximum rect computed from placement (the ceiling for growth). + pub max_rect: Rect, + /// Current rect — starts small, grows as content arrives. + pub current_rect: Rect, + pub scroll_offset: u16, + /// True when the popup renders above the cursor (input at bottom of card). + pub render_above: bool, +} + +impl PopupState { + /// Resize the popup to fit `needed` lines of content. + /// + /// Grows or shrinks the popup as needed (clamped to max_rect / INITIAL_POPUP_HEIGHT). + /// When growing, clears the new rect area. When shrinking, restores freed rows + /// from the saved screen data. + /// + /// Returns `Some(new_rect)` if the size changed (caller must resize terminal), + /// or `None` if no change is needed. + pub fn fit_to(&mut self, needed: u16) -> Option { + let new_height = needed.clamp(INITIAL_POPUP_HEIGHT, self.max_rect.height); + if new_height == self.current_rect.height { + return None; + } + + let old_rect = self.current_rect; + let growing = new_height > old_rect.height; + + if self.render_above { + let new_y = self.max_rect.y + self.max_rect.height - new_height; + self.current_rect = Rect::new(old_rect.x, new_y, old_rect.width, new_height); + } else { + self.current_rect = Rect::new(old_rect.x, old_rect.y, old_rect.width, new_height); + } + + if growing { + // Clear the entire new rect so the new Terminal doesn't leave + // ghost content from the old card. + self.clear_rows( + self.current_rect.y, + self.current_rect.y + self.current_rect.height, + ); + } else { + // Shrinking: restore freed rows from saved screen data, then + // clear the new (smaller) rect for the re-rendered card. + self.restore_rows(&old_rect); + self.clear_rows( + self.current_rect.y, + self.current_rect.y + self.current_rect.height, + ); + } + + Some(self.current_rect) + } + + /// Clear a range of terminal rows within the popup width. + fn clear_rows(&self, from_row: u16, to_row: u16) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let mut out = stdout(); + for row in from_row..to_row { + let _ = execute!( + out, + MoveTo(self.current_rect.x, row), + SetAttribute(Attribute::Reset) + ); + let _ = write!( + out, + "{:width$}", + "", + width = self.current_rect.width as usize + ); + } + let _ = out.flush(); + } + + /// Restore rows that were freed by shrinking — the rows in old_rect + /// that are no longer covered by current_rect. + fn restore_rows(&self, old_rect: &Rect) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let mut out = stdout(); + + // Determine which rows are freed + let (freed_start, freed_end) = if self.render_above { + // Shrinking from above: freed rows are at the old top + (old_rect.y, self.current_rect.y) + } else { + // Shrinking from below: freed rows are at the old bottom + ( + self.current_rect.y + self.current_rect.height, + old_rect.y + old_rect.height, + ) + }; + + for row in freed_start..freed_end { + let source_row = (row + self.scroll_offset) as usize; + + // Clear the popup region + let _ = execute!(out, MoveTo(old_rect.x, row), SetAttribute(Attribute::Reset),); + let _ = write!(out, "{:width$}", "", width = old_rect.width as usize); + + // Write back saved row data from column 0 + let _ = execute!(out, MoveTo(0, row)); + if let Some(row_bytes) = self.saved_screen.rows_data.get(source_row) { + let _ = out.write_all(row_bytes); + } + } + let _ = out.flush(); + } +} + +/// Try to set up popup overlay mode. +/// +/// Checks for `ATUIN_HEX_SOCKET`, fetches screen state, computes placement, +/// and scrolls the terminal if needed. Returns `None` if popup mode is not +/// available (no socket, fetch failed, etc.), in which case the caller should +/// fall back to inline mode. +pub fn try_setup_popup() -> Option { + use std::io::Write; + + let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok()?; + let saved = fetch_screen_state(&socket_path)?; + + let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((saved.cols, saved.rows)); + // Full-width popup with margin for visual separation + let popup_width = term_cols; + let (rect, scroll, render_above) = compute_popup_placement( + saved.cursor_row, + saved.cursor_col, + term_rows, + term_cols, + popup_width, + ); + + // Scroll terminal up if needed to make room for the popup + if scroll > 0 { + let mut stdout = std::io::stdout(); + let _ = crossterm::execute!(stdout, crossterm::cursor::MoveTo(0, term_rows - 1)); + for _ in 0..scroll { + let _ = writeln!(stdout); + } + let _ = stdout.flush(); + } + + // Start with a small rect that grows as content arrives + let initial_height = INITIAL_POPUP_HEIGHT.min(rect.height); + let current_rect = if render_above { + // Anchor at the bottom of max_rect (near cursor), grow upward + Rect::new( + rect.x, + rect.y + rect.height - initial_height, + rect.width, + initial_height, + ) + } else { + // Anchor at the top of max_rect (near cursor), grow downward + Rect::new(rect.x, rect.y, rect.width, initial_height) + }; + + Some(PopupState { + saved_screen: saved, + max_rect: rect, + current_rect, + scroll_offset: scroll, + render_above, + }) +} + +/// Restore the screen area that was covered by the popup. +/// +/// Clears the popup region, then writes pre-formatted per-row ANSI bytes from +/// column 0 to correctly restore wide characters, colors, and all attributes. +pub fn restore(state: &PopupState) { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::{Write, stdout}; + + let saved = &state.saved_screen; + let popup_rect = state.current_rect; + let scroll_offset = state.scroll_offset; + + let mut stdout = stdout(); + + for dy in 0..popup_rect.height { + let target_row = popup_rect.y + dy; + let source_row = (target_row + scroll_offset) as usize; + + // Clear only the popup region with spaces + let _ = execute!( + stdout, + MoveTo(popup_rect.x, target_row), + SetAttribute(Attribute::Reset), + ); + let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); + + // Write back full row ANSI data from column 0 + let _ = execute!(stdout, MoveTo(0, target_row)); + if let Some(row_bytes) = saved.rows_data.get(source_row) { + let _ = stdout.write_all(row_bytes); + } + } + + // Restore cursor position (adjusted for any scrolling) + let _ = execute!( + stdout, + MoveTo( + saved.cursor_col, + saved.cursor_row.saturating_sub(scroll_offset) + ) + ); + let _ = stdout.flush(); +} + +/// Connect to atuin-hex's Unix socket and fetch the current screen state. +/// +/// The wire format is: +/// ```text +/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] +/// [row_0_len: u32 BE][row_0_bytes...] +/// [row_1_len: u32 BE][row_1_bytes...] +/// ... +/// ``` +fn fetch_screen_state(socket_path: &str) -> Option { + use std::io::Read; + use std::os::unix::net::UnixStream; + use std::time::Duration; + + let mut stream = UnixStream::connect(socket_path).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + + let mut data = Vec::new(); + stream.read_to_end(&mut data).ok()?; + + if data.len() < 8 { + return None; + } + + let rows = u16::from_be_bytes([data[0], data[1]]); + let cols = u16::from_be_bytes([data[2], data[3]]); + let cursor_row = u16::from_be_bytes([data[4], data[5]]); + let cursor_col = u16::from_be_bytes([data[6], data[7]]); + + let mut rows_data = Vec::with_capacity(rows as usize); + let mut offset = 8; + while offset + 4 <= data.len() { + let row_len = u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) as usize; + offset += 4; + if offset + row_len > data.len() { + break; + } + rows_data.push(data[offset..offset + row_len].to_vec()); + offset += row_len; + } + + Some(SavedScreen { + rows, + cols, + cursor_row, + cursor_col, + rows_data, + }) +} + +/// Compute popup placement for the AI card. +/// +/// Positions the popup near the cursor: below if there's room, above otherwise. +/// Uses a capped height (MAX_POPUP_HEIGHT) so the popup doesn't fill the screen. +/// +/// Returns `(popup_rect, scroll_offset, render_above)`: +/// - `render_above`: true when popup is above cursor (input should be at bottom) +/// - `scroll_offset`: lines the caller should scroll the terminal up +fn compute_popup_placement( + cursor_row: u16, + cursor_col: u16, + term_rows: u16, + term_cols: u16, + card_width: u16, +) -> (Rect, u16, bool) { + // Horizontal: anchor card near cursor, clamp to screen + let popup_w = card_width.min(term_cols); + let preferred_x = cursor_col.saturating_sub(2); + let max_x = term_cols.saturating_sub(popup_w); + let popup_x = preferred_x.min(max_x); + + // Vertical: use a reasonable height, not the full terminal + let max_h = MAX_POPUP_HEIGHT + .min(term_rows.saturating_sub(2)) + .max(MIN_POPUP_HEIGHT); + let space_above = cursor_row; + let space_below = term_rows.saturating_sub(cursor_row); + + if max_h <= space_below { + // Fits below cursor — input at top (close to prompt) + let popup_y = cursor_row; + (Rect::new(popup_x, popup_y, popup_w, max_h), 0, false) + } else if max_h <= space_above { + // Fits above cursor — input at bottom (close to prompt) + let popup_y = cursor_row.saturating_sub(max_h); + (Rect::new(popup_x, popup_y, popup_w, max_h), 0, true) + } else { + // Neither side fits fully — use whichever side has more space, + // scrolling the terminal if needed to reach MIN_POPUP_HEIGHT. + let render_above = space_above > space_below; + let available = if render_above { + space_above + } else { + space_below + }; + let h = available.max(MIN_POPUP_HEIGHT).min(max_h); + let scroll = h.saturating_sub(available); + let popup_y = if render_above { + cursor_row.saturating_sub(h + scroll) + } else { + cursor_row.saturating_sub(scroll) + }; + ( + Rect::new(popup_x, popup_y, popup_w, h), + scroll, + render_above, + ) + } +} diff --git a/crates/atuin-ai/src/tui/render.rs b/crates/atuin-ai/src/tui/render.rs index 0b6341e6..9326b0df 100644 --- a/crates/atuin-ai/src/tui/render.rs +++ b/crates/atuin-ai/src/tui/render.rs @@ -15,7 +15,7 @@ use super::state::AppState; use super::view_model::{Blocks, Content, WarningKind}; /// Fixed card width for the TUI -const CARD_WIDTH: u16 = 64; +pub(crate) const CARD_WIDTH: u16 = 64; pub struct RenderContext<'a> { pub theme: &'a Theme, @@ -23,15 +23,26 @@ pub struct RenderContext<'a> { pub textarea: Option<&'a TextArea<'static>>, /// Maximum viewport height (for scroll calculations) pub max_height: u16, + /// When true, the viewport is a fixed rect already positioned for the card. + /// The card fills the entire viewport instead of positioning via anchor_col. + pub popup_mode: bool, + /// When true, blocks are rendered in reverse order so that the input field + /// appears at the bottom of the card (close to the prompt when the popup + /// is above the cursor). + pub render_above: bool, } /// Calculate the height needed to render the current state. /// Used to dynamically resize the viewport before rendering. -pub fn calculate_needed_height(state: &AppState) -> u16 { - use super::state::AppMode; - +/// `card_width` is the outer card width (including borders); pass 0 to use CARD_WIDTH default. +pub fn calculate_needed_height(state: &AppState, card_width: u16) -> u16 { let view = Blocks::from_state(state); - let content_width = usize::from(CARD_WIDTH.saturating_sub(4)).max(1); + let w = if card_width > 0 { + card_width + } else { + CARD_WIDTH + }; + let content_width = usize::from(w.saturating_sub(4)).max(1); let mut total_height = 0u16; for (idx, block) in view.items.iter().enumerate() { @@ -43,19 +54,6 @@ pub fn calculate_needed_height(state: &AppState) -> u16 { total_height.saturating_add(calculate_block_height(&block.content, content_width)); } - // In Streaming/Generating mode, always reserve space for spinner block even during - // the 200ms delay when it's not yet shown. This prevents the UI from briefly - // shrinking and scrolling away the user message. - let has_spinner_block = view.items.iter().any(|b| { - b.content - .iter() - .any(|c| matches!(c, Content::Spinner { .. })) - }); - if matches!(state.mode, AppMode::Streaming | AppMode::Generating) && !has_spinner_block { - // Reserve space for separator (2 lines) + spinner block (1 line) - total_height = total_height.saturating_add(3); - } - // Add borders (2) + top padding (1), minimum 5 total_height.saturating_add(3).max(5) } @@ -70,19 +68,43 @@ pub fn render(frame: &mut Frame, state: &AppState, ctx: &RenderContext) { } fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { - let area = frame.area(); - - // Calculate frame dimensions (fixed width, min 32 if terminal is narrow) - let desired_width = CARD_WIDTH.min(area.width.saturating_sub(2)).max(32); + let full_area = frame.area(); + + // In popup mode, the viewport is already positioned and sized for the card. + // Clear it to prevent background bleed-through, then inset by margin for the card. + let (area, card_x, desired_width) = if ctx.popup_mode { + #[cfg(unix)] + use super::popup::POPUP_MARGIN; + #[cfg(not(unix))] + const POPUP_MARGIN: u16 = 0; + frame.render_widget(ratatui::widgets::Clear, full_area); + let inset = full_area.inner(ratatui::layout::Margin { + horizontal: POPUP_MARGIN, + vertical: POPUP_MARGIN, + }); + (inset, inset.x, inset.width) + } else { + let dw = CARD_WIDTH.min(full_area.width.saturating_sub(2)).max(32); + let max_x = full_area.x + full_area.width.saturating_sub(dw); + let preferred_x = full_area.x + ctx.anchor_col.saturating_sub(2); + (full_area, preferred_x.min(max_x), dw) + }; let content_width = usize::from(desired_width.saturating_sub(4)).max(1); - // Position at anchor_col - let max_x = area.x + area.width.saturating_sub(desired_width); - let preferred_x = area.x + ctx.anchor_col.saturating_sub(2); + // Build ordered items list — the active content (input/LLM response) + // should always be closest to the cursor/prompt: + // - Popup below cursor (render_above=false): reverse so active is at top + // - Popup above cursor (render_above=true): normal order, active is at bottom + // - Inline mode: normal order (no reversal) + let items: Vec<&super::view_model::Block> = if ctx.popup_mode && !ctx.render_above { + view.items.iter().rev().collect() + } else { + view.items.iter().collect() + }; // Calculate height from view model let mut total_height = 0u16; - for (idx, block) in view.items.iter().enumerate() { + for (idx, block) in items.iter().enumerate() { if idx > 0 { total_height = total_height.saturating_add(1); // separator total_height = total_height.saturating_add(1); // leading blank after separator @@ -98,17 +120,24 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { // Cap card height at viewport height to prevent overflow let actual_height = desired_height.min(area.height); - // Calculate scroll offset (scroll to show bottom content when overflowing) - let scroll_offset = desired_height.saturating_sub(actual_height); + // Calculate scroll offset to keep the active content visible when overflowing. + // When render_above=false (popup below cursor), items are reversed so the active + // content (input/spinner) is at the top — scroll_offset stays 0 to show the top. + // Otherwise, scroll to show the bottom where the active content lives. + let scroll_offset = if ctx.popup_mode && !ctx.render_above { + 0 + } else { + desired_height.saturating_sub(actual_height) + }; let card = Rect { - x: preferred_x.min(max_x), + x: card_x, y: area.y, width: desired_width, height: actual_height, }; - // Get title from first block (if any) + // Get title from first block in ORIGINAL order (always the input block) let title = view .items .first() @@ -117,22 +146,31 @@ fn render_view(frame: &mut Frame, view: &Blocks, ctx: &RenderContext) { // Create bordered frame // Padding: left=1, right=1, top=1, bottom=0 (blocks have trailing blanks) - let outer_block = RatatuiBlock::default() + let mut outer_block = RatatuiBlock::default() .borders(Borders::ALL) .title(title) .title_bottom(Line::from(view.footer).alignment(Alignment::Right)) .padding(Padding::new(1, 1, 1, 0)); + // Status bar: transient status on the bottom border, left-aligned + if let Some(ref sb) = view.status_bar { + let style = Style::from_crossterm(ctx.theme.as_style(Meaning::Annotation)); + let spinner = active_frame(sb.frame); + let status_text = format!(" {} {} ", spinner, sb.text); + outer_block = outer_block + .title_bottom(Line::from(Span::styled(status_text, style)).alignment(Alignment::Left)); + } + let inner_area = outer_block.inner(card); frame.render_widget(outer_block, card); // Render blocks (with scroll offset for overflowing content) - render_blocks_content(frame, view, ctx, inner_area, card.width, scroll_offset); + render_blocks_content(frame, &items, ctx, inner_area, card.width, scroll_offset); } fn render_blocks_content( frame: &mut Frame, - view: &Blocks, + items: &[&super::view_model::Block], ctx: &RenderContext, area: Rect, card_width: u16, @@ -143,7 +181,7 @@ fn render_blocks_content( // Build layout constraints for full content let mut constraints = Vec::new(); let mut block_heights = Vec::new(); - for (idx, block) in view.items.iter().enumerate() { + for (idx, block) in items.iter().enumerate() { if idx > 0 { constraints.push(Constraint::Length(1)); // separator constraints.push(Constraint::Length(1)); // leading blank after separator @@ -173,7 +211,7 @@ fn render_blocks_content( .split(area); let mut chunk_idx = 0; - for (idx, block) in view.items.iter().enumerate() { + for (idx, block) in items.iter().enumerate() { if idx > 0 { // Check if separator is visible (its position minus scroll_offset) let sep_start = cumulative[chunk_idx]; diff --git a/crates/atuin-ai/src/tui/terminal.rs b/crates/atuin-ai/src/tui/terminal.rs index 75bfd6e6..f8089323 100644 --- a/crates/atuin-ai/src/tui/terminal.rs +++ b/crates/atuin-ai/src/tui/terminal.rs @@ -3,7 +3,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode}, }; use eyre::{Context, Result, bail}; -use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend}; +use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend, layout::Rect}; use std::io::{IsTerminal, Stdout, stdout}; /// Install a panic hook that ensures the terminal is restored to a usable state @@ -65,6 +65,7 @@ pub struct TerminalGuard { anchor_col: u16, keep_output: bool, viewport_height: u16, + popup_mode: bool, } impl TerminalGuard { @@ -122,6 +123,56 @@ impl TerminalGuard { anchor_col, keep_output, viewport_height, + popup_mode: false, + }) + } + + /// Create a new TerminalGuard for popup overlay mode. + /// + /// In popup mode: + /// - Raw mode is not managed (atuin-hex owns it) + /// - The viewport is a fixed rect positioned over existing terminal content + /// - The popup area is pre-cleared to prevent background bleed-through + /// - Drop does not clear the viewport or disable raw mode + pub fn new_popup(popup_rect: Rect, anchor_col: u16) -> Result { + // Pre-clear the popup area before creating the ratatui terminal. + // Ratatui's diff-based rendering won't write "default" (space) cells on + // the first frame because its previous buffer is also all-default. By + // writing spaces to the terminal now, we ensure those positions are + // visually blank even if ratatui skips them. + { + use crossterm::cursor::MoveTo; + use crossterm::execute; + use crossterm::style::{Attribute, SetAttribute}; + use std::io::Write; + + let mut out = stdout(); + for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { + let _ = execute!( + out, + MoveTo(popup_rect.x, row), + SetAttribute(Attribute::Reset) + ); + let _ = write!(out, "{:width$}", "", width = popup_rect.width as usize); + } + let _ = out.flush(); + } + + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(popup_rect), + }, + ) + .context("failed to create terminal with fixed viewport")?; + + Ok(Self { + terminal, + anchor_col, + keep_output: false, + viewport_height: popup_rect.height, + popup_mode: true, }) } @@ -149,6 +200,24 @@ impl TerminalGuard { &mut self.terminal } + /// Resize the popup viewport to a new rect. + /// + /// Creates a fresh terminal with the updated Fixed viewport. The caller + /// is responsible for pre-clearing any newly exposed rows before calling + /// this (see `PopupState::grow_to`). + pub fn resize_popup(&mut self, new_rect: Rect) -> Result<()> { + self.viewport_height = new_rect.height; + let backend = CrosstermBackend::new(stdout()); + self.terminal = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Fixed(new_rect), + }, + ) + .context("failed to resize popup terminal")?; + Ok(()) + } + /// Get the anchor column where the inline UI should be positioned. /// /// This is the column position where the cursor was located when @@ -173,6 +242,12 @@ impl TerminalGuard { /// - The panic hook provides a second layer of safety for abnormal exits impl Drop for TerminalGuard { fn drop(&mut self) { + if self.popup_mode { + // Popup mode: screen restoration handled by caller before drop. + // Raw mode is owned by atuin-hex, don't touch it. + return; + } + // Clear terminal content only if keep_output is false - ignore errors (best-effort) if !self.keep_output { let _ = self.terminal.clear(); diff --git a/crates/atuin-ai/src/tui/view_model.rs b/crates/atuin-ai/src/tui/view_model.rs index e89932d9..0a296065 100644 --- a/crates/atuin-ai/src/tui/view_model.rs +++ b/crates/atuin-ai/src/tui/view_model.rs @@ -87,11 +87,22 @@ pub struct Block { pub title: Option, } +/// Status bar content shown on the bottom border during processing +#[derive(Debug, Clone)] +pub struct StatusBar { + /// Spinner animation frame + pub frame: usize, + /// Status text to display (e.g., "Thinking...", "run_bash (used 2 tools)") + pub text: String, +} + /// Complete view model - the rendering specification #[derive(Debug, Clone)] pub struct Blocks { pub items: Vec, pub footer: &'static str, + /// Transient status shown on bottom border during streaming/generating + pub status_bar: Option, } /// Count non-suggest_command tool calls since the last user message @@ -146,6 +157,7 @@ impl Blocks { /// Also handles streaming text and mode-dependent UI. pub fn from_state(state: &AppState) -> Self { let mut items = Vec::new(); + let mut status_bar = None; // 1. Build blocks from conversation events for event in &state.events { @@ -255,25 +267,32 @@ impl Blocks { } } - // 2. AI response block (tool status + streaming text) - shown during Streaming only - // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above + // 2. AI response block (streaming text only) - shown during Streaming only + // Transient status (spinner, tool progress) goes to status_bar on the bottom border. + // In Review mode, ToolStatus is handled inline with ConversationEvent::Text above. if state.mode == AppMode::Streaming { let (completed, in_flight) = count_tool_calls_since_last_user(&state.events); - let mut response_content = Vec::new(); - // Add tool status if there are any non-suggest_command tools - if completed > 0 || in_flight.is_some() { - response_content.push(Content::ToolStatus { - completed_count: completed, - current_label: in_flight.clone(), + // Tool status -> status bar + if let Some(ref label) = in_flight { + let text = if completed > 0 { + format!( + "{} (used {} tool{})", + label, + completed, + if completed == 1 { "" } else { "s" } + ) + } else { + label.clone() + }; + status_bar = Some(StatusBar { frame: state.spinner_frame, + text, }); } - // Add streaming text or spinner + // Spinner -> status bar (only when no text yet and no tool in-flight) if state.streaming_text.is_empty() { - // Check if enough time has passed to show spinner (200ms delay) - // Show spinner immediately if status event has arrived let should_show_spinner = state.streaming_status.is_some() || state .streaming_started @@ -281,29 +300,23 @@ impl Blocks { .unwrap_or(true); if should_show_spinner && in_flight.is_none() { - // Only show generating spinner if no tool is in-flight let status_text = state .streaming_status .as_ref() .map(|s| s.display_text().to_string()) .unwrap_or_else(|| "Generating...".to_string()); - response_content.push(Content::Spinner { + status_bar = Some(StatusBar { frame: state.spinner_frame, - status_text, + text: status_text, }); } } else { - // Show streaming text - response_content.push(Content::Text { - markdown: state.streaming_text.clone(), - }); - } - - // Add the response block if there's any content - if !response_content.is_empty() { + // Show streaming text as content items.push(Block { - content: response_content, + content: vec![Content::Text { + markdown: state.streaming_text.clone(), + }], separator_above: false, title: None, }); @@ -332,13 +345,9 @@ impl Blocks { .map(|s| s.display_text().to_string()) .unwrap_or_else(|| "Generating...".to_string()); - items.push(Block { - content: vec![Content::Spinner { - frame: state.spinner_frame, - status_text, - }], - separator_above: false, - title: None, + status_bar = Some(StatusBar { + frame: state.spinner_frame, + text: status_text, }); } AppMode::Streaming => { @@ -373,7 +382,11 @@ impl Blocks { // 7. Derive footer from mode and events let footer = Self::footer_for_mode(&state.mode, &state.events, state.confirmation_pending); - Self { items, footer } + Self { + items, + footer, + status_bar, + } } /// Derive footer text from current mode and conversation state diff --git a/crates/atuin-hex/Cargo.toml b/crates/atuin-hex/Cargo.toml new file mode 100644 index 00000000..8a574a55 --- /dev/null +++ b/crates/atuin-hex/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "atuin-hex" +edition = "2024" +description = "a terminal emulator for atuin" + +version = { workspace = true } +authors = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } + +[dependencies] +clap = { workspace = true } + +[target.'cfg(all(unix, not(target_os = "illumos")))'.dependencies] +crossterm = { workspace = true } +eyre = { workspace = true } +portable-pty = "0.8" +signal-hook = "0.3" +vt100 = "0.15" diff --git a/crates/atuin-hex/src/lib.rs b/crates/atuin-hex/src/lib.rs new file mode 100644 index 00000000..ff37cfe3 --- /dev/null +++ b/crates/atuin-hex/src/lib.rs @@ -0,0 +1,465 @@ +pub mod osc133; + +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Subcommand, Debug)] +pub enum Cmd { + /// Print shell code to initialize atuin-hex on shell startup + Init(Init), +} + +#[derive(Args, Debug)] +pub struct Init { + /// Shell to generate init for. If omitted, attempt auto-detection + #[arg(value_enum)] + shell: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "lower")] +#[allow(clippy::enum_variant_names, clippy::doc_markdown)] +enum Shell { + /// Zsh setup + Zsh, + /// Bash setup + Bash, + /// Fish setup + Fish, +} + +impl Shell { + fn as_str(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Zsh => "zsh", + Self::Fish => "fish", + } + } +} + +impl Init { + fn run(self) -> Result<(), String> { + let shell = detect_shell(self.shell)?; + let script = render_init(shell); + print!("{script}"); + Ok(()) + } +} + +pub fn run(cmd: Option) { + match cmd { + Some(Cmd::Init(init)) => { + if let Err(err) = init.run() { + eprintln!("atuin hex: {err}"); + std::process::exit(1); + } + } + None => app::main(), + } +} + +fn detect_shell(cli_shell: Option) -> Result { + if let Some(shell) = cli_shell { + return Ok(shell); + } + + if let Ok(shell) = std::env::var("ATUIN_SHELL") + && let Some(shell) = shell_from_name(&shell) + { + return Ok(shell); + } + + if let Ok(shell) = std::env::var("SHELL") + && let Some(shell) = shell_from_name(&shell) + { + return Ok(shell); + } + + Err( + "could not detect a supported shell. Please specify one explicitly: bash, zsh, or fish" + .to_string(), + ) +} + +fn shell_from_name(name: &str) -> Option { + let shell = name + .trim() + .rsplit('/') + .next() + .unwrap_or(name) + .trim_start_matches('-') + .to_ascii_lowercase(); + + match shell.as_str() { + "bash" => Some(Shell::Bash), + "zsh" => Some(Shell::Zsh), + "fish" => Some(Shell::Fish), + _ => None, + } +} + +fn init_command(shell: Shell) -> String { + format!("atuin init {}", shell.as_str()) +} + +fn render_init(shell: Shell) -> String { + let init_command = init_command(shell); + + match shell { + Shell::Bash | Shell::Zsh => format!( + r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then + _atuin_hex_tmux_current="${{TMUX:-}}" + _atuin_hex_tmux_previous="${{ATUIN_HEX_TMUX:-}}" + + if [[ -z "${{ATUIN_HEX_ACTIVE:-}}" ]] || [[ "$_atuin_hex_tmux_current" != "$_atuin_hex_tmux_previous" ]]; then + export ATUIN_HEX_ACTIVE=1 + export ATUIN_HEX_TMUX="$_atuin_hex_tmux_current" + exec atuin hex + fi + + unset _atuin_hex_tmux_current _atuin_hex_tmux_previous +fi + +eval "$({init_command})" +"# + ), + Shell::Fish => format!( + r#"if status is-interactive; and test -t 0; and test -t 1 + set -l _atuin_hex_tmux_current "" + if set -q TMUX + set _atuin_hex_tmux_current "$TMUX" + end + + set -l _atuin_hex_tmux_previous "" + if set -q ATUIN_HEX_TMUX + set _atuin_hex_tmux_previous "$ATUIN_HEX_TMUX" + end + + if not set -q ATUIN_HEX_ACTIVE + set -gx ATUIN_HEX_ACTIVE 1 + set -gx ATUIN_HEX_TMUX "$_atuin_hex_tmux_current" + exec atuin hex + else if test "$_atuin_hex_tmux_current" != "$_atuin_hex_tmux_previous" + set -gx ATUIN_HEX_ACTIVE 1 + set -gx ATUIN_HEX_TMUX "$_atuin_hex_tmux_current" + exec atuin hex + end +end + +{init_command} | source +"# + ), + } +} + +#[cfg(any(not(unix), target_os = "illumos"))] +mod app { + pub(crate) fn main() { + eprintln!("atuin hex currently supports unix platforms excluding illumos"); + std::process::exit(1); + } +} + +#[cfg(all(unix, not(target_os = "illumos")))] +mod app { + use std::io::{Read, Write}; + use std::os::unix::net::UnixListener; + use std::sync::mpsc; + + use crossterm::terminal; + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + + enum ParserMsg { + Data(Vec), + Resize { rows: u16, cols: u16 }, + ScreenRequest(mpsc::Sender>), + } + + pub(crate) fn main() { + if let Err(e) = run() { + let _ = terminal::disable_raw_mode(); + eprintln!("atuin hex: {e:#}"); + std::process::exit(1); + } + } + + fn socket_path() -> std::path::PathBuf { + let dir = std::env::temp_dir(); + dir.join(format!("atuin-hex-{}.sock", std::process::id())) + } + + /// Wire format written to the Unix socket: + /// + /// ```text + /// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] + /// [row_0_len: u32 BE][row_0_bytes...] + /// [row_1_len: u32 BE][row_1_bytes...] + /// ... + /// ``` + /// + /// Each row's bytes come from `screen.rows_formatted(0, cols)` and contain + /// pre-built ANSI escape sequences. The client can write them directly to + /// stdout without needing its own vt100 parser. + fn encode_screen(parser: &vt100::Parser) -> Vec { + let screen = parser.screen(); + let (rows, cols) = screen.size(); + let (cursor_row, cursor_col) = screen.cursor_position(); + + let mut buf: Vec = Vec::with_capacity(256 + (rows as usize * cols as usize)); + buf.extend_from_slice(&rows.to_be_bytes()); + buf.extend_from_slice(&cols.to_be_bytes()); + buf.extend_from_slice(&cursor_row.to_be_bytes()); + buf.extend_from_slice(&cursor_col.to_be_bytes()); + + for row_bytes in screen.rows_formatted(0, cols) { + let len = row_bytes.len() as u32; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(&row_bytes); + } + + buf + } + + fn handle_parser_msg(parser: &mut vt100::Parser, msg: ParserMsg) { + match msg { + ParserMsg::Data(data) => parser.process(&data), + ParserMsg::Resize { rows, cols } => parser.set_size(rows, cols), + ParserMsg::ScreenRequest(reply_tx) => { + let _ = reply_tx.send(encode_screen(parser)); + } + } + } + + fn run() -> eyre::Result<()> { + let (cols, rows) = terminal::size()?; + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|e| eyre::eyre!("{e:#}"))?; + + // Set up socket path and expose it to child processes + let sock_path = socket_path(); + // Clean up any stale socket from a previous crash + let _ = std::fs::remove_file(&sock_path); + + let mut cmd = CommandBuilder::new_default_prog(); + cmd.cwd(std::env::current_dir()?); + cmd.env("ATUIN_HEX_SOCKET", sock_path.as_os_str()); + + let mut child = pair + .slave + .spawn_command(cmd) + .map_err(|e| eyre::eyre!("{e:#}"))?; + + // Close slave side in parent process + drop(pair.slave); + + let mut pty_reader = pair + .master + .try_clone_reader() + .map_err(|e| eyre::eyre!("{e:#}"))?; + let mut pty_writer = pair + .master + .take_writer() + .map_err(|e| eyre::eyre!("{e:#}"))?; + + // Channel: stdout/sigwinch/socket threads -> parser thread (bounded, non-blocking send) + let (msg_tx, msg_rx) = mpsc::sync_channel::(64); + + // --- Parser thread --- + // Maintains a persistent vt100::Parser fed bytes as they arrive. + // On screen request: reads current state directly (no replay). + std::thread::spawn(move || { + let mut parser = vt100::Parser::new(rows, cols, 0); + + loop { + // Block until at least one message arrives + let first = match msg_rx.recv() { + Ok(msg) => msg, + Err(_) => break, + }; + + handle_parser_msg(&mut parser, first); + + // Drain all remaining pending messages so the parser stays + // caught up during high-throughput bursts (e.g. `cat bigfile`). + // The channel holds at most 64 items, so this is bounded. + while let Ok(msg) = msg_rx.try_recv() { + handle_parser_msg(&mut parser, msg); + } + } + }); + + // --- Socket server thread --- + // Listens on Unix socket; on connection, requests screen state from parser thread. + { + let sock_path_clone = sock_path.clone(); + let screen_tx = msg_tx.clone(); + std::thread::spawn(move || { + let listener = match UnixListener::bind(&sock_path_clone) { + Ok(l) => l, + Err(e) => { + eprintln!("atuin hex: failed to bind socket: {e}"); + return; + } + }; + + for stream in listener.incoming() { + let mut stream = match stream { + Ok(s) => s, + Err(_) => break, + }; + + let (reply_tx, reply_rx) = mpsc::channel(); + if screen_tx.send(ParserMsg::ScreenRequest(reply_tx)).is_err() { + break; + } + if let Ok(data) = reply_rx.recv() { + let _ = stream.write_all(&data); + let _ = stream.flush(); + } + } + }); + } + + // Handle terminal resize via SIGWINCH + { + use signal_hook::consts::SIGWINCH; + use signal_hook::iterator::Signals; + + let master = pair.master; + let resize_tx = msg_tx.clone(); + let mut signals = Signals::new([SIGWINCH])?; + + std::thread::spawn(move || { + for _ in signals.forever() { + if let Ok((cols, rows)) = terminal::size() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + let _ = resize_tx.try_send(ParserMsg::Resize { rows, cols }); + } + } + }); + } + + terminal::enable_raw_mode()?; + + // PTY -> stdout (with OSC 133 parsing + buffer feed) + let stdout_thread = std::thread::spawn(move || { + let mut stdout = std::io::stdout(); + let mut parser = crate::osc133::Parser::new(); + let mut buf = [0u8; 8192]; + loop { + match pty_reader.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + parser.push(&buf[..n], |_event| { + // Zone transitions are tracked inside the parser. + // Callers can query parser.zone() after push. + }); + + // Feed bytes to the shadow parser. Drops on backpressure — + // the screen snapshot may be stale during bursts, but + // self-corrects once output settles. + let _ = msg_tx.try_send(ParserMsg::Data(buf[..n].to_vec())); + + if stdout.write_all(&buf[..n]).is_err() { + break; + } + let _ = stdout.flush(); + } + } + } + }); + + // stdin -> PTY + std::thread::spawn(move || { + let mut stdin = std::io::stdin(); + let mut buf = [0u8; 8192]; + loop { + match stdin.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + if pty_writer.write_all(&buf[..n]).is_err() { + break; + } + } + } + } + }); + + let status = child.wait()?; + let _ = stdout_thread.join(); + + let _ = terminal::disable_raw_mode(); + + // Clean up socket file + let _ = std::fs::remove_file(&sock_path); + + std::process::exit(process_exit_code(status.exit_code())); + } + + fn process_exit_code(code: u32) -> i32 { + i32::try_from(code).unwrap_or(1) + } + + #[cfg(test)] + mod tests { + use super::process_exit_code; + + #[test] + fn process_exit_code_preserves_valid_values() { + assert_eq!(process_exit_code(0), 0); + assert_eq!(process_exit_code(127), 127); + assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX); + } + + #[test] + fn process_exit_code_defaults_when_out_of_range() { + assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1); + } + } +} + +#[cfg(test)] +mod tests { + use super::{Shell, init_command, render_init, shell_from_name}; + + #[test] + fn shell_from_name_handles_paths() { + assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh)); + assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash)); + assert_eq!(shell_from_name("fish"), Some(Shell::Fish)); + } + + #[test] + fn init_command_is_bootstrap_only() { + let command = init_command(Shell::Zsh); + assert_eq!(command, "atuin init zsh"); + } + + #[test] + fn posix_init_uses_exec_and_tmux_guard() { + let script = render_init(Shell::Bash); + assert!(script.contains("exec atuin hex")); + assert!(script.contains("ATUIN_HEX_TMUX")); + assert!(script.contains("eval \"$(atuin init bash)\"")); + } + + #[test] + fn fish_init_uses_source() { + let script = render_init(Shell::Fish); + assert!(script.contains("exec atuin hex")); + assert!(script.contains("atuin init fish | source")); + } +} diff --git a/crates/atuin-hex/src/osc133.rs b/crates/atuin-hex/src/osc133.rs new file mode 100644 index 00000000..d6ee1220 --- /dev/null +++ b/crates/atuin-hex/src/osc133.rs @@ -0,0 +1,657 @@ +//! Streaming parser for OSC 133 (FinalTerm semantic prompt) escape sequences. +//! +//! OSC 133 marks four regions of a shell interaction: +//! +//! | Marker | Meaning | +//! |--------|--------------------------------------| +//! | A | Prompt is about to be printed | +//! | B | Prompt ended — command input begins | +//! | C | Command submitted — output begins | +//! | D[;n] | Command finished with exit code *n* | +//! +//! The wire format is `ESC ] 133 ; [; ] ST` where ST is either +//! BEL (0x07) or ESC \ (0x1B 0x5C). +//! +//! # Design goals +//! +//! * **Zero-copy** — the parser observes the byte stream without buffering or +//! modifying it. +//! * **Zero-alloc** — after construction no heap allocation occurs. +//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available +//! and returns immediately. +//! * **Transparent** — the caller is responsible for forwarding bytes to their +//! destination; the parser only emits [`Event`]s through a callback. + +/// Events emitted when an OSC 133 marker is detected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + /// `ESC ] 133 ; A ST` — the shell is about to display its prompt. + PromptStart, + /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command. + CommandStart, + /// `ESC ] 133 ; C ST` — the command has been submitted for execution. + CommandExecuted, + /// `ESC ] 133 ; D [; ] ST` — command output is complete. + CommandFinished { + /// The exit code reported after the `;`, if present and valid. + exit_code: Option, + }, +} + +/// The current semantic zone as determined by the most recent OSC 133 marker. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum Zone { + /// No marker seen yet, or after a `D` marker (between commands). + #[default] + Unknown, + /// Between `A` and `B` — the shell is rendering its prompt. + Prompt, + /// Between `B` and `C` — the user is editing a command line. + Input, + /// Between `C` and `D` — command output is being produced. + Output, +} + +// --------------------------------------------------------------------------- +// Internal constants +// --------------------------------------------------------------------------- + +const ESC: u8 = 0x1B; +const BEL: u8 = 0x07; +const BACKSLASH: u8 = b'\\'; +const RIGHT_BRACKET: u8 = b']'; + +/// Maximum bytes we'll buffer for the OSC parameter string. 32 bytes is far +/// more than any valid OSC 133 payload needs (e.g. `133;D;127` is 9 bytes). +/// Longer (non-133) OSC sequences simply stop accumulating once the buffer is +/// full — the dispatch logic will harmlessly ignore them. +const PARAM_BUF_CAP: usize = 32; + +// --------------------------------------------------------------------------- +// State machine +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum State { + /// Normal pass-through. + Ground, + /// Saw ESC (0x1B). + Esc, + /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes. + OscParam, + /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \` + /// (string terminator) or something else. + OscEsc, +} + +/// A streaming, zero-allocation parser for OSC 133 escape sequences. +/// +/// Feed arbitrary byte slices into [`Parser::push`]. The parser detects +/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback +/// without modifying the data. It can sit transparently between a PTY reader +/// and stdout. +pub struct Parser { + state: State, + zone: Zone, + param_buf: [u8; PARAM_BUF_CAP], + param_len: usize, +} + +impl Default for Parser { + fn default() -> Self { + Self::new() + } +} + +impl Parser { + /// Create a new parser in the initial (ground / unknown-zone) state. + #[inline] + pub fn new() -> Self { + Self { + state: State::Ground, + zone: Zone::Unknown, + param_buf: [0u8; PARAM_BUF_CAP], + param_len: 0, + } + } + + /// The current semantic zone based on markers seen so far. + #[inline] + #[allow(dead_code)] + pub fn zone(&self) -> Zone { + self.zone + } + + /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker + /// found. + /// + /// All bytes in `data` should still be forwarded to the terminal by the + /// caller — this method only *observes* the stream. + #[inline] + pub fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) { + for &byte in data { + match self.state { + State::Ground => { + if byte == ESC { + self.state = State::Esc; + } + } + State::Esc => { + if byte == RIGHT_BRACKET { + self.state = State::OscParam; + self.param_len = 0; + } else { + self.state = State::Ground; + } + } + State::OscParam => { + if byte == BEL { + self.dispatch(&mut on_event); + self.state = State::Ground; + } else if byte == ESC { + self.state = State::OscEsc; + } else if self.param_len < PARAM_BUF_CAP { + self.param_buf[self.param_len] = byte; + self.param_len += 1; + } + // If param_len == PARAM_BUF_CAP we silently stop + // accumulating — dispatch will ignore non-133 sequences. + } + State::OscEsc => { + if byte == BACKSLASH { + self.dispatch(&mut on_event); + } + // Whether we got a valid ST or not, return to ground. + // (A new ESC ] would restart accumulation via the Ground + // -> Esc -> OscParam path on the *next* byte.) + self.state = State::Ground; + } + } + } + } + + /// Inspect the accumulated parameter buffer. If it holds an OSC 133 + /// payload, emit the corresponding [`Event`] and update the zone. + #[inline] + fn dispatch(&mut self, on_event: &mut impl FnMut(Event)) { + let params = &self.param_buf[..self.param_len]; + + // Must start with "133;" + if params.len() < 5 || ¶ms[..4] != b"133;" { + return; + } + + let cmd = params[4]; + let event = match cmd { + b'A' => { + self.zone = Zone::Prompt; + Event::PromptStart + } + b'B' => { + self.zone = Zone::Input; + Event::CommandStart + } + b'C' => { + self.zone = Zone::Output; + Event::CommandExecuted + } + b'D' => { + let exit_code = if params.len() > 6 && params[5] == b';' { + std::str::from_utf8(¶ms[6..]) + .ok() + .and_then(|s| s.parse::().ok()) + } else { + None + }; + self.zone = Zone::Unknown; + Event::CommandFinished { exit_code } + } + _ => return, + }; + + on_event(event); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Collect all events from a single `push` call. + fn parse_events(data: &[u8]) -> Vec { + let mut parser = Parser::new(); + let mut events = Vec::new(); + parser.push(data, |e| events.push(e)); + events + } + + // -- Basic event detection ------------------------------------------------ + + #[test] + fn detect_prompt_start_bel() { + let data = b"\x1b]133;A\x07"; + assert_eq!(parse_events(data), vec![Event::PromptStart]); + } + + #[test] + fn detect_prompt_start_st() { + let data = b"\x1b]133;A\x1b\\"; + assert_eq!(parse_events(data), vec![Event::PromptStart]); + } + + #[test] + fn detect_command_start_bel() { + let data = b"\x1b]133;B\x07"; + assert_eq!(parse_events(data), vec![Event::CommandStart]); + } + + #[test] + fn detect_command_start_st() { + let data = b"\x1b]133;B\x1b\\"; + assert_eq!(parse_events(data), vec![Event::CommandStart]); + } + + #[test] + fn detect_command_executed_bel() { + let data = b"\x1b]133;C\x07"; + assert_eq!(parse_events(data), vec![Event::CommandExecuted]); + } + + #[test] + fn detect_command_executed_st() { + let data = b"\x1b]133;C\x1b\\"; + assert_eq!(parse_events(data), vec![Event::CommandExecuted]); + } + + #[test] + fn detect_command_finished_no_exit_code() { + let data = b"\x1b]133;D\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { exit_code: None }] + ); + } + + #[test] + fn detect_command_finished_exit_zero() { + let data = b"\x1b]133;D;0\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { exit_code: Some(0) }] + ); + } + + #[test] + fn detect_command_finished_exit_nonzero() { + let data = b"\x1b]133;D;127\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { + exit_code: Some(127) + }] + ); + } + + #[test] + fn detect_command_finished_negative_exit_code() { + let data = b"\x1b]133;D;-1\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { + exit_code: Some(-1) + }] + ); + } + + #[test] + fn detect_command_finished_exit_code_st() { + let data = b"\x1b]133;D;42\x1b\\"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { + exit_code: Some(42) + }] + ); + } + + #[test] + fn invalid_exit_code_yields_none() { + let data = b"\x1b]133;D;abc\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { exit_code: None }] + ); + } + + // -- Zone tracking -------------------------------------------------------- + + #[test] + fn zone_starts_unknown() { + let parser = Parser::new(); + assert_eq!(parser.zone(), Zone::Unknown); + } + + #[test] + fn full_zone_cycle() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b]133;A\x07", |e| events.push(e)); + assert_eq!(parser.zone(), Zone::Prompt); + + parser.push(b"\x1b]133;B\x07", |e| events.push(e)); + assert_eq!(parser.zone(), Zone::Input); + + parser.push(b"\x1b]133;C\x07", |e| events.push(e)); + assert_eq!(parser.zone(), Zone::Output); + + parser.push(b"\x1b]133;D;0\x07", |e| events.push(e)); + assert_eq!(parser.zone(), Zone::Unknown); + + assert_eq!( + events, + vec![ + Event::PromptStart, + Event::CommandStart, + Event::CommandExecuted, + Event::CommandFinished { exit_code: Some(0) }, + ] + ); + } + + // -- Multiple events in one push ------------------------------------------ + + #[test] + fn multiple_events_single_push() { + let data = b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file.txt\n\x1b]133;D;0\x07"; + let events = parse_events(data); + assert_eq!( + events, + vec![ + Event::PromptStart, + Event::CommandStart, + Event::CommandExecuted, + Event::CommandFinished { exit_code: Some(0) }, + ] + ); + } + + // -- Split across push boundaries ----------------------------------------- + + #[test] + fn split_esc_and_bracket() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b", |e| events.push(e)); + assert!(events.is_empty()); + + parser.push(b"]133;A\x07", |e| events.push(e)); + assert_eq!(events, vec![Event::PromptStart]); + } + + #[test] + fn split_mid_param() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b]13", |e| events.push(e)); + assert!(events.is_empty()); + + parser.push(b"3;D;42\x07", |e| events.push(e)); + assert_eq!( + events, + vec![Event::CommandFinished { + exit_code: Some(42) + }] + ); + } + + #[test] + fn split_before_terminator() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b]133;B", |e| events.push(e)); + assert!(events.is_empty()); + + parser.push(b"\x07", |e| events.push(e)); + assert_eq!(events, vec![Event::CommandStart]); + } + + #[test] + fn split_esc_backslash_terminator() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b]133;C\x1b", |e| events.push(e)); + assert!(events.is_empty()); + + parser.push(b"\\", |e| events.push(e)); + assert_eq!(events, vec![Event::CommandExecuted]); + } + + // -- Interleaved normal text ---------------------------------------------- + + #[test] + fn normal_text_before_and_after() { + let data = b"hello world\x1b]133;A\x07prompt text\x1b]133;B\x07command"; + let events = parse_events(data); + assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); + } + + // -- Non-133 OSC sequences (should be ignored) ---------------------------- + + #[test] + fn non_133_osc_ignored() { + let data = b"\x1b]0;window title\x07\x1b]133;A\x07"; + let events = parse_events(data); + assert_eq!(events, vec![Event::PromptStart]); + } + + #[test] + fn osc_7_ignored() { + let data = b"\x1b]7;file:///home/user\x07"; + assert!(parse_events(data).is_empty()); + } + + // -- Unknown command letter ----------------------------------------------- + + #[test] + fn unknown_command_ignored() { + let data = b"\x1b]133;Z\x07"; + assert!(parse_events(data).is_empty()); + } + + // -- Malformed sequences -------------------------------------------------- + + #[test] + fn esc_followed_by_non_bracket() { + let data = b"\x1b[31m\x1b]133;A\x07"; + let events = parse_events(data); + assert_eq!(events, vec![Event::PromptStart]); + } + + #[test] + fn lone_esc_at_end_of_chunk() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + parser.push(b"\x1b", |e| events.push(e)); + assert!(events.is_empty()); + + // Feed non-bracket to abort the escape, then a real sequence. + parser.push(b"x\x1b]133;A\x07", |e| events.push(e)); + assert_eq!(events, vec![Event::PromptStart]); + } + + #[test] + fn truncated_133_prefix() { + // "13" followed by terminator — not "133;" so no event. + let data = b"\x1b]13\x07"; + assert!(parse_events(data).is_empty()); + } + + #[test] + fn empty_osc() { + let data = b"\x1b]\x07"; + assert!(parse_events(data).is_empty()); + } + + // -- Buffer overflow (very long non-133 OSC) ------------------------------ + + #[test] + fn very_long_osc_does_not_panic() { + let mut data = Vec::new(); + data.extend_from_slice(b"\x1b]"); + data.extend(std::iter::repeat(b'x').take(1000)); + data.push(BEL); + // Should not panic and should produce no event. + assert!(parse_events(&data).is_empty()); + } + + // -- Empty input ---------------------------------------------------------- + + #[test] + fn empty_input() { + assert!(parse_events(b"").is_empty()); + } + + #[test] + fn only_normal_text() { + let data = b"just some regular terminal output\r\n"; + assert!(parse_events(data).is_empty()); + } + + // -- Repeated prompts (empty command) ------------------------------------ + + #[test] + fn repeated_prompt_cycle() { + let mut parser = Parser::new(); + let mut events = Vec::new(); + + // User hits enter on an empty prompt twice. + let data = b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;D\x07\x1b]133;A\x07$ \x1b]133;B\x07"; + parser.push(data, |e| events.push(e)); + + assert_eq!( + events, + vec![ + Event::PromptStart, + Event::CommandStart, + Event::CommandFinished { exit_code: None }, + Event::PromptStart, + Event::CommandStart, + ] + ); + assert_eq!(parser.zone(), Zone::Input); + } + + // -- Byte-at-a-time feeding ----------------------------------------------- + + #[test] + fn byte_at_a_time() { + let data = b"\x1b]133;D;99\x07"; + let mut parser = Parser::new(); + let mut events = Vec::new(); + + for &byte in data { + parser.push(&[byte], |e| events.push(e)); + } + + assert_eq!( + events, + vec![Event::CommandFinished { + exit_code: Some(99) + }] + ); + } + + // -- Mixed terminators ---------------------------------------------------- + + #[test] + fn mixed_bel_and_st_terminators() { + let data = b"\x1b]133;A\x07\x1b]133;B\x1b\\\x1b]133;C\x07\x1b]133;D;1\x1b\\"; + let events = parse_events(data); + assert_eq!( + events, + vec![ + Event::PromptStart, + Event::CommandStart, + Event::CommandExecuted, + Event::CommandFinished { exit_code: Some(1) }, + ] + ); + } + + // -- Default trait -------------------------------------------------------- + + #[test] + fn parser_default() { + let parser = Parser::default(); + assert_eq!(parser.zone(), Zone::Unknown); + } + + #[test] + fn zone_default() { + assert_eq!(Zone::default(), Zone::Unknown); + } + + // -- D with empty exit code field ----------------------------------------- + + #[test] + fn d_with_semicolon_but_empty_code() { + // "133;D;" — semicolon present but no digits. + let data = b"\x1b]133;D;\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { exit_code: None }] + ); + } + + // -- Consecutive OSC sequences without gap -------------------------------- + + #[test] + fn back_to_back_osc_no_gap() { + let data = b"\x1b]133;A\x07\x1b]133;B\x07"; + let events = parse_events(data); + assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); + } + + // -- CSI sequences interleaved (should not confuse parser) ---------------- + + #[test] + fn csi_sequences_ignored() { + // CSI (ESC [) color codes mixed with OSC 133. + let data = b"\x1b[32m\x1b]133;A\x07\x1b[0m$ \x1b]133;B\x07"; + let events = parse_events(data); + assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); + } + + // -- Large exit codes ----------------------------------------------------- + + #[test] + fn large_exit_code() { + let data = b"\x1b]133;D;2147483647\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { + exit_code: Some(i32::MAX) + }] + ); + } + + #[test] + fn overflow_exit_code_yields_none() { + let data = b"\x1b]133;D;9999999999999\x07"; + assert_eq!( + parse_events(data), + vec![Event::CommandFinished { exit_code: None }] + ); + } +} diff --git a/crates/atuin-shell/Cargo.toml b/crates/atuin-shell/Cargo.toml deleted file mode 100644 index c14072dd..00000000 --- a/crates/atuin-shell/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "atuin-shell" -edition = "2024" -description = "a terminal emulator for atuin" - -version = { workspace = true } -authors = { workspace = true } -rust-version = { workspace = true } -license = { workspace = true } -homepage = { workspace = true } -repository = { workspace = true } - -[[bin]] -name = "atuin-shell" -path = "src/main.rs" - -[dependencies] -clap = { workspace = true } - -[target.'cfg(all(unix, not(target_os = "illumos")))'.dependencies] -crossterm = { workspace = true } -eyre = { workspace = true } -portable-pty = "0.8" -signal-hook = "0.3" diff --git a/crates/atuin-shell/src/main.rs b/crates/atuin-shell/src/main.rs deleted file mode 100644 index 337237de..00000000 --- a/crates/atuin-shell/src/main.rs +++ /dev/null @@ -1,342 +0,0 @@ -mod osc133; - -use clap::{Args, Parser, Subcommand, ValueEnum}; - -#[derive(Parser, Debug)] -#[command(infer_subcommands = true)] -struct Cli { - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum Cmd { - /// Print shell code to initialize atuin-shell on shell startup - Init(Init), -} - -#[derive(Args, Debug)] -struct Init { - /// Shell to generate init for. If omitted, attempt auto-detection - #[arg(value_enum)] - shell: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] -#[value(rename_all = "lower")] -#[allow(clippy::enum_variant_names, clippy::doc_markdown)] -enum Shell { - /// Zsh setup - Zsh, - /// Bash setup - Bash, - /// Fish setup - Fish, -} - -impl Shell { - fn as_str(self) -> &'static str { - match self { - Self::Bash => "bash", - Self::Zsh => "zsh", - Self::Fish => "fish", - } - } -} - -impl Init { - fn run(self) -> Result<(), String> { - let shell = detect_shell(self.shell)?; - let script = render_init(shell); - print!("{script}"); - Ok(()) - } -} - -fn detect_shell(cli_shell: Option) -> Result { - if let Some(shell) = cli_shell { - return Ok(shell); - } - - if let Ok(shell) = std::env::var("ATUIN_SHELL") - && let Some(shell) = shell_from_name(&shell) - { - return Ok(shell); - } - - if let Ok(shell) = std::env::var("SHELL") - && let Some(shell) = shell_from_name(&shell) - { - return Ok(shell); - } - - Err( - "could not detect a supported shell. Please specify one explicitly: bash, zsh, or fish" - .to_string(), - ) -} - -fn shell_from_name(name: &str) -> Option { - let shell = name - .trim() - .rsplit('/') - .next() - .unwrap_or(name) - .trim_start_matches('-') - .to_ascii_lowercase(); - - match shell.as_str() { - "bash" => Some(Shell::Bash), - "zsh" => Some(Shell::Zsh), - "fish" => Some(Shell::Fish), - _ => None, - } -} - -fn init_command(shell: Shell) -> String { - format!("atuin init {}", shell.as_str()) -} - -fn render_init(shell: Shell) -> String { - let init_command = init_command(shell); - - match shell { - Shell::Bash | Shell::Zsh => format!( - r#"if [[ "$-" == *i* ]] && [[ -t 0 ]] && [[ -t 1 ]]; then - _atuin_shell_tmux_current="${{TMUX:-}}" - _atuin_shell_tmux_previous="${{ATUIN_SHELL_TMUX:-}}" - - if [[ -z "${{ATUIN_SHELL_ACTIVE:-}}" ]] || [[ "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous" ]]; then - export ATUIN_SHELL_ACTIVE=1 - export ATUIN_SHELL_TMUX="$_atuin_shell_tmux_current" - exec atuin-shell - fi - - unset _atuin_shell_tmux_current _atuin_shell_tmux_previous -fi - -eval "$({init_command})" -"# - ), - Shell::Fish => format!( - r#"if status is-interactive; and test -t 0; and test -t 1 - set -l _atuin_shell_tmux_current "" - if set -q TMUX - set _atuin_shell_tmux_current "$TMUX" - end - - set -l _atuin_shell_tmux_previous "" - if set -q ATUIN_SHELL_TMUX - set _atuin_shell_tmux_previous "$ATUIN_SHELL_TMUX" - end - - if not set -q ATUIN_SHELL_ACTIVE - set -gx ATUIN_SHELL_ACTIVE 1 - set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current" - exec atuin-shell - else if test "$_atuin_shell_tmux_current" != "$_atuin_shell_tmux_previous" - set -gx ATUIN_SHELL_ACTIVE 1 - set -gx ATUIN_SHELL_TMUX "$_atuin_shell_tmux_current" - exec atuin-shell - end -end - -{init_command} | source -"# - ), - } -} - -fn main() { - let cli = Cli::parse(); - - match cli.command { - Some(Cmd::Init(init)) => { - if let Err(err) = init.run() { - eprintln!("atuin-shell: {err}"); - std::process::exit(1); - } - } - None => app::main(), - } -} - -#[cfg(any(not(unix), target_os = "illumos"))] -mod app { - pub(crate) fn main() { - eprintln!("atuin-shell currently supports unix platforms excluding illumos"); - std::process::exit(1); - } -} - -#[cfg(all(unix, not(target_os = "illumos")))] -mod app { - use std::io::{Read, Write}; - - use crossterm::terminal; - use portable_pty::{CommandBuilder, PtySize, native_pty_system}; - - pub(crate) fn main() { - if let Err(e) = run() { - let _ = terminal::disable_raw_mode(); - eprintln!("atuin-shell: {e:#}"); - std::process::exit(1); - } - } - - fn run() -> eyre::Result<()> { - let (cols, rows) = terminal::size()?; - - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }) - .map_err(|e| eyre::eyre!("{e:#}"))?; - - let mut cmd = CommandBuilder::new_default_prog(); - cmd.cwd(std::env::current_dir()?); - let mut child = pair - .slave - .spawn_command(cmd) - .map_err(|e| eyre::eyre!("{e:#}"))?; - - // Close slave side in parent process - drop(pair.slave); - - let mut pty_reader = pair - .master - .try_clone_reader() - .map_err(|e| eyre::eyre!("{e:#}"))?; - let mut pty_writer = pair - .master - .take_writer() - .map_err(|e| eyre::eyre!("{e:#}"))?; - - // Handle terminal resize via SIGWINCH - { - use signal_hook::consts::SIGWINCH; - use signal_hook::iterator::Signals; - - let master = pair.master; - let mut signals = Signals::new([SIGWINCH])?; - - std::thread::spawn(move || { - for _ in signals.forever() { - if let Ok((cols, rows)) = terminal::size() { - let _ = master.resize(PtySize { - rows, - cols, - pixel_width: 0, - pixel_height: 0, - }); - } - } - }); - } - - terminal::enable_raw_mode()?; - - // PTY -> stdout (with OSC 133 parsing) - let stdout_thread = std::thread::spawn(move || { - let mut stdout = std::io::stdout(); - let mut parser = crate::osc133::Parser::new(); - let mut buf = [0u8; 8192]; - loop { - match pty_reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - parser.push(&buf[..n], |_event| { - // Zone transitions are tracked inside the parser. - // Callers can query parser.zone() after push. - }); - if stdout.write_all(&buf[..n]).is_err() { - break; - } - let _ = stdout.flush(); - } - } - } - }); - - // stdin -> PTY - std::thread::spawn(move || { - let mut stdin = std::io::stdin(); - let mut buf = [0u8; 8192]; - loop { - match stdin.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - if pty_writer.write_all(&buf[..n]).is_err() { - break; - } - } - } - } - }); - - let status = child.wait()?; - let _ = stdout_thread.join(); - - let _ = terminal::disable_raw_mode(); - - std::process::exit(process_exit_code(status.exit_code())); - } - - fn process_exit_code(code: u32) -> i32 { - i32::try_from(code).unwrap_or(1) - } - - #[cfg(test)] - mod tests { - use super::process_exit_code; - - #[test] - fn process_exit_code_preserves_valid_values() { - assert_eq!(process_exit_code(0), 0); - assert_eq!(process_exit_code(127), 127); - assert_eq!(process_exit_code(i32::MAX as u32), i32::MAX); - } - - #[test] - fn process_exit_code_defaults_when_out_of_range() { - assert_eq!(process_exit_code(i32::MAX as u32 + 1), 1); - } - } -} - -#[cfg(test)] -mod tests { - use super::{Shell, init_command, render_init, shell_from_name}; - - #[test] - fn shell_from_name_handles_paths() { - assert_eq!(shell_from_name("/bin/zsh"), Some(Shell::Zsh)); - assert_eq!(shell_from_name("/usr/local/bin/bash"), Some(Shell::Bash)); - assert_eq!(shell_from_name("fish"), Some(Shell::Fish)); - } - - #[test] - fn init_command_is_bootstrap_only() { - let command = init_command(Shell::Zsh); - assert_eq!(command, "atuin init zsh"); - } - - #[test] - fn posix_init_uses_exec_and_tmux_guard() { - let script = render_init(Shell::Bash); - assert!(script.contains("exec atuin-shell")); - assert!(script.contains("ATUIN_SHELL_TMUX")); - assert!(script.contains("eval \"$(atuin init bash)\"")); - } - - #[test] - fn fish_init_uses_source() { - let script = render_init(Shell::Fish); - assert!(script.contains("exec atuin-shell")); - assert!(script.contains("atuin init fish | source")); - } -} diff --git a/crates/atuin-shell/src/osc133.rs b/crates/atuin-shell/src/osc133.rs deleted file mode 100644 index d6ee1220..00000000 --- a/crates/atuin-shell/src/osc133.rs +++ /dev/null @@ -1,657 +0,0 @@ -//! Streaming parser for OSC 133 (FinalTerm semantic prompt) escape sequences. -//! -//! OSC 133 marks four regions of a shell interaction: -//! -//! | Marker | Meaning | -//! |--------|--------------------------------------| -//! | A | Prompt is about to be printed | -//! | B | Prompt ended — command input begins | -//! | C | Command submitted — output begins | -//! | D[;n] | Command finished with exit code *n* | -//! -//! The wire format is `ESC ] 133 ; [; ] ST` where ST is either -//! BEL (0x07) or ESC \ (0x1B 0x5C). -//! -//! # Design goals -//! -//! * **Zero-copy** — the parser observes the byte stream without buffering or -//! modifying it. -//! * **Zero-alloc** — after construction no heap allocation occurs. -//! * **Non-blocking** — [`Parser::push`] processes whatever bytes are available -//! and returns immediately. -//! * **Transparent** — the caller is responsible for forwarding bytes to their -//! destination; the parser only emits [`Event`]s through a callback. - -/// Events emitted when an OSC 133 marker is detected. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Event { - /// `ESC ] 133 ; A ST` — the shell is about to display its prompt. - PromptStart, - /// `ESC ] 133 ; B ST` — the prompt has ended; the user may type a command. - CommandStart, - /// `ESC ] 133 ; C ST` — the command has been submitted for execution. - CommandExecuted, - /// `ESC ] 133 ; D [; ] ST` — command output is complete. - CommandFinished { - /// The exit code reported after the `;`, if present and valid. - exit_code: Option, - }, -} - -/// The current semantic zone as determined by the most recent OSC 133 marker. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum Zone { - /// No marker seen yet, or after a `D` marker (between commands). - #[default] - Unknown, - /// Between `A` and `B` — the shell is rendering its prompt. - Prompt, - /// Between `B` and `C` — the user is editing a command line. - Input, - /// Between `C` and `D` — command output is being produced. - Output, -} - -// --------------------------------------------------------------------------- -// Internal constants -// --------------------------------------------------------------------------- - -const ESC: u8 = 0x1B; -const BEL: u8 = 0x07; -const BACKSLASH: u8 = b'\\'; -const RIGHT_BRACKET: u8 = b']'; - -/// Maximum bytes we'll buffer for the OSC parameter string. 32 bytes is far -/// more than any valid OSC 133 payload needs (e.g. `133;D;127` is 9 bytes). -/// Longer (non-133) OSC sequences simply stop accumulating once the buffer is -/// full — the dispatch logic will harmlessly ignore them. -const PARAM_BUF_CAP: usize = 32; - -// --------------------------------------------------------------------------- -// State machine -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum State { - /// Normal pass-through. - Ground, - /// Saw ESC (0x1B). - Esc, - /// Inside an OSC sequence (`ESC ]`), accumulating parameter bytes. - OscParam, - /// Inside an OSC sequence, saw ESC — next byte decides if this is `ESC \` - /// (string terminator) or something else. - OscEsc, -} - -/// A streaming, zero-allocation parser for OSC 133 escape sequences. -/// -/// Feed arbitrary byte slices into [`Parser::push`]. The parser detects -/// OSC 133 markers and reports [`Event`]s through a caller-supplied callback -/// without modifying the data. It can sit transparently between a PTY reader -/// and stdout. -pub struct Parser { - state: State, - zone: Zone, - param_buf: [u8; PARAM_BUF_CAP], - param_len: usize, -} - -impl Default for Parser { - fn default() -> Self { - Self::new() - } -} - -impl Parser { - /// Create a new parser in the initial (ground / unknown-zone) state. - #[inline] - pub fn new() -> Self { - Self { - state: State::Ground, - zone: Zone::Unknown, - param_buf: [0u8; PARAM_BUF_CAP], - param_len: 0, - } - } - - /// The current semantic zone based on markers seen so far. - #[inline] - #[allow(dead_code)] - pub fn zone(&self) -> Zone { - self.zone - } - - /// Process a chunk of bytes, calling `on_event` for every OSC 133 marker - /// found. - /// - /// All bytes in `data` should still be forwarded to the terminal by the - /// caller — this method only *observes* the stream. - #[inline] - pub fn push(&mut self, data: &[u8], mut on_event: impl FnMut(Event)) { - for &byte in data { - match self.state { - State::Ground => { - if byte == ESC { - self.state = State::Esc; - } - } - State::Esc => { - if byte == RIGHT_BRACKET { - self.state = State::OscParam; - self.param_len = 0; - } else { - self.state = State::Ground; - } - } - State::OscParam => { - if byte == BEL { - self.dispatch(&mut on_event); - self.state = State::Ground; - } else if byte == ESC { - self.state = State::OscEsc; - } else if self.param_len < PARAM_BUF_CAP { - self.param_buf[self.param_len] = byte; - self.param_len += 1; - } - // If param_len == PARAM_BUF_CAP we silently stop - // accumulating — dispatch will ignore non-133 sequences. - } - State::OscEsc => { - if byte == BACKSLASH { - self.dispatch(&mut on_event); - } - // Whether we got a valid ST or not, return to ground. - // (A new ESC ] would restart accumulation via the Ground - // -> Esc -> OscParam path on the *next* byte.) - self.state = State::Ground; - } - } - } - } - - /// Inspect the accumulated parameter buffer. If it holds an OSC 133 - /// payload, emit the corresponding [`Event`] and update the zone. - #[inline] - fn dispatch(&mut self, on_event: &mut impl FnMut(Event)) { - let params = &self.param_buf[..self.param_len]; - - // Must start with "133;" - if params.len() < 5 || ¶ms[..4] != b"133;" { - return; - } - - let cmd = params[4]; - let event = match cmd { - b'A' => { - self.zone = Zone::Prompt; - Event::PromptStart - } - b'B' => { - self.zone = Zone::Input; - Event::CommandStart - } - b'C' => { - self.zone = Zone::Output; - Event::CommandExecuted - } - b'D' => { - let exit_code = if params.len() > 6 && params[5] == b';' { - std::str::from_utf8(¶ms[6..]) - .ok() - .and_then(|s| s.parse::().ok()) - } else { - None - }; - self.zone = Zone::Unknown; - Event::CommandFinished { exit_code } - } - _ => return, - }; - - on_event(event); - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - /// Collect all events from a single `push` call. - fn parse_events(data: &[u8]) -> Vec { - let mut parser = Parser::new(); - let mut events = Vec::new(); - parser.push(data, |e| events.push(e)); - events - } - - // -- Basic event detection ------------------------------------------------ - - #[test] - fn detect_prompt_start_bel() { - let data = b"\x1b]133;A\x07"; - assert_eq!(parse_events(data), vec![Event::PromptStart]); - } - - #[test] - fn detect_prompt_start_st() { - let data = b"\x1b]133;A\x1b\\"; - assert_eq!(parse_events(data), vec![Event::PromptStart]); - } - - #[test] - fn detect_command_start_bel() { - let data = b"\x1b]133;B\x07"; - assert_eq!(parse_events(data), vec![Event::CommandStart]); - } - - #[test] - fn detect_command_start_st() { - let data = b"\x1b]133;B\x1b\\"; - assert_eq!(parse_events(data), vec![Event::CommandStart]); - } - - #[test] - fn detect_command_executed_bel() { - let data = b"\x1b]133;C\x07"; - assert_eq!(parse_events(data), vec![Event::CommandExecuted]); - } - - #[test] - fn detect_command_executed_st() { - let data = b"\x1b]133;C\x1b\\"; - assert_eq!(parse_events(data), vec![Event::CommandExecuted]); - } - - #[test] - fn detect_command_finished_no_exit_code() { - let data = b"\x1b]133;D\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { exit_code: None }] - ); - } - - #[test] - fn detect_command_finished_exit_zero() { - let data = b"\x1b]133;D;0\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { exit_code: Some(0) }] - ); - } - - #[test] - fn detect_command_finished_exit_nonzero() { - let data = b"\x1b]133;D;127\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { - exit_code: Some(127) - }] - ); - } - - #[test] - fn detect_command_finished_negative_exit_code() { - let data = b"\x1b]133;D;-1\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { - exit_code: Some(-1) - }] - ); - } - - #[test] - fn detect_command_finished_exit_code_st() { - let data = b"\x1b]133;D;42\x1b\\"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { - exit_code: Some(42) - }] - ); - } - - #[test] - fn invalid_exit_code_yields_none() { - let data = b"\x1b]133;D;abc\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { exit_code: None }] - ); - } - - // -- Zone tracking -------------------------------------------------------- - - #[test] - fn zone_starts_unknown() { - let parser = Parser::new(); - assert_eq!(parser.zone(), Zone::Unknown); - } - - #[test] - fn full_zone_cycle() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b]133;A\x07", |e| events.push(e)); - assert_eq!(parser.zone(), Zone::Prompt); - - parser.push(b"\x1b]133;B\x07", |e| events.push(e)); - assert_eq!(parser.zone(), Zone::Input); - - parser.push(b"\x1b]133;C\x07", |e| events.push(e)); - assert_eq!(parser.zone(), Zone::Output); - - parser.push(b"\x1b]133;D;0\x07", |e| events.push(e)); - assert_eq!(parser.zone(), Zone::Unknown); - - assert_eq!( - events, - vec![ - Event::PromptStart, - Event::CommandStart, - Event::CommandExecuted, - Event::CommandFinished { exit_code: Some(0) }, - ] - ); - } - - // -- Multiple events in one push ------------------------------------------ - - #[test] - fn multiple_events_single_push() { - let data = b"\x1b]133;A\x07$ \x1b]133;B\x07ls\n\x1b]133;C\x07file.txt\n\x1b]133;D;0\x07"; - let events = parse_events(data); - assert_eq!( - events, - vec![ - Event::PromptStart, - Event::CommandStart, - Event::CommandExecuted, - Event::CommandFinished { exit_code: Some(0) }, - ] - ); - } - - // -- Split across push boundaries ----------------------------------------- - - #[test] - fn split_esc_and_bracket() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b", |e| events.push(e)); - assert!(events.is_empty()); - - parser.push(b"]133;A\x07", |e| events.push(e)); - assert_eq!(events, vec![Event::PromptStart]); - } - - #[test] - fn split_mid_param() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b]13", |e| events.push(e)); - assert!(events.is_empty()); - - parser.push(b"3;D;42\x07", |e| events.push(e)); - assert_eq!( - events, - vec![Event::CommandFinished { - exit_code: Some(42) - }] - ); - } - - #[test] - fn split_before_terminator() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b]133;B", |e| events.push(e)); - assert!(events.is_empty()); - - parser.push(b"\x07", |e| events.push(e)); - assert_eq!(events, vec![Event::CommandStart]); - } - - #[test] - fn split_esc_backslash_terminator() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b]133;C\x1b", |e| events.push(e)); - assert!(events.is_empty()); - - parser.push(b"\\", |e| events.push(e)); - assert_eq!(events, vec![Event::CommandExecuted]); - } - - // -- Interleaved normal text ---------------------------------------------- - - #[test] - fn normal_text_before_and_after() { - let data = b"hello world\x1b]133;A\x07prompt text\x1b]133;B\x07command"; - let events = parse_events(data); - assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); - } - - // -- Non-133 OSC sequences (should be ignored) ---------------------------- - - #[test] - fn non_133_osc_ignored() { - let data = b"\x1b]0;window title\x07\x1b]133;A\x07"; - let events = parse_events(data); - assert_eq!(events, vec![Event::PromptStart]); - } - - #[test] - fn osc_7_ignored() { - let data = b"\x1b]7;file:///home/user\x07"; - assert!(parse_events(data).is_empty()); - } - - // -- Unknown command letter ----------------------------------------------- - - #[test] - fn unknown_command_ignored() { - let data = b"\x1b]133;Z\x07"; - assert!(parse_events(data).is_empty()); - } - - // -- Malformed sequences -------------------------------------------------- - - #[test] - fn esc_followed_by_non_bracket() { - let data = b"\x1b[31m\x1b]133;A\x07"; - let events = parse_events(data); - assert_eq!(events, vec![Event::PromptStart]); - } - - #[test] - fn lone_esc_at_end_of_chunk() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - parser.push(b"\x1b", |e| events.push(e)); - assert!(events.is_empty()); - - // Feed non-bracket to abort the escape, then a real sequence. - parser.push(b"x\x1b]133;A\x07", |e| events.push(e)); - assert_eq!(events, vec![Event::PromptStart]); - } - - #[test] - fn truncated_133_prefix() { - // "13" followed by terminator — not "133;" so no event. - let data = b"\x1b]13\x07"; - assert!(parse_events(data).is_empty()); - } - - #[test] - fn empty_osc() { - let data = b"\x1b]\x07"; - assert!(parse_events(data).is_empty()); - } - - // -- Buffer overflow (very long non-133 OSC) ------------------------------ - - #[test] - fn very_long_osc_does_not_panic() { - let mut data = Vec::new(); - data.extend_from_slice(b"\x1b]"); - data.extend(std::iter::repeat(b'x').take(1000)); - data.push(BEL); - // Should not panic and should produce no event. - assert!(parse_events(&data).is_empty()); - } - - // -- Empty input ---------------------------------------------------------- - - #[test] - fn empty_input() { - assert!(parse_events(b"").is_empty()); - } - - #[test] - fn only_normal_text() { - let data = b"just some regular terminal output\r\n"; - assert!(parse_events(data).is_empty()); - } - - // -- Repeated prompts (empty command) ------------------------------------ - - #[test] - fn repeated_prompt_cycle() { - let mut parser = Parser::new(); - let mut events = Vec::new(); - - // User hits enter on an empty prompt twice. - let data = b"\x1b]133;A\x07$ \x1b]133;B\x07\x1b]133;D\x07\x1b]133;A\x07$ \x1b]133;B\x07"; - parser.push(data, |e| events.push(e)); - - assert_eq!( - events, - vec![ - Event::PromptStart, - Event::CommandStart, - Event::CommandFinished { exit_code: None }, - Event::PromptStart, - Event::CommandStart, - ] - ); - assert_eq!(parser.zone(), Zone::Input); - } - - // -- Byte-at-a-time feeding ----------------------------------------------- - - #[test] - fn byte_at_a_time() { - let data = b"\x1b]133;D;99\x07"; - let mut parser = Parser::new(); - let mut events = Vec::new(); - - for &byte in data { - parser.push(&[byte], |e| events.push(e)); - } - - assert_eq!( - events, - vec![Event::CommandFinished { - exit_code: Some(99) - }] - ); - } - - // -- Mixed terminators ---------------------------------------------------- - - #[test] - fn mixed_bel_and_st_terminators() { - let data = b"\x1b]133;A\x07\x1b]133;B\x1b\\\x1b]133;C\x07\x1b]133;D;1\x1b\\"; - let events = parse_events(data); - assert_eq!( - events, - vec![ - Event::PromptStart, - Event::CommandStart, - Event::CommandExecuted, - Event::CommandFinished { exit_code: Some(1) }, - ] - ); - } - - // -- Default trait -------------------------------------------------------- - - #[test] - fn parser_default() { - let parser = Parser::default(); - assert_eq!(parser.zone(), Zone::Unknown); - } - - #[test] - fn zone_default() { - assert_eq!(Zone::default(), Zone::Unknown); - } - - // -- D with empty exit code field ----------------------------------------- - - #[test] - fn d_with_semicolon_but_empty_code() { - // "133;D;" — semicolon present but no digits. - let data = b"\x1b]133;D;\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { exit_code: None }] - ); - } - - // -- Consecutive OSC sequences without gap -------------------------------- - - #[test] - fn back_to_back_osc_no_gap() { - let data = b"\x1b]133;A\x07\x1b]133;B\x07"; - let events = parse_events(data); - assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); - } - - // -- CSI sequences interleaved (should not confuse parser) ---------------- - - #[test] - fn csi_sequences_ignored() { - // CSI (ESC [) color codes mixed with OSC 133. - let data = b"\x1b[32m\x1b]133;A\x07\x1b[0m$ \x1b]133;B\x07"; - let events = parse_events(data); - assert_eq!(events, vec![Event::PromptStart, Event::CommandStart]); - } - - // -- Large exit codes ----------------------------------------------------- - - #[test] - fn large_exit_code() { - let data = b"\x1b]133;D;2147483647\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { - exit_code: Some(i32::MAX) - }] - ); - } - - #[test] - fn overflow_exit_code_yields_none() { - let data = b"\x1b]133;D;9999999999999\x07"; - assert_eq!( - parse_events(data), - vec![Event::CommandFinished { exit_code: None }] - ); - } -} diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index c3f8c786..87b3dbd7 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -33,11 +33,12 @@ buildflags = ["--release"] atuin = { path = "/usr/bin/atuin" } [features] -default = ["client", "sync", "clipboard", "check-update", "daemon", "ai"] +default = ["client", "sync", "clipboard", "check-update", "daemon", "ai", "hex"] client = ["atuin-client"] sync = ["atuin-client/sync"] daemon = ["atuin-client/daemon", "atuin-daemon"] ai = ["atuin-ai"] +hex = ["atuin-hex"] clipboard = ["arboard"] check-update = ["atuin-client/check-update"] @@ -48,6 +49,7 @@ atuin-common = { workspace = true } atuin-dotfiles = { workspace = true } atuin-history = { workspace = true } atuin-daemon = { path = "../atuin-daemon", version = "18.13.0-beta.3", optional = true, default-features = false } +atuin-hex = { path = "../atuin-hex", version = "18.13.0-beta.3", optional = true, default-features = false } atuin-scripts = { workspace = true } atuin-kv = { workspace = true } diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 8eea2aa2..4acf7be1 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -3,6 +3,9 @@ use std::{ time::Duration, }; +#[cfg(unix)] +use std::io::Read as _; + use atuin_common::{shell::Shell, utils::Escapable as _}; use eyre::Result; use futures_util::FutureExt; @@ -35,13 +38,13 @@ use ratatui::{ crossterm::{ cursor::SetCursorStyle, event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent, MouseEvent}, - execute, terminal, + execute, queue, terminal, }, layout::{Alignment, Constraint, Direction, Layout}, prelude::*, style::{Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, Padding, Paragraph, Tabs}, + widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs}, }; #[cfg(not(target_os = "windows"))] @@ -806,6 +809,7 @@ impl State { #[allow(clippy::bool_to_int_with_if)] #[allow(clippy::too_many_lines)] + #[allow(clippy::too_many_arguments)] fn draw( &mut self, f: &mut Frame, @@ -814,6 +818,27 @@ impl State { inspecting: Option<&History>, settings: &Settings, theme: &Theme, + popup_mode: bool, + ) { + let area = f.area(); + if popup_mode { + f.render_widget(Clear, area); + } + self.draw_inner(f, area, results, stats, inspecting, settings, theme); + } + + #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_lines)] + #[allow(clippy::bool_to_int_with_if)] + fn draw_inner( + &mut self, + f: &mut Frame, + area: Rect, + results: &[History], + stats: Option, + inspecting: Option<&History>, + settings: &Settings, + theme: &Theme, ) { let compactness = to_compactness(f, settings); let invert = settings.invert; @@ -821,7 +846,7 @@ impl State { Compactness::Full => 1, _ => 0, }; - let preview_width = f.area().width - 2; + let preview_width = area.width.saturating_sub(2); let preview_height = Self::calc_preview_height( settings, results, @@ -832,7 +857,7 @@ impl State { preview_width, ); let show_help = - settings.show_help && (matches!(compactness, Compactness::Full) || f.area().height > 1); + settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1); // This is an OR, as it seems more likely for someone to wish to override // tabs unexpectedly being missed, than unexpectedly present. let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact); @@ -869,7 +894,7 @@ impl State { } .as_ref(), ) - .split(f.area()); + .split(area); let input_chunk = if invert { chunks[0] } else { chunks[3] }; let results_list_chunk = if invert { chunks[1] } else { chunks[2] }; @@ -1274,6 +1299,118 @@ impl Write for TerminalWriter { } } +/// Screen state captured from atuin-hex's screen server. +#[cfg(unix)] +struct SavedScreen { + #[allow(dead_code)] + rows: u16, + #[allow(dead_code)] + cols: u16, + cursor_row: u16, + cursor_col: u16, + /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. + rows_data: Vec>, +} + +/// Connect to atuin-hex's Unix socket and fetch the current screen state. +/// +/// The wire format is: +/// ```text +/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] +/// [row_0_len: u32 BE][row_0_bytes...] +/// [row_1_len: u32 BE][row_1_bytes...] +/// ... +/// ``` +#[cfg(unix)] +fn fetch_screen_state(socket_path: &str) -> Option { + use std::os::unix::net::UnixStream; + + let mut stream = UnixStream::connect(socket_path).ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + + let mut data = Vec::new(); + stream.read_to_end(&mut data).ok()?; + + if data.len() < 8 { + return None; + } + + let rows = u16::from_be_bytes([data[0], data[1]]); + let cols = u16::from_be_bytes([data[2], data[3]]); + let cursor_row = u16::from_be_bytes([data[4], data[5]]); + let cursor_col = u16::from_be_bytes([data[6], data[7]]); + + // Parse length-prefixed rows + let mut rows_data = Vec::with_capacity(rows as usize); + let mut offset = 8; + while offset + 4 <= data.len() { + let row_len = u32::from_be_bytes([ + data[offset], + data[offset + 1], + data[offset + 2], + data[offset + 3], + ]) as usize; + offset += 4; + if offset + row_len > data.len() { + break; + } + rows_data.push(data[offset..offset + row_len].to_vec()); + offset += row_len; + } + + Some(SavedScreen { + rows, + cols, + cursor_row, + cursor_col, + rows_data, + }) +} + +/// Restore the screen area that was covered by the popup. +/// +/// Writes the pre-formatted per-row ANSI bytes received from atuin-hex +/// directly to stdout, which correctly handles wide characters, colors, and +/// all text attributes without needing a client-side vt100 parser. +#[cfg(unix)] +fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) { + use ratatui::crossterm::cursor::MoveTo; + + let mut stdout = stdout(); + + for dy in 0..popup_rect.height { + let target_row = popup_rect.y + dy; + let source_row = (target_row + scroll_offset) as usize; + + // Clear only the popup region. The server-side rows_formatted() skips + // default cells (spaces with default attributes) using cursor jumps, so + // any popup content at those positions would remain if not cleared + // beforehand. We write `popup_rect.width` spaces instead of + // ClearType::CurrentLine so that only the popup area is cleared, not + // the entire terminal line. + let _ = execute!( + stdout, + MoveTo(popup_rect.x, target_row), + ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset), + ); + let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize); + let _ = execute!(stdout, MoveTo(popup_rect.x, target_row)); + + if let Some(row_bytes) = saved.rows_data.get(source_row) { + let _ = stdout.write_all(row_bytes); + } + } + + let _ = execute!( + stdout, + MoveTo( + saved.cursor_col, + saved.cursor_row.saturating_sub(scroll_offset) + ) + ); + let _ = stdout.flush(); +} + struct Stdout { writer: TerminalWriter, inline_mode: bool, @@ -1364,6 +1501,39 @@ impl Write for Stdout { } // this is a big blob of horrible! clean it up! +/// Compute the popup position and any scroll offset needed to make room. +/// +/// Given the cursor row, terminal dimensions, and desired popup height, +/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number +/// of lines the caller should scroll the terminal up before rendering. +/// +/// This function performs no I/O — it is a pure computation. +fn compute_popup_placement( + cursor_row: u16, + term_rows: u16, + term_cols: u16, + inline_height: u16, +) -> (Rect, u16) { + let popup_w = term_cols; + let popup_h = inline_height.min(term_rows); + let space_below = term_rows.saturating_sub(cursor_row); + + let (popup_y, scroll) = if popup_h <= space_below { + // Fits below cursor + (cursor_row, 0u16) + } else if cursor_row >= term_rows / 2 { + // Bottom half — render above cursor (overlay on existing text) + (cursor_row.saturating_sub(popup_h), 0u16) + } else { + // Top half, not enough space — scroll terminal to make room + let scroll = popup_h.saturating_sub(space_below); + let popup_y = cursor_row.saturating_sub(scroll); + (popup_y, scroll) + }; + + (Rect::new(0, popup_y, popup_w, popup_h), scroll) +} + // for now, it works. But it'd be great if it were more easily readable, and // modular. I'd like to add some more stats and stuff at some point #[allow( @@ -1404,12 +1574,81 @@ pub async fn history( inline_height }; + // Popup mode: if running under atuin-hex and inline mode is requested, + // fetch the screen state and render as a centered overlay. + #[cfg(unix)] + let (saved_screen, popup_rect, popup_scroll_offset) = { + let socket_path = std::env::var("ATUIN_HEX_SOCKET").ok(); + if let Some(ref path) = socket_path + && inline_height > 0 + { + let saved = fetch_screen_state(path); + if let Some(ref s) = saved { + let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows)); + let (popup_rect, scroll) = + compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height); + + // Scroll terminal content up to make room if needed + if scroll > 0 { + use ratatui::crossterm::cursor::MoveTo; + let mut stdout = stdout(); + let _ = execute!(stdout, MoveTo(0, term_rows - 1)); + for _ in 0..scroll { + let _ = writeln!(stdout); + } + let _ = stdout.flush(); + } + + (saved, popup_rect, scroll) + } else { + (None, Rect::default(), 0u16) + } + } else { + (None, Rect::default(), 0u16) + } + }; + + #[cfg(not(unix))] + let (saved_screen, popup_rect, popup_scroll_offset): (Option<()>, Rect, u16) = + (None, Rect::default(), 0); + + let popup_mode = saved_screen.is_some(); + let stdout = Stdout::new(inline_height > 0, stdout_is_terminal)?; + + // In popup mode, clear the popup region on the physical terminal before + // ratatui takes over. Ratatui's diff-based rendering compares against an + // initially-empty buffer, so cells that remain "empty" (spaces with default + // style) won't be written — leaving underlying terminal text visible. + // By pre-clearing with spaces, those cells are already correct on screen. + if popup_mode { + use ratatui::crossterm::cursor::MoveTo; + let mut raw_stdout = std::io::stdout(); + // Queue all commands without flushing so the terminal receives them + // as a single write — no intermediate cursor positions are visible. + let _ = queue!( + raw_stdout, + ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset) + ); + for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { + let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row)); + let _ = write!( + raw_stdout, + "{:width$}", + "", + width = popup_rect.width as usize + ); + } + let _ = raw_stdout.flush(); + } + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::with_options( backend, TerminalOptions { - viewport: if inline_height > 0 { + viewport: if popup_mode { + Viewport::Fixed(popup_rect) + } else if inline_height > 0 { Viewport::Inline(inline_height) } else { Viewport::Fullscreen @@ -1498,7 +1737,7 @@ pub async fn history( let mut results = app.query_results(&mut db, settings.smart_sort).await?; - if inline_height > 0 { + if inline_height > 0 && !popup_mode { terminal.clear()?; } @@ -1514,6 +1753,7 @@ pub async fn history( inspecting.as_ref(), settings, theme, + popup_mode, ); })?; @@ -1566,8 +1806,12 @@ pub async fn history( } }, InputAction::Redraw => { - terminal.clear()?; - terminal.draw(|f| app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme))?; + if !popup_mode { + terminal.clear()?; + } + terminal.draw(|f| { + app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme, popup_mode); + })?; }, r => { accept = app.accept; @@ -1647,7 +1891,14 @@ pub async fn history( app.finalize_keymap_cursor(settings); - if inline_height > 0 { + if popup_mode { + // In popup mode, restore the screen area that was covered by the popup. + // This must happen before Stdout is dropped (which disables raw mode). + #[cfg(unix)] + if let Some(ref saved) = saved_screen { + restore_popup_area(saved, popup_rect, popup_scroll_offset); + } + } else if inline_height > 0 { terminal.clear()?; } diff --git a/crates/atuin/src/command/mod.rs b/crates/atuin/src/command/mod.rs index d9fa53df..7896628d 100644 --- a/crates/atuin/src/command/mod.rs +++ b/crates/atuin/src/command/mod.rs @@ -21,6 +21,13 @@ pub enum AtuinCmd { #[command(flatten)] Client(client::Cmd), + /// Terminal emulator for atuin + #[cfg(feature = "hex")] + Hex { + #[command(subcommand)] + cmd: Option, + }, + /// Generate a UUID Uuid, @@ -47,6 +54,12 @@ impl AtuinCmd { #[cfg(feature = "client")] Self::Client(client) => client.run(), + #[cfg(feature = "hex")] + Self::Hex { cmd } => { + atuin_hex::run(cmd); + Ok(()) + } + Self::Contributors => { contributors::run(); Ok(()) -- cgit v1.3.1