aboutsummaryrefslogtreecommitdiff
path: root/src
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
parent305d9577043291af271d16bdd2208f748e5949ba (diff)
feat add custom shell and pane keybind commands
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs388
-rw-r--r--src/app/mod.rs41
-rw-r--r--src/app/state.rs1
-rw-r--r--src/config.rs531
-rw-r--r--src/pane.rs79
-rw-r--r--src/ui.rs10
-rw-r--r--src/workspace.rs83
7 files changed, 1028 insertions, 105 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(),
diff --git a/src/config.rs b/src/config.rs
index ce317d33..0e841b0d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -96,6 +96,36 @@ pub struct LiveKeybindConfig {
pub keybinds: Keybinds,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum CommandKeybindType {
+ #[default]
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct CommandKeybindConfig {
+ /// Navigate-mode key that runs a command after pressing the prefix key.
+ pub key: String,
+ /// Command executed either in the background shell or inside a pane.
+ pub command: String,
+ /// Command execution mode. Default: "shell".
+ #[serde(rename = "type")]
+ pub action_type: CommandKeybindType,
+}
+
+impl Default for CommandKeybindConfig {
+ fn default() -> Self {
+ Self {
+ key: String::new(),
+ command: String::new(),
+ action_type: CommandKeybindType::Shell,
+ }
+ }
+}
+
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct KeysConfig {
@@ -141,6 +171,8 @@ pub struct KeysConfig {
pub resize_mode: String,
/// Toggle sidebar collapse. Default: "b"
pub toggle_sidebar: String,
+ /// Prefix-mode custom command bindings.
+ pub command: Vec<CommandKeybindConfig>,
}
#[derive(Debug, Deserialize)]
@@ -313,6 +345,7 @@ impl Default for KeysConfig {
fullscreen: "f".into(),
resize_mode: "r".into(),
toggle_sidebar: "b".into(),
+ command: Vec::new(),
}
}
}
@@ -463,8 +496,15 @@ impl Config {
Vec<String>,
Keybinds,
) {
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ enum BindingScope {
+ Navigate,
+ TerminalDirect,
+ }
+
#[derive(Clone)]
- struct Binding<'a> {
+ struct RequiredBinding<'a> {
+ scope: BindingScope,
field: &'a str,
label: String,
default_label: &'a str,
@@ -472,23 +512,59 @@ impl Config {
default: (KeyCode, KeyModifiers),
}
- let mut diagnostics = Vec::new();
- let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
- &self.keys.prefix,
- "keys.prefix",
- (KeyCode::Char('b'), KeyModifiers::CONTROL),
- );
- if let Some(diag) = &prefix_diag {
- warn!(message = %diag, "config diagnostic");
+ struct OptionalBinding {
+ scope: BindingScope,
+ field: &'static str,
+ value: Option<(KeyCode, KeyModifiers)>,
+ label: Option<String>,
+ }
+
+ #[derive(Default)]
+ struct BindingRegistry {
+ seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
+ }
+
+ impl BindingRegistry {
+ fn register(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) -> Option<String> {
+ self.seen
+ .insert((scope, binding.0, binding.1), field.to_string())
+ }
+
+ fn conflict(
+ &self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ ) -> Option<&str> {
+ self.seen
+ .get(&(scope, binding.0, binding.1))
+ .map(String::as_str)
+ }
+
+ fn reserve_if_unbound(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) {
+ self.seen
+ .entry((scope, binding.0, binding.1))
+ .or_insert_with(|| field.to_string());
+ }
}
- fn binding<'a>(
+ fn required_binding<'a>(
+ scope: BindingScope,
field: &'a str,
configured_label: &'a str,
default_label: &'a str,
default: (KeyCode, KeyModifiers),
diagnostics: &mut Vec<String>,
- ) -> Binding<'a> {
+ ) -> RequiredBinding<'a> {
let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default);
let label = if let Some(diag) = diag {
diagnostics.push(diag);
@@ -496,7 +572,8 @@ impl Config {
} else {
configured_label.to_string()
};
- Binding {
+ RequiredBinding {
+ scope,
field,
label,
default_label,
@@ -506,91 +583,128 @@ impl Config {
}
fn optional_binding(
+ scope: BindingScope,
field: &'static str,
configured_label: &str,
diagnostics: &mut Vec<String>,
- ) -> (Option<(KeyCode, KeyModifiers)>, Option<String>) {
+ ) -> OptionalBinding {
if configured_label.trim().is_empty() {
- return (None, None);
+ return OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ };
}
- let (value, diag) = parse_key_combo_with_diagnostic(
- configured_label,
- field,
- (KeyCode::Null, KeyModifiers::empty()),
- );
- if let Some(diag) = diag {
- diagnostics.push(diag);
- (None, None)
- } else {
- (Some(value), Some(configured_label.to_string()))
+ match parse_key_combo(configured_label) {
+ Some(value) => OptionalBinding {
+ scope,
+ field,
+ value: Some(value),
+ label: Some(configured_label.to_string()),
+ },
+ None => {
+ let diag = format!(
+ "invalid keybinding: {field} = {:?}; disabling binding",
+ configured_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ }
+ }
}
}
+ let mut diagnostics = Vec::new();
+ let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
+ &self.keys.prefix,
+ "keys.prefix",
+ (KeyCode::Char('b'), KeyModifiers::CONTROL),
+ );
+ if let Some(diag) = &prefix_diag {
+ warn!(message = %diag, "config diagnostic");
+ }
+
let mut bindings = vec![
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.new_workspace",
&self.keys.new_workspace,
"n",
(KeyCode::Char('n'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.rename_workspace",
&self.keys.rename_workspace,
"shift+n",
(KeyCode::Char('n'), KeyModifiers::SHIFT),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.close_workspace",
&self.keys.close_workspace,
"d",
(KeyCode::Char('d'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.new_tab",
&self.keys.new_tab,
"c",
(KeyCode::Char('c'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.split_vertical",
&self.keys.split_vertical,
"v",
(KeyCode::Char('v'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.split_horizontal",
&self.keys.split_horizontal,
"-",
(KeyCode::Char('-'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.close_pane",
&self.keys.close_pane,
"x",
(KeyCode::Char('x'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.fullscreen",
&self.keys.fullscreen,
"f",
(KeyCode::Char('f'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.resize_mode",
&self.keys.resize_mode,
"r",
(KeyCode::Char('r'), KeyModifiers::empty()),
&mut diagnostics,
),
- binding(
+ required_binding(
+ BindingScope::Navigate,
"keys.toggle_sidebar",
&self.keys.toggle_sidebar,
"b",
@@ -599,51 +713,72 @@ impl Config {
),
];
- let optional_bindings = [
+ let mut optional_bindings = vec![
optional_binding(
+ BindingScope::Navigate,
"keys.previous_workspace",
&self.keys.previous_workspace,
&mut diagnostics,
),
optional_binding(
+ BindingScope::Navigate,
"keys.next_workspace",
&self.keys.next_workspace,
&mut diagnostics,
),
- optional_binding("keys.rename_tab", &self.keys.rename_tab, &mut diagnostics),
optional_binding(
+ BindingScope::Navigate,
+ "keys.rename_tab",
+ &self.keys.rename_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
"keys.previous_tab",
&self.keys.previous_tab,
&mut diagnostics,
),
- optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics),
- optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics),
optional_binding(
+ BindingScope::Navigate,
+ "keys.next_tab",
+ &self.keys.next_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.close_tab",
+ &self.keys.close_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
"keys.focus_pane_left",
&self.keys.focus_pane_left,
&mut diagnostics,
),
optional_binding(
+ BindingScope::TerminalDirect,
"keys.focus_pane_down",
&self.keys.focus_pane_down,
&mut diagnostics,
),
optional_binding(
+ BindingScope::TerminalDirect,
"keys.focus_pane_up",
&self.keys.focus_pane_up,
&mut diagnostics,
),
optional_binding(
+ BindingScope::TerminalDirect,
"keys.focus_pane_right",
&self.keys.focus_pane_right,
&mut diagnostics,
),
];
- use std::collections::HashMap;
- let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new();
+ let mut registry = BindingRegistry::default();
for binding in &mut bindings {
- if let Some(first_field) = seen.get(&binding.value) {
+ if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
let diag = format!(
"duplicate keybinding: {} conflicts with {}; using default {}",
binding.field, first_field, binding.default_label
@@ -652,9 +787,165 @@ impl Config {
diagnostics.push(diag);
binding.value = binding.default;
binding.label = binding.default_label.to_string();
- } else {
- seen.insert(binding.value, binding.field);
}
+ registry.register(binding.scope, binding.value, binding.field);
+ }
+
+ for binding in &mut optional_bindings {
+ let Some(value) = binding.value else {
+ continue;
+ };
+ if let Some(first_field) = registry.conflict(binding.scope, value) {
+ let diag = format!(
+ "duplicate keybinding: {} conflicts with {}; disabling binding",
+ binding.field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = None;
+ binding.label = None;
+ continue;
+ }
+ registry.register(binding.scope, value, binding.field);
+ }
+
+ registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
+ for (field, binding) in [
+ ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())),
+ (
+ "navigate.open_workspace",
+ (KeyCode::Enter, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.settings",
+ (KeyCode::Char('s'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.keybind_help",
+ (KeyCode::Char('?'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_up",
+ (KeyCode::Up, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_down",
+ (KeyCode::Down, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_left",
+ (KeyCode::Char('h'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_down",
+ (KeyCode::Char('j'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_up",
+ (KeyCode::Char('k'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_right",
+ (KeyCode::Char('l'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_left",
+ (KeyCode::Left, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_right",
+ (KeyCode::Right, KeyModifiers::empty()),
+ ),
+ ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())),
+ (
+ "navigate.tab_prev",
+ (KeyCode::BackTab, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_1",
+ (KeyCode::Char('1'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_2",
+ (KeyCode::Char('2'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_3",
+ (KeyCode::Char('3'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_4",
+ (KeyCode::Char('4'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_5",
+ (KeyCode::Char('5'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_6",
+ (KeyCode::Char('6'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_7",
+ (KeyCode::Char('7'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_8",
+ (KeyCode::Char('8'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_9",
+ (KeyCode::Char('9'), KeyModifiers::empty()),
+ ),
+ ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())),
+ ] {
+ registry.reserve_if_unbound(BindingScope::Navigate, binding, field);
+ }
+
+ let mut custom_commands = Vec::new();
+ for (index, command) in self.keys.command.iter().enumerate() {
+ let key_field = format!("keys.command[{index}].key");
+ let command_field = format!("keys.command[{index}].command");
+
+ if command.command.trim().is_empty() {
+ let diag =
+ format!("empty custom command: {command_field}; disabling custom command");
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ }
+
+ let Some(binding) = parse_key_combo(&command.key) else {
+ let diag = format!(
+ "invalid keybinding: {} = {:?}; disabling custom command",
+ key_field, command.key
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ };
+
+ if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) {
+ let diag = format!(
+ "duplicate custom keybinding: {} conflicts with {}; disabling custom command",
+ key_field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ }
+
+ registry.register(BindingScope::Navigate, binding, &key_field);
+ let action = match command.action_type {
+ CommandKeybindType::Shell => CustomCommandAction::Shell,
+ CommandKeybindType::Pane => CustomCommandAction::Pane,
+ };
+ custom_commands.push(CustomCommandKeybind {
+ key: binding,
+ label: format_key_combo(binding),
+ command: command.command.clone(),
+ action,
+ });
}
let keybinds = Keybinds {
@@ -664,28 +955,28 @@ impl Config {
rename_workspace_label: bindings[1].label.clone(),
close_workspace: bindings[2].value,
close_workspace_label: bindings[2].label.clone(),
- previous_workspace: optional_bindings[0].0,
- previous_workspace_label: optional_bindings[0].1.clone(),
- next_workspace: optional_bindings[1].0,
- next_workspace_label: optional_bindings[1].1.clone(),
+ previous_workspace: optional_bindings[0].value,
+ previous_workspace_label: optional_bindings[0].label.clone(),
+ next_workspace: optional_bindings[1].value,
+ next_workspace_label: optional_bindings[1].label.clone(),
new_tab: bindings[3].value,
new_tab_label: bindings[3].label.clone(),
- rename_tab: optional_bindings[2].0,
- rename_tab_label: optional_bindings[2].1.clone(),
- previous_tab: optional_bindings[3].0,
- previous_tab_label: optional_bindings[3].1.clone(),
- next_tab: optional_bindings[4].0,
- next_tab_label: optional_bindings[4].1.clone(),
- close_tab: optional_bindings[5].0,
- close_tab_label: optional_bindings[5].1.clone(),
- focus_pane_left: optional_bindings[6].0,
- focus_pane_left_label: optional_bindings[6].1.clone(),
- focus_pane_down: optional_bindings[7].0,
- focus_pane_down_label: optional_bindings[7].1.clone(),
- focus_pane_up: optional_bindings[8].0,
- focus_pane_up_label: optional_bindings[8].1.clone(),
- focus_pane_right: optional_bindings[9].0,
- focus_pane_right_label: optional_bindings[9].1.clone(),
+ rename_tab: optional_bindings[2].value,
+ rename_tab_label: optional_bindings[2].label.clone(),
+ previous_tab: optional_bindings[3].value,
+ previous_tab_label: optional_bindings[3].label.clone(),
+ next_tab: optional_bindings[4].value,
+ next_tab_label: optional_bindings[4].label.clone(),
+ close_tab: optional_bindings[5].value,
+ close_tab_label: optional_bindings[5].label.clone(),
+ focus_pane_left: optional_bindings[6].value,
+ focus_pane_left_label: optional_bindings[6].label.clone(),
+ focus_pane_down: optional_bindings[7].value,
+ focus_pane_down_label: optional_bindings[7].label.clone(),
+ focus_pane_up: optional_bindings[8].value,
+ focus_pane_up_label: optional_bindings[8].label.clone(),
+ focus_pane_right: optional_bindings[9].value,
+ focus_pane_right_label: optional_bindings[9].label.clone(),
split_vertical: bindings[4].value,
split_vertical_label: bindings[4].label.clone(),
split_horizontal: bindings[5].value,
@@ -698,12 +989,27 @@ impl Config {
resize_mode_label: bindings[8].label.clone(),
toggle_sidebar: bindings[9].value,
toggle_sidebar_label: bindings[9].label.clone(),
+ custom_commands,
};
(prefix_diag, prefix, diagnostics, keybinds)
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CustomCommandAction {
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone)]
+pub struct CustomCommandKeybind {
+ pub key: (KeyCode, KeyModifiers),
+ pub label: String,
+ pub command: String,
+ pub action: CustomCommandAction,
+}
+
/// Parsed keybinds for navigate mode actions.
#[derive(Debug, Clone)]
pub struct Keybinds {
@@ -747,6 +1053,7 @@ pub struct Keybinds {
pub resize_mode_label: String,
pub toggle_sidebar: (KeyCode, KeyModifiers),
pub toggle_sidebar_label: String,
+ pub custom_commands: Vec<CustomCommandKeybind>,
}
/// Parse a color string into a ratatui Color.
@@ -1171,6 +1478,7 @@ mod tests {
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'));
+ assert!(kb.custom_commands.is_empty());
}
#[test]
@@ -1326,6 +1634,103 @@ rename_workspace = "g"
}
#[test]
+ fn duplicate_optional_keybinding_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+rename_tab = "g"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding")));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.rename_tab, None);
+ }
+
+ #[test]
+ fn custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(
+ kb.custom_commands[0].key,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.custom_commands[0].label, "g");
+ assert_eq!(kb.custom_commands[0].command, "echo hi");
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell);
+ }
+
+ #[test]
+ fn pane_custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+type = "pane"
+command = "lazygit"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane);
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("keys.new_workspace")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() {
+ let toml = r#"
+[[keys.command]]
+key = "q"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("navigate.quit")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
fn live_keybinds_reject_invalid_keybinding() {
let config: Config = toml::from_str(
r#"
diff --git a/src/pane.rs b/src/pane.rs
index 7424638e..74fec4e4 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1823,11 +1823,78 @@ impl PaneRuntime {
cols: u16,
cwd: std::path::PathBuf,
scrollback_limit_bytes: usize,
- _host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
+ let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
+ let mut cmd = CommandBuilder::new(&shell);
+ cmd.cwd(cwd);
+ cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+ crate::integration::apply_pane_env(&mut cmd, pane_id);
+ Self::spawn_command_builder(
+ pane_id,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ cmd,
+ "failed to spawn shell",
+ )
+ }
+
+ pub fn spawn_shell_command(
+ pane_id: PaneId,
+ rows: u16,
+ cols: u16,
+ cwd: std::path::PathBuf,
+ command: &str,
+ extra_env: &[(String, String)],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> std::io::Result<Self> {
+ let mut cmd = CommandBuilder::new("/bin/sh");
+ cmd.arg("-c");
+ cmd.arg(command);
+ cmd.cwd(cwd);
+ cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+ crate::integration::apply_pane_env(&mut cmd, pane_id);
+ for (key, value) in extra_env {
+ cmd.env(key, value);
+ }
+ Self::spawn_command_builder(
+ pane_id,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events,
+ render_notify,
+ render_dirty,
+ cmd,
+ "failed to spawn command pane",
+ )
+ }
+
+ fn spawn_command_builder(
+ pane_id: PaneId,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ cmd: CommandBuilder,
+ spawn_error_message: &'static str,
+ ) -> std::io::Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
@@ -1849,18 +1916,12 @@ impl PaneRuntime {
let terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
.map_err(|e| std::io::Error::other(e.to_string()))?;
let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
- pane_terminal.apply_host_terminal_theme(_host_terminal_theme);
+ pane_terminal.apply_host_terminal_theme(host_terminal_theme);
let terminal = Arc::new(PaneTerminal {
ghostty: pane_terminal,
});
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
- let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
- let mut cmd = CommandBuilder::new(&shell);
- cmd.cwd(cwd);
- cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
- crate::integration::apply_pane_env(&mut cmd, pane_id);
-
let reader = pair
.master
.try_clone_reader()
@@ -1889,7 +1950,7 @@ impl PaneRuntime {
Err(e) => error!(pane = pane_id.raw(), err = %e, "child wait failed"),
}
}
- Err(e) => error!(pane = pane_id.raw(), err = %e, "failed to spawn shell"),
+ Err(e) => error!(pane = pane_id.raw(), err = %e, "{spawn_error_message}"),
}
// Use blocking send — PaneDied is critical, must not be dropped
if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) {
diff --git a/src/ui.rs b/src/ui.rs
index 8ea95a40..b78e366b 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -2541,6 +2541,16 @@ fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'stat
];
groups.push(("panes", panes));
+ if !kb.custom_commands.is_empty() {
+ groups.push((
+ "custom",
+ kb.custom_commands
+ .iter()
+ .map(|binding| (binding.label.clone(), "custom command"))
+ .collect(),
+ ));
+ }
+
groups
}
diff --git a/src/workspace.rs b/src/workspace.rs
index 09fa382d..23d9a8e1 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -122,20 +122,79 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
) -> std::io::Result<PaneId> {
- let new_id = self.layout.split_focused(direction);
- let actual_cwd =
- cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let runtime = PaneRuntime::spawn(
- new_id,
+ self.split_focused_with_runtime(
+ direction,
rows,
cols,
- actual_cwd.clone(),
+ cwd,
scrollback_limit_bytes,
host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?;
+ None,
+ )
+ }
+
+ pub fn split_focused_command(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ command: &str,
+ extra_env: &[(String, String)],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<PaneId> {
+ self.split_focused_with_runtime(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ Some((command, extra_env)),
+ )
+ }
+
+ fn split_focused_with_runtime(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ command: Option<(&str, &[(String, String)])>,
+ ) -> std::io::Result<PaneId> {
+ let new_id = self.layout.split_focused(direction);
+ let actual_cwd =
+ cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ let runtime = if let Some((command, extra_env)) = command {
+ PaneRuntime::spawn_shell_command(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ command,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?
+ } else {
+ PaneRuntime::spawn(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?
+ };
self.panes.insert(new_id, PaneState::new());
self.pane_cwds.insert(new_id, actual_cwd);
self.runtimes.insert(new_id, runtime);
@@ -325,6 +384,10 @@ impl Workspace {
self.tabs.get(self.active_tab)
}
+ pub fn active_tab_index(&self) -> usize {
+ self.active_tab
+ }
+
pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
self.tabs.get_mut(self.active_tab)
}