From ca30e9a0ee06fd2d83fb517a0271bc9ef14054c0 Mon Sep 17 00:00:00 2001 From: Gregor Lohaus Date: Wed, 9 Sep 2026 12:49:47 +0200 Subject: [PATCH] explicit scrollback editor command --- Cargo.lock | 1 + Cargo.toml | 1 + config.example.toml | 6 ++ flake.nix | 1 + src/app.rs | 8 ++ src/config.rs | 17 ++++ src/pane.rs | 194 +++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 227 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 5b696a8..e388b01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2319,6 +2319,7 @@ dependencies = [ "png", "portable-pty", "serde", + "shell-words", "toml", "wgpu", ] diff --git a/Cargo.toml b/Cargo.toml index 96293cb..e55f371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] } png = "0.18" portable-pty = "0.9" serde = { version = "1", features = ["derive"] } +shell-words = "1" toml = "0.9" wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } diff --git a/config.example.toml b/config.example.toml index af68f20..c4cc8ff 100644 --- a/config.example.toml +++ b/config.example.toml @@ -18,6 +18,10 @@ # regular-only fonts retain synthetic bold/italic emphasis. font_size = 16 scrollback = 10000 +# Command for opening scrollback; omit to use $EDITOR, or vi if unset. +# Supports quoted arguments; the scrollback file path is appended automatically. +# Runs directly without shell expansion. The editor should stay in the foreground. +# scrollback_editor = "nvim -R" window_width = 1100 window_height = 720 @@ -67,6 +71,8 @@ floating_max_height = 640 "Alt+Shift+H" = "previous_tab" "Alt+Shift+L" = "next_tab" "Alt+X" = "close_pane" +# Open scrollback in scrollback_editor (or $EDITOR/vi), returning on exit. +"Alt+S" = "open_scrollback" "Ctrl+Shift+Z" = "zoom_pane" "Ctrl+Shift+R" = "reload_config" "Ctrl+Shift+C" = "copy" diff --git a/flake.nix b/flake.nix index 1971a9a..6e9c0a9 100644 --- a/flake.nix +++ b/flake.nix @@ -207,6 +207,7 @@ checkFlags = [ "--test-threads=1" "--skip=app::tests::" + "--skip=pane::scrollback_tests::" "--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized" "--skip=tests::new_panes_start_in_the_directory_of_the_pane_they_came_from" "--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine" diff --git a/src/app.rs b/src/app.rs index 75846b0..b484979 100644 --- a/src/app.rs +++ b/src/app.rs @@ -235,6 +235,13 @@ impl App { } } Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + Action::OpenScrollback => { + self.panes.get_mut(&active).unwrap().open_scrollback( + &self.config, + &self.theme, + ctx, + )?; + } } Ok(()) } @@ -425,6 +432,7 @@ impl eframe::App for App { ("Zoom / restore", Action::ZoomPane), ("Next pane", Action::NextPane), ("Close pane", Action::ClosePane), + ("Open scrollback in editor", Action::OpenScrollback), ] { if ui.button(label).clicked() { action = Some(a); diff --git a/src/config.rs b/src/config.rs index 9f2bc54..905cf38 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub struct Config { pub font_bold_italic_path: Option, pub font_fallbacks: Vec, pub scrollback: usize, + pub scrollback_editor: Option, pub window_width: f32, pub window_height: f32, pub theme: String, @@ -77,6 +78,7 @@ pub enum Action { ReloadConfig, Copy, Paste, + OpenScrollback, } impl Default for Kitty { @@ -115,6 +117,7 @@ impl Default for Config { font_bold_italic_path: None, font_fallbacks: Vec::new(), scrollback: 10000, + scrollback_editor: None, window_width: 1100.0, window_height: 720.0, theme: theme::DEFAULT_NAME.into(), @@ -135,6 +138,7 @@ impl Default for Config { ("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+L", Action::NextTab), ("Alt+X", Action::ClosePane), + ("Alt+S", Action::OpenScrollback), ("Ctrl+Shift+Z", Action::ZoomPane), ("Ctrl+Shift+R", Action::ReloadConfig), ("Ctrl+Shift+C", Action::Copy), @@ -186,6 +190,14 @@ impl Config { !config.shell.is_empty() && !config.shell[0].is_empty(), "shell must contain an executable" ); + if let Some(editor) = &config.scrollback_editor { + let command = + shell_words::split(editor).context("Invalid scrollback_editor command")?; + ensure!( + !command.is_empty() && !command[0].is_empty(), + "scrollback_editor must contain an executable" + ); + } ensure!( (8.0..=48.0).contains(&config.font_size), "font_size must be 8..48" @@ -307,6 +319,10 @@ mod tests { "font_fallbacks = [' ']", "font_family = 'Test'\nfont_path = '/font.ttf'", "shell = []", + "scrollback_editor = ''", + "scrollback_editor = ' '", + "scrollback_editor = \"''\"", + "scrollback_editor = \"'\"", "background = '#xx0000'", "font_sze = 16", "[keys]\n'Hyper+Q' = 'close_pane'", @@ -330,6 +346,7 @@ mod tests { ("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+L", Action::NextTab), ("Alt+X", Action::ClosePane), + ("Alt+S", Action::OpenScrollback), ] { assert_eq!(config.keys.get(binding), Some(&action)); assert!(shortcut(binding).unwrap().modifiers.alt); diff --git a/src/pane.rs b/src/pane.rs index 3ae13d8..daa7710 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -5,7 +5,7 @@ use crate::{ render::{Grid, Renderer, cell_size}, theme::{self, Theme}, }; -use anyhow::{Result, ensure}; +use anyhow::{Context, Result, ensure}; use egui::{FontId, Rect, Sense, vec2}; use libghostty_vt::{ Terminal, TerminalOptions, mouse, @@ -33,6 +33,134 @@ pub struct Pane { mouse_event: mouse::Event<'static>, selection_start: Option, focused: bool, + saved_session: Option<(Box, ScrollbackFile)>, +} + +struct ScrollbackFile(PathBuf); + +impl ScrollbackFile { + fn new(bytes: &[u8]) -> Result { + use std::{ + fs::OpenOptions, + io::Write, + os::unix::fs::OpenOptionsExt, + sync::atomic::{AtomicU64, Ordering}, + }; + static NEXT: AtomicU64 = AtomicU64::new(0); + loop { + let path = std::env::temp_dir().join(format!( + "slerm-scrollback-{}-{}.txt", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + match OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + { + Ok(mut file) => { + let temporary = Self(path); + file.write_all(bytes)?; + return Ok(temporary); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error.into()), + } + } + } +} + +#[cfg(test)] +mod scrollback_tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn editor_receives_history_and_restores_original_session_on_exit() { + let ctx = egui::Context::default(); + let config = Config { + shell: vec![ + "/bin/sh".into(), + "-c".into(), + "read answer; printf ORIGINAL_ALIVE; read answer".into(), + ], + scrollback_editor: Config::parse( + "scrollback_editor = \"/bin/sh -c 'printf EDITOR_READY; read answer' editor\"", + ) + .unwrap() + .scrollback_editor, + ..Default::default() + }; + let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap(); + pane.terminal.resize(12, 2, 8, 16).unwrap(); + pane.terminal + .vt_write(b"history\r\n\x1b[31mred\x1b[0m\r\nlong line that wraps\r\nlast"); + pane.open_scrollback(&config, &theme::DEFAULT, &ctx) + .unwrap(); + let (original, file) = pane.saved_session.as_ref().unwrap(); + assert!(!original.dead); + let path = file.0.clone(); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!( + contents.contains("history\nred\nlong line that wraps\nlast"), + "{contents:?}" + ); + assert!(!contents.contains('\x1b')); + pane.send(b"done\n".to_vec()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while pane.saved_session.is_some() && Instant::now() < deadline { + pane.drain(&ctx); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(pane.saved_session.is_none()); + assert!(!pane.dead); + assert!(!path.exists()); + pane.send(b"continue\n".to_vec()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + pane.drain(&ctx); + let text = libghostty_vt::fmt::Formatter::new( + &pane.terminal, + libghostty_vt::fmt::FormatterOptions::new().with_unwrap(true), + ) + .unwrap() + .format_alloc(None) + .unwrap(); + if String::from_utf8_lossy(&text).contains("ORIGINAL_ALIVE") { + break; + } + assert!( + Instant::now() < deadline, + "original process did not resume input" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn invalid_editor_leaves_the_original_pane_intact() { + let ctx = egui::Context::default(); + let config = Config { + shell: vec!["/bin/sh".into()], + ..Default::default() + }; + let mut pane = Pane::new(&config, &theme::DEFAULT, &ctx, None).unwrap(); + for editor in ["", "'", "/no/such/slerm-editor"] { + assert!( + pane.open_scrollback_with_editor(&config, &theme::DEFAULT, &ctx, editor) + .is_err() + ); + assert!(pane.saved_session.is_none()); + assert!(!pane.dead); + } + } +} + +impl Drop for ScrollbackFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } } /// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be @@ -181,10 +309,14 @@ impl Pane { mouse_event, selection_start: None, focused: false, + saved_session: None, }) } pub fn drain(&mut self, ctx: &egui::Context) { + if let Some((session, _)) = &mut self.saved_session { + session.drain(ctx); + } // Bound work per frame as well as queued bytes, so one busy PTY cannot starve others. let mut drained = 0; for _ in 0..64 { @@ -210,6 +342,66 @@ impl Pane { if drained == 64 { ctx.request_repaint(); } + if self.dead + && let Some((session, _file)) = self.saved_session.take() + { + *self = *session; + ctx.request_repaint(); + } + } + + pub fn open_scrollback( + &mut self, + config: &Config, + theme: &Theme, + ctx: &egui::Context, + ) -> Result<()> { + if self.saved_session.is_some() { + return Ok(()); + } + let editor = config + .scrollback_editor + .clone() + .unwrap_or_else(|| std::env::var("EDITOR").unwrap_or_else(|_| "vi".into())); + self.open_scrollback_with_editor(config, theme, ctx, &editor) + } + + fn open_scrollback_with_editor( + &mut self, + config: &Config, + theme: &Theme, + ctx: &egui::Context, + editor: &str, + ) -> Result<()> { + use libghostty_vt::fmt::{Formatter, FormatterOptions}; + let mut command = + shell_words::split(editor).context("Invalid scrollback editor command")?; + ensure!( + !command.is_empty() && !command[0].is_empty(), + "Scrollback editor command is empty" + ); + self.drain(ctx); + let bytes = Formatter::new( + &self.terminal, + FormatterOptions::new().with_trim(true).with_unwrap(true), + )? + .format_alloc(None)?; + let file = ScrollbackFile::new(&bytes).context("Cannot save scrollback")?; + command.push( + file.0 + .to_str() + .context("Scrollback path is not valid UTF-8")? + .into(), + ); + let mut editor_config = config.clone(); + editor_config.shell = command; + let mut editor_pane = Self::new(&editor_config, theme, ctx, self.cwd().as_deref()) + .context("Cannot open scrollback editor")?; + editor_pane.last_rect = self.last_rect; + let session = std::mem::replace(self, editor_pane); + self.saved_session = Some((Box::new(session), file)); + ctx.request_repaint(); + Ok(()) } pub fn send(&mut self, bytes: Vec) -> Result<()> {