parent
170d567487
commit
7b8f699083
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Reference in New Issue