From 7b8f6990837ed2df63b533c7bbcd5bf6835efef7 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 16 May 2026 18:33:23 +0300 Subject: [PATCH] feat: add scrollback editor keybind refs #122 --- .pi/docs/CHANGELOG.md | 3 + .pi/docs/CONFIGURATION.md | 4 + src/app/api.rs | 4 + src/app/input/navigate.rs | 193 +++++++++++++++++++++++++++++++++++++- src/app/mod.rs | 3 +- src/app/state.rs | 2 + src/config/keybinds.rs | 32 +++++-- src/config/model.rs | 3 + src/main.rs | 1 + src/ui/keybind_help.rs | 4 + 10 files changed, 236 insertions(+), 13 deletions(-) diff --git a/.pi/docs/CHANGELOG.md b/.pi/docs/CHANGELOG.md index 2af756a5..d3bacc74 100644 --- a/.pi/docs/CHANGELOG.md +++ b/.pi/docs/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- Added optional `keys.edit_scrollback` to open the focused pane's retained scrollback in `$EDITOR` inside a temporary zoomed pane. + ### Fixed - GitHub Copilot is now correctly detected when its process name is `copilot`. - Integration installs now respect `PI_CODING_AGENT_DIR`, `CLAUDE_CONFIG_DIR`, and `CODEX_HOME` when choosing Pi, Claude Code, and Codex config paths. diff --git a/.pi/docs/CONFIGURATION.md b/.pi/docs/CONFIGURATION.md index 073a369f..6295f5e3 100644 --- a/.pi/docs/CONFIGURATION.md +++ b/.pi/docs/CONFIGURATION.md @@ -103,6 +103,7 @@ split_vertical = "d" split_horizontal = "D" close_pane = "x" rename_pane = "" # optional, unset by default +edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR fullscreen = "f" resize_mode = "r" toggle_sidebar = "b" @@ -151,10 +152,13 @@ agents = "" # optional; follows visible agent panel order | `split_horizontal` | `-` | split pane horizontally (stacked) | | `close_pane` | `x` | close focused pane | | `rename_pane` | unset | rename the focused pane | +| `edit_scrollback` | unset | open the focused pane's retained scrollback in `$EDITOR` inside a temporary zoomed pane | | `fullscreen` | `f` | toggle focused pane fullscreen | | `resize_mode` | `r` | enter or leave resize mode | | `toggle_sidebar` | `b` | collapse or expand the sidebar | +`edit_scrollback` writes the focused pane's retained plain-text scrollback to a temporary file, opens `${EDITOR:-vi}` on that file in a temporary zoomed pane, then removes the file when the editor exits. + ### indexed keybindings Use `[keys.indexed]` to bind number keys `1` through `9` as positional shortcuts. Each value is a modifier combo only. Empty values disable that shortcut family. diff --git a/src/app/api.rs b/src/app/api.rs index 8c929e9a..50a56624 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -149,6 +149,10 @@ impl App { } fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) { + for temp_file in &overlay.temp_files { + let _ = std::fs::remove_file(temp_file); + } + let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else { return; }; diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 7675835b..7eb5b8d9 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -1,4 +1,9 @@ -use std::process::{Command, Stdio}; +use std::{ + fs, io, + io::Write, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, +}; use bytes::Bytes; use crossterm::event::{KeyCode, KeyEvent}; @@ -103,7 +108,11 @@ impl App { } if let Some(action) = navigate_action_for_key(&self.state, &key) { - execute_navigate_action(&mut self.state, action); + if action == NavigateAction::EditScrollback { + self.launch_focused_scrollback_editor(); + } else { + execute_navigate_action(&mut self.state, action); + } return; } @@ -137,7 +146,9 @@ impl App { let previous_toast = self.state.toast.clone(); let result = match binding.action { crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding), - crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command), + crate::config::CustomCommandAction::Pane => { + self.spawn_pane_command(&binding.command, Vec::new()) + } }; match result { Ok(()) => leave_navigate_mode(&mut self.state), @@ -218,7 +229,67 @@ impl App { Ok(()) } - fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> { + fn launch_focused_scrollback_editor(&mut self) { + let previous_toast = self.state.toast.clone(); + match self.open_focused_scrollback_in_editor() { + Ok(()) => self.sync_toast_deadline(previous_toast), + Err(err) => { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::NeedsAttention, + title: "edit scrollback failed".to_string(), + context: err.to_string(), + target: None, + }); + self.sync_toast_deadline(previous_toast); + } + } + } + + fn open_focused_scrollback_in_editor(&mut self) -> std::io::Result<()> { + let ws_idx = self + .state + .active + .ok_or_else(|| std::io::Error::other("no active workspace"))?; + let ws = self + .state + .workspaces + .get(ws_idx) + .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?; + let pane_id = ws + .focused_pane_id() + .ok_or_else(|| std::io::Error::other("no focused pane"))?; + let scrollback = ws + .focused_runtime() + .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))? + .recent_text(usize::MAX); + + let path = write_scrollback_temp_file(&scrollback)?; + + let quoted_path = shell_quote(&path.display().to_string()); + let command = format!( + r#"scrollback_file={quoted_path}; eval "${{EDITOR:-vi}} \"\$scrollback_file\""; status=$?; rm -f "$scrollback_file"; exit $status"# + ); + if let Err(err) = self.spawn_pane_command(&command, vec![path.clone()]) { + let _ = fs::remove_file(&path); + return Err(err); + } + + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.state.toast = Some(crate::app::state::ToastNotification { + kind: crate::app::state::ToastKind::Finished, + title: "opened scrollback".to_string(), + context: format!("focused pane {public_pane_id}"), + target: None, + }); + } + Ok(()) + } + + fn spawn_pane_command( + &mut self, + command: &str, + temp_files: Vec, + ) -> std::io::Result<()> { let Some(ws_idx) = self.state.active else { return Err(std::io::Error::other("no active workspace")); }; @@ -264,6 +335,7 @@ impl App { tab_idx, previous_focus, previous_zoomed, + temp_files, }, ); self.state.mode = Mode::Terminal; @@ -397,6 +469,7 @@ pub(crate) enum NavigateAction { SplitVertical, SplitHorizontal, ClosePane, + EditScrollback, Fullscreen, EnterResizeMode, ToggleSidebar, @@ -506,6 +579,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option {} NavigateAction::Fullscreen => { state.toggle_fullscreen(); leave_navigate_mode(state); @@ -674,6 +754,65 @@ fn leave_navigate_mode(state: &mut AppState) { } } +fn write_scrollback_temp_file(content: &str) -> io::Result { + let mut last_collision = None; + for attempt in 0..16 { + let path = unique_scrollback_path(attempt); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + match options.open(&path) { + Ok(mut file) => { + file.write_all(content.as_bytes())?; + return Ok(path); + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + last_collision = Some(err); + } + Err(err) => return Err(err), + } + } + + Err(last_collision.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to create unique scrollback temp file", + ) + })) +} + +fn unique_scrollback_path(attempt: u32) -> std::path::PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "herdr-scrollback-{}-{nanos}-{attempt}.txt", + std::process::id() + )) +} + +fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value.chars().all(|ch| { + ch.is_ascii_alphanumeric() + || matches!( + ch, + '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' + ) + }) + { + return value.to_string(); + } + + format!("'{}'", value.replace('\'', "'\\''")) +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -979,6 +1118,52 @@ mod tests { let _ = std::fs::remove_file(output_path); } + #[tokio::test] + async fn edit_scrollback_key_opens_focused_runtime_scrollback_in_editor_pane() { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut app = App::new( + &Config::default(), + true, + None, + None, + api_rx, + crate::api::EventHub::default(), + ); + let mut workspace = Workspace::test_new("test"); + let root_pane = workspace.tabs[0].root_pane; + workspace.tabs[0].runtimes.insert( + root_pane, + crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"), + ); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Navigate; + + let output_path = unique_temp_path("edit-scrollback"); + let previous_editor = std::env::var_os("EDITOR"); + std::env::set_var( + "EDITOR", + format!("sh -c 'cp \"$1\" {}' sh", output_path.display()), + ); + app.state.keybinds.edit_scrollback = Some((KeyCode::Char('g'), KeyModifiers::empty())); + app.state.keybinds.edit_scrollback_label = Some("g".into()); + + app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty())); + + match previous_editor { + Some(value) => std::env::set_var("EDITOR", value), + None => std::env::remove_var("EDITOR"), + } + + let content = wait_for_file(&output_path); + assert!(content.contains("alpha")); + assert!(content.contains("beta")); + assert_eq!(app.state.mode, Mode::Terminal); + + let _ = std::fs::remove_file(output_path); + } + #[test] fn fullscreen_action_exits_navigate_mode() { let mut state = state_with_workspaces(&["test"]); diff --git a/src/app/mod.rs b/src/app/mod.rs index a332503f..644329ab 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -48,12 +48,13 @@ use crate::events::AppEvent; pub use state::{AppState, Mode, ToastKind, ViewState}; /// Full application: AppState + runtime concerns (event channels, async I/O). -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct OverlayPaneState { ws_idx: usize, tab_idx: usize, previous_focus: crate::layout::PaneId, previous_zoomed: bool, + temp_files: Vec, } pub struct App { diff --git a/src/app/state.rs b/src/app/state.rs index d0d40c72..b51d500f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1113,6 +1113,8 @@ impl AppState { close_tab_label: None, rename_pane: None, rename_pane_label: None, + edit_scrollback: None, + edit_scrollback_label: None, focus_pane_left: None, focus_pane_left_label: None, focus_pane_down: None, diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index de4f03a9..9a1dc496 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -95,6 +95,8 @@ pub struct Keybinds { pub close_tab_label: Option, pub rename_pane: Option<(KeyCode, KeyModifiers)>, pub rename_pane_label: Option, + pub edit_scrollback: Option<(KeyCode, KeyModifiers)>, + pub edit_scrollback_label: Option, pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, pub focus_pane_left_label: Option, pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, @@ -462,6 +464,12 @@ impl Config { &self.keys.rename_pane, &mut diagnostics, ), + optional_binding( + navigate_scope(), + "keys.edit_scrollback", + &self.keys.edit_scrollback, + &mut diagnostics, + ), optional_binding( terminal_direct_scope(), "keys.focus_pane_left", @@ -767,14 +775,16 @@ impl Config { close_tab_label: optional_bindings[10].label.clone(), rename_pane: optional_bindings[11].value, rename_pane_label: optional_bindings[11].label.clone(), - focus_pane_left: optional_bindings[12].value, - focus_pane_left_label: optional_bindings[12].label.clone(), - focus_pane_down: optional_bindings[13].value, - focus_pane_down_label: optional_bindings[13].label.clone(), - focus_pane_up: optional_bindings[14].value, - focus_pane_up_label: optional_bindings[14].label.clone(), - focus_pane_right: optional_bindings[15].value, - focus_pane_right_label: optional_bindings[15].label.clone(), + edit_scrollback: optional_bindings[12].value, + edit_scrollback_label: optional_bindings[12].label.clone(), + focus_pane_left: optional_bindings[13].value, + focus_pane_left_label: optional_bindings[13].label.clone(), + focus_pane_down: optional_bindings[14].value, + focus_pane_down_label: optional_bindings[14].label.clone(), + focus_pane_up: optional_bindings[15].value, + focus_pane_up_label: optional_bindings[15].label.clone(), + focus_pane_right: optional_bindings[16].value, + focus_pane_right_label: optional_bindings[16].label.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, @@ -1082,6 +1092,7 @@ mod tests { assert_eq!(kb.split_vertical.0, KeyCode::Char('v')); assert_eq!(kb.split_horizontal.0, KeyCode::Char('-')); assert_eq!(kb.close_pane.0, KeyCode::Char('x')); + assert_eq!(kb.edit_scrollback, None); assert_eq!(kb.fullscreen.0, KeyCode::Char('f')); assert_eq!(kb.resize_mode.0, KeyCode::Char('r')); assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b')); @@ -1104,6 +1115,7 @@ resize_mode = "ctrl+r" toggle_sidebar = "tab" previous_agent = "alt+a" next_agent = "alt+d" +edit_scrollback = "e" focus_pane_left = "alt+h" focus_pane_right = "alt+right" "#; @@ -1139,6 +1151,10 @@ focus_pane_right = "alt+right" Some((KeyCode::Char('a'), KeyModifiers::ALT)) ); assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT))); + assert_eq!( + kb.edit_scrollback, + Some((KeyCode::Char('e'), KeyModifiers::empty())) + ); assert_eq!( kb.focus_pane_left, Some((KeyCode::Char('h'), KeyModifiers::ALT)) diff --git a/src/config/model.rs b/src/config/model.rs index ed8c4dd5..f389b18b 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -103,6 +103,8 @@ pub struct KeysConfig { pub close_tab: String, /// Rename the focused pane. Unset by default. pub rename_pane: String, + /// Open the focused pane scrollback in $EDITOR. Unset by default. + pub edit_scrollback: String, /// Focus the pane to the left in terminal mode. Unset by default. pub focus_pane_left: String, /// Focus the pane below in terminal mode. Unset by default. @@ -198,6 +200,7 @@ impl Default for KeysConfig { next_tab: "".into(), close_tab: "".into(), rename_pane: "".into(), + edit_scrollback: "".into(), focus_pane_left: "".into(), focus_pane_down: "".into(), focus_pane_up: "".into(), diff --git a/src/main.rs b/src/main.rs index 16c11406..7d268a4d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # next_tab = "" # optional, unset by default # close_tab = "" # optional, unset by default # rename_pane = "" # optional, unset by default +# edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR # focus_pane_left = "" # optional, unset by default # focus_pane_down = "" # optional, unset by default # focus_pane_up = "" # optional, unset by default diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index ccc32c5c..7b524be8 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -104,6 +104,10 @@ pub(super) fn keybind_help_groups( (kb.split_horizontal_label.clone(), "split horizontal"), (kb.close_pane_label.clone(), "close pane"), (optional_keybind_label(&kb.rename_pane_label), "rename pane"), + ( + optional_keybind_label(&kb.edit_scrollback_label), + "edit scrollback", + ), (kb.fullscreen_label.clone(), "fullscreen"), (kb.resize_mode_label.clone(), "resize mode"), (kb.toggle_sidebar_label.clone(), "toggle sidebar"),