aboutsummaryrefslogtreecommitdiff
path: root/src/app/custom_commands.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/custom_commands.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/custom_commands.rs')
-rw-r--r--src/app/custom_commands.rs782
1 files changed, 782 insertions, 0 deletions
diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs
new file mode 100644
index 00000000..a2245884
--- /dev/null
+++ b/src/app/custom_commands.rs
@@ -0,0 +1,782 @@
+use std::fs;
+use std::io::{self, Write};
+use std::process::Stdio;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use ratatui::layout::Direction;
+
+use super::{App, Mode};
+
+static NEXT_COMMAND_NAMESPACE: AtomicU64 = AtomicU64::new(1);
+
+pub(super) fn new_command_namespace() -> String {
+ let counter = NEXT_COMMAND_NAMESPACE.fetch_add(1, Ordering::Relaxed);
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ format!("{}-{nanos}-{counter}", std::process::id())
+}
+
+#[derive(Debug)]
+pub(super) struct EndpointCommandRegistry {
+ entries: Vec<EndpointCommand>,
+}
+
+#[derive(Debug)]
+struct EndpointCommand {
+ id: String,
+ binding: crate::config::CustomCommandKeybind,
+ action: crate::protocol::ClientShellCommandAction,
+}
+
+impl EndpointCommandRegistry {
+ pub(super) fn new(bindings: &[crate::config::CustomCommandKeybind]) -> Self {
+ let namespace = new_command_namespace();
+ let entries = bindings
+ .iter()
+ .enumerate()
+ .map(|(index, binding)| EndpointCommand {
+ action: binding.action.into(),
+ id: format!("cmd_{namespace}_{index}"),
+ binding: binding.clone(),
+ })
+ .collect();
+ Self { entries }
+ }
+}
+
+impl App {
+ pub(crate) fn client_shell_keybindings_profile(&self) -> Option<&str> {
+ self.client_shell_keybindings_profile.as_deref()
+ }
+
+ pub(crate) fn client_shell_command_manifest(&self) -> Vec<crate::protocol::ClientShellCommand> {
+ self.endpoint_commands
+ .entries
+ .iter()
+ .map(|entry| crate::protocol::ClientShellCommand {
+ command_id: entry.id.clone(),
+ binding_label: entry.binding.label.clone(),
+ binding_labels: entry.binding.bindings.labels(),
+ action: entry.action,
+ description: entry.binding.description.clone(),
+ })
+ .collect()
+ }
+
+ pub(crate) fn resolve_client_shell_command(
+ &self,
+ id: &str,
+ ) -> Option<crate::config::CustomCommandKeybind> {
+ self.endpoint_commands
+ .entries
+ .iter()
+ .find(|entry| entry.id == id)
+ .map(|entry| entry.binding.clone())
+ }
+
+ pub(crate) fn handle_command_invoke(
+ &mut self,
+ id: String,
+ params: crate::api::schema::CommandInvokeParams,
+ ) -> String {
+ let Some(binding) = self.resolve_client_shell_command(&params.command_id) else {
+ return crate::app::api::responses::encode_error(
+ id,
+ "command_not_found",
+ "custom command manifest is stale; reload configuration",
+ );
+ };
+ if let Err((code, message)) = self.focus_client_shell_command_target(&params) {
+ return crate::app::api::responses::encode_error(id, code, message);
+ }
+ let selected_text = if binding.action == crate::config::CustomCommandAction::PluginAction {
+ let Some(selection) = params.selection.as_ref() else {
+ return self.execute_custom_command_response(id, &binding, None);
+ };
+ if params.pane_id.as_deref() != Some(selection.pane_id.as_str()) {
+ return crate::app::api::responses::encode_error(
+ id,
+ "command_target_mismatch",
+ "command selection does not belong to the requested pane",
+ );
+ }
+ match self.pane_selection_text(selection) {
+ Ok(text) => Some(text),
+ Err((code, message)) => {
+ return crate::app::api::responses::encode_error(id, code, message);
+ }
+ }
+ } else {
+ None
+ };
+ self.execute_custom_command_response(id, &binding, selected_text)
+ }
+
+ fn execute_custom_command_response(
+ &mut self,
+ id: String,
+ binding: &crate::config::CustomCommandKeybind,
+ selected_text: Option<String>,
+ ) -> String {
+ match self.execute_custom_command_binding(binding, selected_text) {
+ Ok(()) => crate::app::api::responses::encode_success(
+ id,
+ crate::api::schema::ResponseResult::Ok {},
+ ),
+ Err(error) => {
+ crate::app::api::responses::encode_error(id, "command_failed", error.to_string())
+ }
+ }
+ }
+
+ fn focus_client_shell_command_target(
+ &mut self,
+ params: &crate::api::schema::CommandInvokeParams,
+ ) -> Result<(), (&'static str, String)> {
+ if let Some(pane_id) = params.pane_id.as_deref() {
+ let Some((workspace_index, pane)) = self.parse_pane_id(pane_id) else {
+ return Err(("pane_not_found", format!("pane not found: {pane_id}")));
+ };
+ let Some(tab_index) =
+ self.state.workspaces[workspace_index].find_tab_index_for_pane(pane)
+ else {
+ return Err(("pane_not_found", format!("pane not found: {pane_id}")));
+ };
+ self.validate_client_shell_command_parent_ids(params, workspace_index, tab_index)?;
+ self.state.focus_pane_in_workspace(workspace_index, pane);
+ return Ok(());
+ }
+
+ if let Some(tab_id) = params.tab_id.as_deref() {
+ let Some((workspace_index, tab_index)) = self.parse_tab_id(tab_id) else {
+ return Err(("tab_not_found", format!("tab not found: {tab_id}")));
+ };
+ self.validate_client_shell_command_workspace_id(params, workspace_index)?;
+ self.state.switch_workspace_tab(workspace_index, tab_index);
+ return Ok(());
+ }
+
+ if let Some(workspace_id) = params.workspace_id.as_deref() {
+ let Some(workspace_index) = self.parse_workspace_id(workspace_id) else {
+ return Err((
+ "workspace_not_found",
+ format!("workspace not found: {workspace_id}"),
+ ));
+ };
+ self.state.switch_workspace(workspace_index);
+ }
+ Ok(())
+ }
+
+ fn validate_client_shell_command_parent_ids(
+ &self,
+ params: &crate::api::schema::CommandInvokeParams,
+ workspace_index: usize,
+ tab_index: usize,
+ ) -> Result<(), (&'static str, String)> {
+ self.validate_client_shell_command_workspace_id(params, workspace_index)?;
+ if let Some(tab_id) = params.tab_id.as_deref() {
+ let actual = self
+ .public_tab_id(workspace_index, tab_index)
+ .unwrap_or_default();
+ if tab_id != actual {
+ return Err((
+ "command_target_mismatch",
+ "command pane does not belong to the requested tab".to_owned(),
+ ));
+ }
+ }
+ Ok(())
+ }
+
+ fn validate_client_shell_command_workspace_id(
+ &self,
+ params: &crate::api::schema::CommandInvokeParams,
+ workspace_index: usize,
+ ) -> Result<(), (&'static str, String)> {
+ if params
+ .workspace_id
+ .as_deref()
+ .is_some_and(|workspace_id| workspace_id != self.public_workspace_id(workspace_index))
+ {
+ return Err((
+ "command_target_mismatch",
+ "command target does not belong to the requested workspace".to_owned(),
+ ));
+ }
+ Ok(())
+ }
+ pub(crate) fn execute_custom_command_binding(
+ &mut self,
+ binding: &crate::config::CustomCommandKeybind,
+ selected_text: Option<String>,
+ ) -> io::Result<()> {
+ match binding.action {
+ crate::config::CustomCommandAction::Shell => self.spawn_custom_command(binding),
+ crate::config::CustomCommandAction::Pane => {
+ self.spawn_pane_command(&binding.command, Vec::new())
+ }
+ crate::config::CustomCommandAction::Popup => self.spawn_custom_popup_command(binding),
+ crate::config::CustomCommandAction::PluginAction => self
+ .invoke_plugin_action_from_keybind(binding.command.clone(), selected_text)
+ .map_err(io::Error::other),
+ }
+ }
+
+ fn spawn_custom_popup_command(
+ &mut self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> io::Result<()> {
+ self.spawn_popup_shell_command(
+ &binding.command,
+ None,
+ self.custom_command_env().0,
+ crate::app::popup::PopupGeometry {
+ width: binding.width,
+ height: binding.height,
+ },
+ )
+ }
+
+ pub(crate) 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, &self.state.terminals, &self.terminal_runtimes)
+ }) {
+ 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(
+ &mut self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> std::io::Result<()> {
+ let mut command = crate::platform::detached_custom_command_process(&binding.command);
+ 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);
+ }
+ let child = command.spawn()?;
+ self.detached_process_children.push(child);
+ Ok(())
+ }
+
+ pub(crate) 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 = self
+ .state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
+ .ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))?
+ .recent_unwrapped_text_snapshot(usize::MAX)
+ .text;
+
+ let path = write_scrollback_temp_file(&scrollback)?;
+
+ let argv = match crate::platform::scrollback_editor_argv(&path) {
+ Ok(argv) => argv,
+ Err(err) => {
+ let _ = fs::remove_file(&path);
+ return Err(err);
+ }
+ };
+ let (env, _) = self.custom_command_env();
+ let new_pane = match self.spawn_overlay_argv_command(&argv, None, env, vec![path.clone()]) {
+ Ok((_, new_pane)) => new_pane,
+ Err(err) => {
+ let _ = fs::remove_file(&path);
+ return Err(err);
+ }
+ };
+ let terminal_id = new_pane.terminal.id.clone();
+ self.terminal_runtimes
+ .insert(terminal_id.clone(), new_pane.runtime);
+ self.state
+ .remove_alias_shadowed_by_new_pane(new_pane.pane_id);
+ self.state.terminals.insert(terminal_id, new_pane.terminal);
+
+ 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}"),
+ position: None,
+ 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"));
+ };
+ let previous_focus_target = self.state.current_pane_focus_target();
+ 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,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ });
+ let new_pane = ws.split_focused_command(
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ command,
+ env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ self.state.host_terminal_appearance,
+ )?;
+ let new_pane_id = new_pane.pane_id;
+ self.terminal_runtimes
+ .insert(new_pane.terminal.id.clone(), new_pane.runtime);
+ self.state
+ .terminals
+ .insert(new_pane.terminal.id.clone(), new_pane.terminal);
+ let new_focus_target = crate::app::state::PaneFocusTarget {
+ workspace_id: ws.id.clone(),
+ pane_id: new_pane_id,
+ };
+ if previous_focus_target.as_ref() != Some(&new_focus_target) {
+ self.state.previous_pane_focus = previous_focus_target;
+ }
+ 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,
+ temp_files,
+ },
+ );
+ self.state.remove_alias_shadowed_by_new_pane(new_pane_id);
+ self.state.mode = Mode::Terminal;
+ Ok(())
+ }
+
+ pub(crate) fn spawn_overlay_argv_command(
+ &mut self,
+ argv: &[String],
+ cwd: Option<std::path::PathBuf>,
+ extra_env: Vec<(String, String)>,
+ temp_files: Vec<std::path::PathBuf>,
+ ) -> std::io::Result<(usize, crate::workspace::NewPane)> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let previous_focus_target = self.state.current_pane_focus_target();
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+
+ let ws = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let cwd = cwd.or_else(|| {
+ ws.active_tab().and_then(|tab| {
+ tab.cwd_for_pane(
+ previous_focus,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ })
+ });
+
+ let (tab_idx, new_pane, workspace_id) = {
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let result = ws.split_pane_argv_command(
+ previous_focus,
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ argv,
+ extra_env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ self.state.host_terminal_appearance,
+ true,
+ );
+ let (tab_idx, new_pane) = match result {
+ Some(Ok(result)) => result,
+ Some(Err(err)) => return Err(err),
+ None => return Err(std::io::Error::other("focused pane disappeared")),
+ };
+ ws.tabs
+ .get_mut(tab_idx)
+ .ok_or_else(|| std::io::Error::other("plugin overlay tab disappeared"))?
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane.pane_id,
+ super::OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ temp_files,
+ },
+ );
+ (tab_idx, new_pane, ws.id.clone())
+ };
+
+ let new_focus_target = crate::app::state::PaneFocusTarget {
+ workspace_id,
+ pane_id: new_pane.pane_id,
+ };
+ if previous_focus_target.as_ref() != Some(&new_focus_target) {
+ self.state.previous_pane_focus = previous_focus_target;
+ }
+ self.state.switch_workspace_tab(ws_idx, tab_idx);
+ self.state.mode = Mode::Terminal;
+ Ok((ws_idx, new_pane))
+ }
+}
+
+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()
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ fn test_app() -> crate::app::App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ crate::app::App::new(
+ &crate::config::Config::default(),
+ crate::app::AppPolicy::TEST,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ )
+ }
+
+ fn binding(action: crate::config::CustomCommandAction) -> crate::config::CustomCommandKeybind {
+ crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::prefix("z"),
+ label: "prefix+z".into(),
+ command: "secret-command --token hidden".into(),
+ action,
+ description: Some("safe description".into()),
+ width: None,
+ height: None,
+ }
+ }
+
+ fn install(app: &mut crate::app::App, binding: crate::config::CustomCommandKeybind) {
+ app.endpoint_commands = super::EndpointCommandRegistry::new(&[binding]);
+ }
+
+ #[test]
+ fn manifest_exposes_opaque_ids_without_command_text() {
+ let mut app = test_app();
+ install(&mut app, binding(crate::config::CustomCommandAction::Shell));
+ let manifest = app.client_shell_command_manifest();
+ assert_eq!(manifest.len(), 1);
+ assert_eq!(manifest[0].binding_label, "prefix+z");
+ assert_eq!(manifest[0].binding_labels, ["prefix+z"]);
+ assert_eq!(manifest[0].description.as_deref(), Some("safe description"));
+ assert!(!format!("{:?}", manifest).contains("secret-command"));
+ assert_eq!(
+ app.resolve_client_shell_command(&manifest[0].command_id)
+ .map(|binding| binding.command),
+ Some("secret-command --token hidden".into())
+ );
+ }
+
+ #[test]
+ fn stale_command_id_is_rejected_after_definition_changes() {
+ let mut app = test_app();
+ install(&mut app, binding(crate::config::CustomCommandAction::Shell));
+ let old_id = app.client_shell_command_manifest()[0].command_id.clone();
+ let mut replacement = binding(crate::config::CustomCommandAction::Shell);
+ replacement.command = "replacement-command".into();
+ install(&mut app, replacement);
+
+ let response = app.handle_command_invoke(
+ "request-1".into(),
+ crate::api::schema::CommandInvokeParams {
+ command_id: old_id,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ selection: None,
+ },
+ );
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "command_not_found");
+ }
+
+ #[test]
+ fn foreground_keybinding_projection_cannot_remap_command_ids() {
+ let mut app = test_app();
+ install(&mut app, binding(crate::config::CustomCommandAction::Shell));
+ let command_id = app.client_shell_command_manifest()[0].command_id.clone();
+ let mut foreground_binding = binding(crate::config::CustomCommandAction::Shell);
+ foreground_binding.command = "different-foreground-command".into();
+ app.state.keybinds.custom_commands = vec![foreground_binding];
+
+ assert_eq!(
+ app.resolve_client_shell_command(&command_id)
+ .map(|binding| binding.command),
+ Some("secret-command --token hidden".into())
+ );
+ }
+
+ #[test]
+ fn command_ids_do_not_alias_across_endpoint_restarts() {
+ let mut old_app = test_app();
+ install(
+ &mut old_app,
+ binding(crate::config::CustomCommandAction::Shell),
+ );
+ let old_id = old_app.client_shell_command_manifest()[0]
+ .command_id
+ .clone();
+
+ let mut replacement_app = test_app();
+ let mut replacement = binding(crate::config::CustomCommandAction::Shell);
+ replacement.command = "replacement-command".into();
+ install(&mut replacement_app, replacement);
+ assert_ne!(
+ replacement_app.client_shell_command_manifest()[0].command_id,
+ old_id
+ );
+ let response = replacement_app.handle_command_invoke(
+ "request-1".into(),
+ crate::api::schema::CommandInvokeParams {
+ command_id: old_id,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ selection: None,
+ },
+ );
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "command_not_found");
+ }
+
+ #[tokio::test]
+ async fn plugin_command_rejects_stale_client_selection_before_invocation() {
+ let mut app = test_app();
+ let workspace = crate::workspace::Workspace::test_new("plugin-selection");
+ let pane_id = workspace.tabs[0].root_pane;
+ let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap();
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.terminal_runtimes.insert(
+ terminal_id,
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"selected text\n"),
+ );
+ let mut plugin = binding(crate::config::CustomCommandAction::PluginAction);
+ plugin.command = "missing.plugin-action".into();
+ install(&mut app, plugin);
+ let command_id = app.client_shell_command_manifest()[0].command_id.clone();
+ let workspace_id = app.public_workspace_id(0);
+ let tab_id = app.public_tab_id(0, 0).unwrap();
+ let pane_id = app.public_pane_id(0, pane_id).unwrap();
+
+ let response = app.handle_command_invoke(
+ "request-selection".into(),
+ crate::api::schema::CommandInvokeParams {
+ command_id,
+ workspace_id: Some(workspace_id),
+ tab_id: Some(tab_id),
+ pane_id: Some(pane_id.clone()),
+ selection: Some(crate::api::schema::PaneSelectionReadParams {
+ pane_id,
+ anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 },
+ cursor: crate::api::schema::PaneTextPoint { row: 0, col: 7 },
+ content_revision: Some(u64::MAX),
+ }),
+ },
+ );
+
+ let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "stale_content");
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn shell_command_invocation_executes_endpoint_owned_definition() {
+ let mut app = test_app();
+ let path = std::path::PathBuf::from(format!(
+ "/var/tmp/herdr-command-invoke-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ ));
+ let _ = std::fs::remove_file(&path);
+ let mut command = binding(crate::config::CustomCommandAction::Shell);
+ command.command = format!("printf invoked > {}", path.display());
+ install(&mut app, command);
+ let command_id = app.client_shell_command_manifest()[0].command_id.clone();
+
+ let response = app.handle_command_invoke(
+ "request-1".into(),
+ crate::api::schema::CommandInvokeParams {
+ command_id,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ selection: None,
+ },
+ );
+ let success: crate::api::schema::SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(success.result, crate::api::schema::ResponseResult::Ok {});
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while !path.exists() && std::time::Instant::now() < deadline {
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ assert_eq!(std::fs::read_to_string(&path).unwrap(), "invoked");
+ let _ = std::fs::remove_file(path);
+ }
+
+ #[test]
+ fn popup_commands_are_advertised_without_exposing_the_command_text() {
+ let mut app = test_app();
+ install(&mut app, binding(crate::config::CustomCommandAction::Popup));
+
+ let manifest = app.client_shell_command_manifest();
+ assert_eq!(manifest.len(), 1);
+ assert_eq!(
+ manifest[0].action,
+ crate::protocol::ClientShellCommandAction::Popup
+ );
+ assert!(!format!("{manifest:?}").contains("secret-command"));
+ }
+}