diff options
| author | Ellie Huxtable <ellie@atuin.sh> | 2026-03-09 14:28:32 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-09 14:28:32 -0700 |
| commit | b4a17e4346c97d837d0ee3a3a55c5ceca789a3e8 (patch) | |
| tree | 4be327a9f902455a870232d36e2cd4fb4206804d | |
| parent | chore: update to Rust 1.94 (#3247) (diff) | |
| download | atuin-b4a17e4346c97d837d0ee3a3a55c5ceca789a3e8.zip | |
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
Diffstat (limited to '')
| -rw-r--r-- | Cargo.lock | 121 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/debug_render.rs | 8 | ||||
| -rw-r--r-- | crates/atuin-ai/src/commands/inline.rs | 65 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/mod.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/popup.rs | 363 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/render.rs | 106 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/terminal.rs | 77 | ||||
| -rw-r--r-- | crates/atuin-ai/src/tui/view_model.rs | 75 | ||||
| -rw-r--r-- | crates/atuin-hex/Cargo.toml (renamed from crates/atuin-shell/Cargo.toml) | 7 | ||||
| -rw-r--r-- | crates/atuin-hex/src/lib.rs (renamed from crates/atuin-shell/src/main.rs) | 227 | ||||
| -rw-r--r-- | crates/atuin-hex/src/osc133.rs (renamed from crates/atuin-shell/src/osc133.rs) | 0 | ||||
| -rw-r--r-- | crates/atuin/Cargo.toml | 4 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/interactive.rs | 271 | ||||
| -rw-r--r-- | crates/atuin/src/command/mod.rs | 13 |
14 files changed, 1161 insertions, 178 deletions
@@ -153,6 +153,12 @@ dependencies = [ ] [[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] name = "async-stream" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -220,6 +226,7 @@ dependencies = [ "atuin-common", "atuin-daemon", "atuin-dotfiles", + "atuin-hex", "atuin-history", "atuin-kv", "atuin-scripts", @@ -417,6 +424,18 @@ dependencies = [ ] [[package]] +name = "atuin-hex" +version = "18.13.0-beta.3" +dependencies = [ + "clap", + "crossterm", + "eyre", + "portable-pty", + "signal-hook", + "vt100", +] + +[[package]] name = "atuin-history" version = "18.13.0-beta.3" dependencies = [ @@ -546,17 +565,6 @@ dependencies = [ ] [[package]] -name = "atuin-shell" -version = "18.13.0-beta.3" -dependencies = [ - "clap", - "crossterm", - "eyre", - "portable-pty", - "signal-hook", -] - -[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2593,9 +2601,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libm" @@ -2863,9 +2871,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minijinja" -version = "2.17.0" +version = "2.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ab6f50e4e8fb40bd21f527066bd019f5b029035b4e5ac9b9f9ba526c6bd87b" +checksum = "5ea5ea1e90055f200af6b8e52a4a34e05e77e7fee953a9fb40c631efdc43cab1" dependencies = [ "serde", ] @@ -3774,9 +3782,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] @@ -4719,12 +4727,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5816,9 +5824,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "atomic", "getrandom 0.4.2", @@ -5846,6 +5854,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "vt100" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cd863bf0db7e392ba3bd04994be3473491b31e66340672af5d11943c6274de" +dependencies = [ + "itoa", + "log", + "unicode-width 0.1.14", + "vte", +] + +[[package]] +name = "vte" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5022b5fbf9407086c180e9557be968742d839e68346af7792b8592489732197" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e369bee1b05d510a7b4ed645f5faa90619e05437111783ea5848f28d97d3c2e" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] name = "vtparse" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -6029,9 +6070,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "aa75f400b7f719bcd68b3f47cd939ba654cedeef690f486db71331eec4c6a406" dependencies = [ "cc", "downcast-rs", @@ -6042,9 +6083,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ "bitflags 2.11.0", "rustix", @@ -6054,9 +6095,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -6066,9 +6107,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -6079,9 +6120,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", "quick-xml", @@ -6090,9 +6131,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "374f6b70e8e0d6bf9461a32988fd553b59ff630964924dad6e4a4eb6bd538d17" dependencies = [ "pkg-config", ] @@ -6653,9 +6694,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -6829,18 +6870,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", 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<String>, 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::<Vec<_>>() }) - }).collect::<Vec<_>>() + }).collect::<Vec<_>>(), + "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<String>, 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<Vec<u8>>, +} + +/// 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<Rect> { + 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<PopupState> { + 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<SavedScreen> { + 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(); + let full_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); + // 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<Self> { + // 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<String>, } +/// 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<Block>, pub footer: &'static str, + /// Transient status shown on bottom border during streaming/generating + pub status_bar: Option<StatusBar>, } /// 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-shell/Cargo.toml b/crates/atuin-hex/Cargo.toml index c14072dd..8a574a55 100644 --- a/crates/atuin-shell/Cargo.toml +++ b/crates/atuin-hex/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "atuin-shell" +name = "atuin-hex" edition = "2024" description = "a terminal emulator for atuin" @@ -10,10 +10,6 @@ license = { workspace = true } homepage = { workspace = true } repository = { workspace = true } -[[bin]] -name = "atuin-shell" -path = "src/main.rs" - [dependencies] clap = { workspace = true } @@ -22,3 +18,4 @@ crossterm = { workspace = true } eyre = { workspace = true } portable-pty = "0.8" signal-hook = "0.3" +vt100 = "0.15" diff --git a/crates/atuin-shell/src/main.rs b/crates/atuin-hex/src/lib.rs index 337237de..ff37cfe3 100644 --- a/crates/atuin-shell/src/main.rs +++ b/crates/atuin-hex/src/lib.rs @@ -1,22 +1,15 @@ -mod osc133; +pub mod osc133; -use clap::{Args, Parser, Subcommand, ValueEnum}; - -#[derive(Parser, Debug)] -#[command(infer_subcommands = true)] -struct Cli { - #[command(subcommand)] - command: Option<Cmd>, -} +use clap::{Args, Subcommand, ValueEnum}; #[derive(Subcommand, Debug)] -enum Cmd { - /// Print shell code to initialize atuin-shell on shell startup +pub enum Cmd { + /// Print shell code to initialize atuin-hex on shell startup Init(Init), } #[derive(Args, Debug)] -struct Init { +pub struct Init { /// Shell to generate init for. If omitted, attempt auto-detection #[arg(value_enum)] shell: Option<Shell>, @@ -53,6 +46,18 @@ impl Init { } } +pub fn run(cmd: Option<Cmd>) { + 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<Shell>) -> Result<Shell, String> { if let Some(shell) = cli_shell { return Ok(shell); @@ -103,16 +108,16 @@ fn render_init(shell: Shell) -> String { 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:-}}" + _atuin_hex_tmux_current="${{TMUX:-}}" + _atuin_hex_tmux_previous="${{ATUIN_HEX_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 + 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_shell_tmux_current _atuin_shell_tmux_previous + unset _atuin_hex_tmux_current _atuin_hex_tmux_previous fi eval "$({init_command})" @@ -120,24 +125,24 @@ 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 "" + set -l _atuin_hex_tmux_current "" if set -q TMUX - set _atuin_shell_tmux_current "$TMUX" + set _atuin_hex_tmux_current "$TMUX" end - set -l _atuin_shell_tmux_previous "" - if set -q ATUIN_SHELL_TMUX - set _atuin_shell_tmux_previous "$ATUIN_SHELL_TMUX" + 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_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 + 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 @@ -147,24 +152,10 @@ end } } -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"); + eprintln!("atuin hex currently supports unix platforms excluding illumos"); std::process::exit(1); } } @@ -172,18 +163,73 @@ mod app { #[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<u8>), + Resize { rows: u16, cols: u16 }, + ScreenRequest(mpsc::Sender<Vec<u8>>), + } + pub(crate) fn main() { if let Err(e) = run() { let _ = terminal::disable_raw_mode(); - eprintln!("atuin-shell: {e:#}"); + 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<u8> { + let screen = parser.screen(); + let (rows, cols) = screen.size(); + let (cursor_row, cursor_col) = screen.cursor_position(); + + let mut buf: Vec<u8> = 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()?; @@ -197,8 +243,15 @@ mod app { }) .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) @@ -216,12 +269,72 @@ mod app { .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::<ParserMsg>(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 || { @@ -233,6 +346,7 @@ mod app { pixel_width: 0, pixel_height: 0, }); + let _ = resize_tx.try_send(ParserMsg::Resize { rows, cols }); } } }); @@ -240,7 +354,7 @@ mod app { terminal::enable_raw_mode()?; - // PTY -> stdout (with OSC 133 parsing) + // 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(); @@ -253,6 +367,12 @@ mod app { // 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; } @@ -283,6 +403,9 @@ mod app { 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())); } @@ -328,15 +451,15 @@ mod tests { #[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("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-shell")); + assert!(script.contains("exec atuin hex")); assert!(script.contains("atuin init fish | source")); } } diff --git a/crates/atuin-shell/src/osc133.rs b/crates/atuin-hex/src/osc133.rs index d6ee1220..d6ee1220 100644 --- a/crates/atuin-shell/src/osc133.rs +++ b/crates/atuin-hex/src/osc133.rs 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<HistoryStats>, + 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<Vec<u8>>, +} + +/// 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<SavedScreen> { + 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<atuin_hex::Cmd>, + }, + /// 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(()) |
