aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-13 18:49:37 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-13 18:49:37 +0300
commit1535a7117a47019f78d306ed4bba2300717357c1 (patch)
treec7c73bbfb4ead927078a1e91885cd3929293c348 /src/app
parent305d9577043291af271d16bdd2208f748e5949ba (diff)
feat add custom shell and pane keybind commands
Diffstat (limited to 'src/app')
-rw-r--r--src/app/input.rs388
-rw-r--r--src/app/mod.rs41
-rw-r--r--src/app/state.rs1
3 files changed, 407 insertions, 23 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 7cecfc8a..67f84108 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -1,5 +1,7 @@
//! Input handling — translates crossterm key/mouse events into state mutations.
+use std::process::{Command, Stdio};
+
use bytes::Bytes;
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
@@ -103,7 +105,7 @@ impl App {
match self.state.mode {
Mode::Onboarding => self.handle_onboarding_key(key),
Mode::ReleaseNotes => self.handle_release_notes_key(key),
- Mode::Navigate => handle_navigate_key(&mut self.state, key),
+ Mode::Navigate => self.handle_navigate_key(key),
Mode::RenameWorkspace | Mode::RenameTab => {
handle_rename_key(&mut self.state, key)
}
@@ -195,6 +197,164 @@ impl App {
}
}
+ fn handle_navigate_key(&mut self, key: KeyEvent) {
+ self.state.update_dismissed = true;
+
+ if self.state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(&mut self.state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(&self.state, &key) {
+ execute_navigate_action(&mut self.state, action);
+ return;
+ }
+
+ if handle_navigate_reserved_key(&mut self.state, key) {
+ return;
+ }
+
+ if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
+ self.launch_custom_command(binding);
+ }
+ }
+
+ fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ 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),
+ };
+ match result {
+ Ok(()) => leave_navigate_mode(&mut self.state),
+ Err(err) => {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "custom command failed".to_string(),
+ context: err.to_string(),
+ });
+ self.sync_toast_deadline(previous_toast);
+ }
+ }
+ }
+
+ fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
+ let mut env = vec![(
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ )];
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+
+ let mut cwd = None;
+ if let Some(ws_idx) = self.state.active {
+ env.push((
+ "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
+ self.public_workspace_id(ws_idx),
+ ));
+ if let Some(workspace) = self.state.workspaces.get(ws_idx) {
+ let tab_idx = workspace.active_tab_index();
+ if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
+ env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
+ }
+ if let Some(pane_id) = workspace.focused_pane_id() {
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
+ env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
+ }
+ if let Some(pane_cwd) = workspace
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(pane_id))
+ {
+ env.push((
+ "HERDR_ACTIVE_PANE_CWD".to_string(),
+ pane_cwd.display().to_string(),
+ ));
+ if pane_cwd.is_dir() {
+ cwd = Some(pane_cwd);
+ }
+ }
+ }
+ }
+ }
+ (env, cwd)
+ }
+
+ fn spawn_custom_command(
+ &self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> std::io::Result<()> {
+ let mut command = Command::new("/bin/sh");
+ command
+ .arg("-lc")
+ .arg(&binding.command)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ let (env, cwd) = self.custom_command_env();
+ command.envs(env);
+ if let Some(cwd) = cwd {
+ command.current_dir(cwd);
+ }
+ command.spawn()?;
+ Ok(())
+ }
+
+ fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+ let (env, _) = self.custom_command_env();
+
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let tab_idx = ws.active_tab_index();
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let cwd = ws
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(previous_focus));
+ let new_pane_id = ws.split_focused_command(
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ command,
+ &env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ )?;
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .layout
+ .focus_pane(new_pane_id);
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane_id,
+ super::OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ },
+ );
+ self.state.mode = Mode::Terminal;
+ Ok(())
+ }
+
pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
if self.state.mode == Mode::ReleaseNotes {
match mouse.kind {
@@ -688,26 +848,30 @@ fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
}
}
-fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
- state.update_dismissed = true;
-
- if state.is_prefix(&key) || key.code == KeyCode::Esc {
- leave_navigate_mode(state);
- return;
- }
-
- if let Some(action) = navigate_action_for_key(state, &key) {
- execute_navigate_action(state, action);
- return;
- }
+fn navigate_custom_command_for_key(
+ state: &AppState,
+ key: &KeyEvent,
+) -> Option<crate::config::CustomCommandKeybind> {
+ state
+ .keybinds
+ .custom_commands
+ .iter()
+ .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .cloned()
+}
+fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
match key.code {
- KeyCode::Char('q') => state.should_quit = true,
+ KeyCode::Char('q') => {
+ state.should_quit = true;
+ true
+ }
KeyCode::Enter => {
if !state.workspaces.is_empty() {
state.switch_workspace(state.selected);
leave_navigate_mode(state);
}
+ true
}
KeyCode::Char(c @ '1'..='9') => {
let idx = (c as usize) - ('1' as usize);
@@ -715,31 +879,73 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.switch_workspace(idx);
leave_navigate_mode(state);
}
+ true
}
KeyCode::Char('s') => {
open_settings(state);
+ true
+ }
+ KeyCode::Char('?') => {
+ open_keybind_help(state);
+ true
}
- KeyCode::Char('?') => open_keybind_help(state),
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
state.ensure_workspace_visible(state.selected);
}
+ true
}
KeyCode::Down => {
if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
state.selected += 1;
state.ensure_workspace_visible(state.selected);
}
+ true
}
- KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left),
- KeyCode::Char('j') => state.navigate_pane(NavDirection::Down),
- KeyCode::Char('k') => state.navigate_pane(NavDirection::Up),
- KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right),
- KeyCode::Tab => state.cycle_pane(false),
- KeyCode::BackTab => state.cycle_pane(true),
- _ => {}
+ KeyCode::Char('h') | KeyCode::Left => {
+ state.navigate_pane(NavDirection::Left);
+ true
+ }
+ KeyCode::Char('j') => {
+ state.navigate_pane(NavDirection::Down);
+ true
+ }
+ KeyCode::Char('k') => {
+ state.navigate_pane(NavDirection::Up);
+ true
+ }
+ KeyCode::Char('l') | KeyCode::Right => {
+ state.navigate_pane(NavDirection::Right);
+ true
+ }
+ KeyCode::Tab => {
+ state.cycle_pane(false);
+ true
+ }
+ KeyCode::BackTab => {
+ state.cycle_pane(true);
+ true
+ }
+ _ => false,
+ }
+}
+
+#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
+fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
+ state.update_dismissed = true;
+
+ if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(state, &key) {
+ execute_navigate_action(state, action);
+ return;
}
+
+ let _ = handle_navigate_reserved_key(state, key);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -3252,6 +3458,12 @@ impl AppState {
#[cfg(test)]
mod tests {
use super::*;
+ use std::{
+ fs,
+ path::Path,
+ time::{Duration, SystemTime, UNIX_EPOCH},
+ };
+
use crate::{config::Config, detect::Agent, workspace::Workspace};
use crossterm::event::{KeyModifiers, MouseEvent};
use ratatui::layout::Rect;
@@ -3312,6 +3524,25 @@ mod tests {
)
}
+ fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+ }
+
+ fn wait_for_file(path: &Path) -> String {
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if let Ok(content) = fs::read_to_string(path) {
+ return content;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+ panic!("timed out waiting for {}", path.display());
+ }
+
fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
match &snapshot.workspaces.first()?.tabs.first()?.layout {
crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
@@ -3439,6 +3670,119 @@ mod tests {
assert_eq!(app.state.mode, Mode::Terminal);
}
+ #[tokio::test]
+ async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
+ 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(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-command-keybind");
+ let command = format!(
+ "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
+ output_path.display()
+ );
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Shell,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ let content = wait_for_file(&output_path);
+ let lines: Vec<&str> = content.lines().collect();
+ assert_eq!(lines.len(), 3);
+ assert_eq!(lines[0], app.state.workspaces[0].id);
+ assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id));
+ assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn pane_overlay_command_opens_and_closes_after_exit() {
+ 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 workspace = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-pane-command");
+ let command = format!("printf done > '{}'", output_path.display());
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Pane,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert!(app.state.workspaces[0].tabs[0].zoomed);
+
+ let _ = wait_for_file(&output_path);
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if app.drain_internal_events()
+ && app.state.workspaces[0].tabs[0].layout.pane_count() == 1
+ {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+ assert!(!app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let _ = 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 985d2322..3b3cdbc3 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -8,7 +8,7 @@ mod actions;
mod input;
pub mod state;
-use std::collections::HashSet;
+use std::collections::{HashMap, HashSet};
use std::future::pending;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -36,6 +36,14 @@ use crate::workspace::Workspace;
pub use state::{AppState, Mode, ToastKind, ViewState};
/// Full application: AppState + runtime concerns (event channels, async I/O).
+#[derive(Debug, Clone, Copy)]
+struct OverlayPaneState {
+ ws_idx: usize,
+ tab_idx: usize,
+ previous_focus: crate::layout::PaneId,
+ previous_zoomed: bool,
+}
+
pub struct App {
pub state: AppState,
pub event_tx: mpsc::Sender<AppEvent>,
@@ -58,6 +66,7 @@ pub struct App {
suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
+ overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
}
enum LoopEvent {
@@ -355,6 +364,7 @@ impl App {
last_terminal_size: terminal::size().ok(),
render_notify,
render_dirty,
+ overlay_panes: HashMap::new(),
}
}
@@ -789,6 +799,12 @@ impl App {
return;
}
+ let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev {
+ self.overlay_panes.remove(pane_id)
+ } else {
+ None
+ };
+
if let AppEvent::PaneDied { pane_id } = &ev {
if let Some((ws_idx, _)) = self.find_pane(*pane_id) {
if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) {
@@ -823,9 +839,32 @@ impl App {
}
}
}
+ if let Some(overlay) = overlay_state {
+ self.restore_overlay_after_exit(overlay);
+ }
self.sync_toast_deadline(previous_toast);
}
+ fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
+ let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
+ return;
+ };
+ if overlay.tab_idx >= ws.tabs.len() {
+ return;
+ }
+
+ ws.active_tab = overlay.tab_idx;
+ let tab = &mut ws.tabs[overlay.tab_idx];
+ if tab.panes.contains_key(&overlay.previous_focus) {
+ tab.layout.focus_pane(overlay.previous_focus);
+ }
+ tab.zoomed = overlay.previous_zoomed;
+
+ if self.state.active == Some(overlay.ws_idx) {
+ self.state.mode = Mode::Terminal;
+ }
+ }
+
fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) {
let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else {
return;
diff --git a/src/app/state.rs b/src/app/state.rs
index c4b79cc3..00968c15 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -798,6 +798,7 @@ impl AppState {
resize_mode_label: "r".into(),
toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()),
toggle_sidebar_label: "b".into(),
+ custom_commands: Vec::new(),
},
spinner_tick: 0,
palette: Palette::catppuccin(),