aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@atuin.sh>2026-03-09 14:28:32 -0700
committerGitHub <noreply@github.com>2026-03-09 14:28:32 -0700
commitb4a17e4346c97d837d0ee3a3a55c5ceca789a3e8 (patch)
tree4be327a9f902455a870232d36e2cd4fb4206804d
parentchore: update to Rust 1.94 (#3247) (diff)
downloadatuin-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
-rw-r--r--Cargo.lock121
-rw-r--r--crates/atuin-ai/src/commands/debug_render.rs8
-rw-r--r--crates/atuin-ai/src/commands/inline.rs65
-rw-r--r--crates/atuin-ai/src/tui/mod.rs2
-rw-r--r--crates/atuin-ai/src/tui/popup.rs363
-rw-r--r--crates/atuin-ai/src/tui/render.rs106
-rw-r--r--crates/atuin-ai/src/tui/terminal.rs77
-rw-r--r--crates/atuin-ai/src/tui/view_model.rs75
-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.toml4
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs271
-rw-r--r--crates/atuin/src/command/mod.rs13
14 files changed, 1161 insertions, 178 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 6412f7dd..ae1a8f33 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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(())