feat: add scrollback editor keybind

refs #122
This commit is contained in:
Ogulcan Celik 2026-05-16 18:33:23 +03:00
parent 170d567487
commit 7b8f699083
10 changed files with 236 additions and 13 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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;
};

View File

@ -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::path::PathBuf>,
) -> 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<NavigateA
{
return Some(NavigateAction::RenamePane);
}
if kb
.edit_scrollback
.is_some_and(|(code, mods)| key_matches(key, code, mods))
{
return Some(NavigateAction::EditScrollback);
}
if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
return Some(NavigateAction::SplitVertical);
}
@ -642,6 +721,7 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.close_pane();
leave_navigate_mode(state);
}
NavigateAction::EditScrollback => {}
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<std::path::PathBuf> {
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"]);

View File

@ -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<std::path::PathBuf>,
}
pub struct App {

View File

@ -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,

View File

@ -95,6 +95,8 @@ pub struct Keybinds {
pub close_tab_label: Option<String>,
pub rename_pane: Option<(KeyCode, KeyModifiers)>,
pub rename_pane_label: Option<String>,
pub edit_scrollback: Option<(KeyCode, KeyModifiers)>,
pub edit_scrollback_label: Option<String>,
pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
pub focus_pane_left_label: Option<String>,
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))

View File

@ -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(),

View File

@ -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

View File

@ -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"),