explicit scrollback editor command

This commit is contained in:
Gregor Lohaus
2026-09-09 12:49:47 +02:00
parent c5e4e5fb85
commit ca30e9a0ee
7 changed files with 227 additions and 1 deletions
Generated
+1
View File
@@ -2319,6 +2319,7 @@ dependencies = [
"png", "png",
"portable-pty", "portable-pty",
"serde", "serde",
"shell-words",
"toml", "toml",
"wgpu", "wgpu",
] ]
+1
View File
@@ -16,6 +16,7 @@ libghostty-vt = { version = "=0.2.1", features = ["png"] }
png = "0.18" png = "0.18"
portable-pty = "0.9" portable-pty = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
shell-words = "1"
toml = "0.9" toml = "0.9"
wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] } wgpu = { version = "27", default-features = false, features = ["std", "vulkan", "gles", "metal", "wgsl"] }
+6
View File
@@ -18,6 +18,10 @@
# regular-only fonts retain synthetic bold/italic emphasis. # regular-only fonts retain synthetic bold/italic emphasis.
font_size = 16 font_size = 16
scrollback = 10000 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_width = 1100
window_height = 720 window_height = 720
@@ -67,6 +71,8 @@ floating_max_height = 640
"Alt+Shift+H" = "previous_tab" "Alt+Shift+H" = "previous_tab"
"Alt+Shift+L" = "next_tab" "Alt+Shift+L" = "next_tab"
"Alt+X" = "close_pane" "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+Z" = "zoom_pane"
"Ctrl+Shift+R" = "reload_config" "Ctrl+Shift+R" = "reload_config"
"Ctrl+Shift+C" = "copy" "Ctrl+Shift+C" = "copy"
+1
View File
@@ -207,6 +207,7 @@
checkFlags = [ checkFlags = [
"--test-threads=1" "--test-threads=1"
"--skip=app::tests::" "--skip=app::tests::"
"--skip=pane::scrollback_tests::"
"--skip=tests::pty_executes_input_and_bracketed_paste_is_sanitized" "--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::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" "--skip=tests::osc7_wins_over_the_process_directory_but_only_for_this_machine"
+8
View File
@@ -235,6 +235,13 @@ impl App {
} }
} }
Action::Paste => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), 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(()) Ok(())
} }
@@ -425,6 +432,7 @@ impl eframe::App for App {
("Zoom / restore", Action::ZoomPane), ("Zoom / restore", Action::ZoomPane),
("Next pane", Action::NextPane), ("Next pane", Action::NextPane),
("Close pane", Action::ClosePane), ("Close pane", Action::ClosePane),
("Open scrollback in editor", Action::OpenScrollback),
] { ] {
if ui.button(label).clicked() { if ui.button(label).clicked() {
action = Some(a); action = Some(a);
+17
View File
@@ -21,6 +21,7 @@ pub struct Config {
pub font_bold_italic_path: Option<PathBuf>, pub font_bold_italic_path: Option<PathBuf>,
pub font_fallbacks: Vec<String>, pub font_fallbacks: Vec<String>,
pub scrollback: usize, pub scrollback: usize,
pub scrollback_editor: Option<String>,
pub window_width: f32, pub window_width: f32,
pub window_height: f32, pub window_height: f32,
pub theme: String, pub theme: String,
@@ -77,6 +78,7 @@ pub enum Action {
ReloadConfig, ReloadConfig,
Copy, Copy,
Paste, Paste,
OpenScrollback,
} }
impl Default for Kitty { impl Default for Kitty {
@@ -115,6 +117,7 @@ impl Default for Config {
font_bold_italic_path: None, font_bold_italic_path: None,
font_fallbacks: Vec::new(), font_fallbacks: Vec::new(),
scrollback: 10000, scrollback: 10000,
scrollback_editor: None,
window_width: 1100.0, window_width: 1100.0,
window_height: 720.0, window_height: 720.0,
theme: theme::DEFAULT_NAME.into(), theme: theme::DEFAULT_NAME.into(),
@@ -135,6 +138,7 @@ impl Default for Config {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
("Ctrl+Shift+Z", Action::ZoomPane), ("Ctrl+Shift+Z", Action::ZoomPane),
("Ctrl+Shift+R", Action::ReloadConfig), ("Ctrl+Shift+R", Action::ReloadConfig),
("Ctrl+Shift+C", Action::Copy), ("Ctrl+Shift+C", Action::Copy),
@@ -186,6 +190,14 @@ impl Config {
!config.shell.is_empty() && !config.shell[0].is_empty(), !config.shell.is_empty() && !config.shell[0].is_empty(),
"shell must contain an executable" "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!( ensure!(
(8.0..=48.0).contains(&config.font_size), (8.0..=48.0).contains(&config.font_size),
"font_size must be 8..48" "font_size must be 8..48"
@@ -307,6 +319,10 @@ mod tests {
"font_fallbacks = [' ']", "font_fallbacks = [' ']",
"font_family = 'Test'\nfont_path = '/font.ttf'", "font_family = 'Test'\nfont_path = '/font.ttf'",
"shell = []", "shell = []",
"scrollback_editor = ''",
"scrollback_editor = ' '",
"scrollback_editor = \"''\"",
"scrollback_editor = \"'\"",
"background = '#xx0000'", "background = '#xx0000'",
"font_sze = 16", "font_sze = 16",
"[keys]\n'Hyper+Q' = 'close_pane'", "[keys]\n'Hyper+Q' = 'close_pane'",
@@ -330,6 +346,7 @@ mod tests {
("Alt+Shift+H", Action::PreviousTab), ("Alt+Shift+H", Action::PreviousTab),
("Alt+Shift+L", Action::NextTab), ("Alt+Shift+L", Action::NextTab),
("Alt+X", Action::ClosePane), ("Alt+X", Action::ClosePane),
("Alt+S", Action::OpenScrollback),
] { ] {
assert_eq!(config.keys.get(binding), Some(&action)); assert_eq!(config.keys.get(binding), Some(&action));
assert!(shortcut(binding).unwrap().modifiers.alt); assert!(shortcut(binding).unwrap().modifiers.alt);
+193 -1
View File
@@ -5,7 +5,7 @@ use crate::{
render::{Grid, Renderer, cell_size}, render::{Grid, Renderer, cell_size},
theme::{self, Theme}, theme::{self, Theme},
}; };
use anyhow::{Result, ensure}; use anyhow::{Context, Result, ensure};
use egui::{FontId, Rect, Sense, vec2}; use egui::{FontId, Rect, Sense, vec2};
use libghostty_vt::{ use libghostty_vt::{
Terminal, TerminalOptions, mouse, Terminal, TerminalOptions, mouse,
@@ -33,6 +33,134 @@ pub struct Pane {
mouse_event: mouse::Event<'static>, mouse_event: mouse::Event<'static>,
selection_start: Option<PointCoordinate>, selection_start: Option<PointCoordinate>,
focused: bool, focused: bool,
saved_session: Option<(Box<Pane>, ScrollbackFile)>,
}
struct ScrollbackFile(PathBuf);
impl ScrollbackFile {
fn new(bytes: &[u8]) -> Result<Self> {
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 /// OSC 7 carries a `file://host/path` URL. Only a directory on this machine can be
@@ -181,10 +309,14 @@ impl Pane {
mouse_event, mouse_event,
selection_start: None, selection_start: None,
focused: false, focused: false,
saved_session: None,
}) })
} }
pub fn drain(&mut self, ctx: &egui::Context) { 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. // Bound work per frame as well as queued bytes, so one busy PTY cannot starve others.
let mut drained = 0; let mut drained = 0;
for _ in 0..64 { for _ in 0..64 {
@@ -210,6 +342,66 @@ impl Pane {
if drained == 64 { if drained == 64 {
ctx.request_repaint(); 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<u8>) -> Result<()> { pub fn send(&mut self, bytes: Vec<u8>) -> Result<()> {