diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-08 16:26:47 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-08 16:26:47 +0300 |
| commit | 10974c822d607f03e20e9741ec027910f0c1f93a (patch) | |
| tree | 85453f5ba5bcfcc56c37cabdbbced8a7e701cbe3 /src | |
| parent | 1d6d1fdc6ba8a4fd0385a2071f157504871201a2 (diff) | |
feat(input): add per-pane right-click routing (#2504)
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 2 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 18 | ||||
| -rw-r--r-- | src/api/server.rs | 1 | ||||
| -rw-r--r-- | src/app/api.rs | 1 | ||||
| -rw-r--r-- | src/app/api/panes.rs | 70 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 56 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 121 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 12 | ||||
| -rw-r--r-- | src/app/runtime_mutations.rs | 18 | ||||
| -rw-r--r-- | src/app/state.rs | 95 | ||||
| -rw-r--r-- | src/cli/pane.rs | 171 | ||||
| -rw-r--r-- | src/cli/runtime.rs | 14 | ||||
| -rw-r--r-- | src/cli/spec.rs | 12 | ||||
| -rw-r--r-- | src/pane/state.rs | 3 | ||||
| -rw-r--r-- | src/workspace.rs | 6 |
17 files changed, 499 insertions, 103 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 7520d83c..27e98d2b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -56,6 +56,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool { | Method::PaneFocusDirection(_) | Method::PaneResize(_) | Method::PaneFocus(_) + | Method::PaneInputSet(_) | Method::PaneRename(_) | Method::PaneGraphicsSet(_) | Method::PaneGraphicsClear(_) diff --git a/src/api/schema.rs b/src/api/schema.rs index 42179c06..447ea42c 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -161,6 +161,8 @@ pub enum Method { PaneGet(PaneTarget), #[serde(rename = "pane.focus")] PaneFocus(PaneTarget), + #[serde(rename = "pane.input.set")] + PaneInputSet(PaneInputSetParams), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 647f7446..2dffd7f9 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -8,6 +8,16 @@ pub(crate) const PANE_GRAPHICS_STREAM_MAX_BYTES: usize = 16 * 1024 * 1024; use super::agents::AgentSessionInfo; use super::common::{AgentStatus, PaneAgentState, ReadFormat, ReadSource, SplitDirection}; +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, Default, +)] +#[serde(rename_all = "snake_case")] +pub enum PaneRightClickTarget { + #[default] + Herdr, + Pane, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] pub struct PaneSplitParams { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -21,10 +31,18 @@ pub struct PaneSplitParams { pub cwd: Option<String>, #[serde(default)] pub focus: bool, + #[serde(default)] + pub right_click: PaneRightClickTarget, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub env: HashMap<String, String>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneInputSetParams { + pub pane_id: String, + pub right_click: PaneRightClickTarget, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PaneDirection { diff --git a/src/api/server.rs b/src/api/server.rs index 0598faea..3932b1ea 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -402,6 +402,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneCurrent(_) => "pane.current", Method::PaneGet(_) => "pane.get", Method::PaneFocus(_) => "pane.focus", + Method::PaneInputSet(_) => "pane.input.set", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", diff --git a/src/app/api.rs b/src/app/api.rs index c2130f36..6dc40c3f 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1080,6 +1080,7 @@ impl App { Method::PaneCurrent(params) => return self.handle_pane_current(request.id, params), Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), + Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), Method::PaneRename(params) => return self.handle_pane_rename(request.id, params), Method::PaneRead(params) => return self.handle_pane_read(request.id, params), Method::PaneGraphicsSet(params) => { diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index 97fa0633..4cbdd661 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -3,11 +3,12 @@ use bytes::Bytes; use crate::api::schema::{ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, - PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneLayoutPane, PaneLayoutParams, - PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, PaneListParams, PaneMoveDestination, - PaneMoveParams, PaneMoveReason, PaneMoveResult, PaneNeighborParams, PaneNeighborResult, - PaneProcessInfo, PaneProcessInfoParams, PaneProcessInfoProcess, PaneReadParams, PaneReadResult, - PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, + PaneFocusDirectionReason, PaneFocusDirectionResult, PaneInfo, PaneInputSetParams, + PaneLayoutPane, PaneLayoutParams, PaneLayoutRect, PaneLayoutSnapshot, PaneLayoutSplit, + PaneListParams, PaneMoveDestination, PaneMoveParams, PaneMoveReason, PaneMoveResult, + PaneNeighborParams, PaneNeighborResult, PaneProcessInfo, PaneProcessInfoParams, + PaneProcessInfoProcess, PaneReadParams, PaneReadResult, PaneReleaseAgentParams, + PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams, PaneResizeParams, PaneResizeReason, PaneResizeResult, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneZoomMode, PaneZoomParams, PaneZoomReason, @@ -101,6 +102,12 @@ impl App { Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()), None => return encode_error(id, "pane_not_found", "pane not found"), }; + if let Some(pane) = self.state.workspaces[ws_idx].pane_state_mut(new_pane.pane_id) { + pane.right_click_passthrough = matches!( + params.right_click, + crate::api::schema::PaneRightClickTarget::Pane + ); + } if params.focus { self.state.switch_workspace_tab(ws_idx, target_tab_idx); self.state @@ -1130,6 +1137,29 @@ impl App { ) } + pub(super) fn handle_pane_input_set( + &mut self, + id: String, + params: PaneInputSetParams, + ) -> String { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return pane_not_found(id, ¶ms.pane_id); + }; + let Some(pane) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|workspace| workspace.pane_state_mut(pane_id)) + else { + return pane_not_found(id, ¶ms.pane_id); + }; + pane.right_click_passthrough = matches!( + params.right_click, + crate::api::schema::PaneRightClickTarget::Pane + ); + encode_success(id, ResponseResult::Ok {}) + } + pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); @@ -1893,6 +1923,36 @@ mod tests { (app, public_pane_id) } + #[test] + fn pane_input_set_changes_only_the_target_pane() { + let (mut app, public_pane_id) = app_with_test_workspace(); + let target = app.state.workspaces[0].tabs[0].root_pane; + let other = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); + + let response = app.handle_pane_input_set( + "req".into(), + PaneInputSetParams { + pane_id: public_pane_id, + right_click: crate::api::schema::PaneRightClickTarget::Pane, + }, + ); + + let response: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert!(matches!(response.result, ResponseResult::Ok {})); + assert!( + app.state.workspaces[0] + .pane_state(target) + .unwrap() + .right_click_passthrough + ); + assert!( + !app.state.workspaces[0] + .pane_state(other) + .unwrap() + .right_click_passthrough + ); + } + fn app_with_send_key_runtime( capacity: usize, ) -> (App, String, tokio::sync::mpsc::Receiver<bytes::Bytes>) { diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index 0a5c13e5..7a9ffe46 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -1288,6 +1288,27 @@ impl App { } ( ContextMenuKind::Pane { + ws_idx, pane_id, .. + }, + Some(action @ ("Send right-clicks to pane" | "Use Herdr right-click menu")), + ) => { + if let Some(pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.runtime_pane_input_set( + "tui.pane.input.set", + crate::api::schema::PaneInputSetParams { + pane_id, + right_click: if action == "Send right-clicks to pane" { + crate::api::schema::PaneRightClickTarget::Pane + } else { + crate::api::schema::PaneRightClickTarget::Herdr + }, + }, + ); + } + self.state.mode = Mode::Terminal; + } + ( + ContextMenuKind::Pane { ws_idx, pane_id, source_pane_id: Some(source_pane_id), @@ -2180,6 +2201,39 @@ mod tests { } #[test] + fn context_menu_toggles_pane_right_click_passthrough() { + let mut app = app_with_test_workspaces(&["main"]); + app.state.active = Some(0); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let menu = ContextMenuState { + kind: ContextMenuKind::Pane { + ws_idx: 0, + tab_idx: 0, + pane_id, + source_pane_id: None, + has_manual_label: false, + right_click_passthrough: false, + }, + x: 0, + y: 0, + list: MenuListState::new(0), + }; + let idx = menu + .items() + .iter() + .position(|item| *item == "Send right-clicks to pane") + .unwrap(); + app.apply_context_menu_action_via_api(menu, idx); + + assert!( + app.state.workspaces[0] + .pane_state(pane_id) + .unwrap() + .right_click_passthrough + ); + } + + #[test] fn context_menu_close_pane_last_parent_group_pane_keeps_confirmation_mode() { let mut state = state_with_workspaces(&["main", "issue"]); state.active = Some(0); @@ -2206,6 +2260,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 0, y: 0, @@ -2271,6 +2326,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 0, y: 0, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index d43dcfc4..2555d95d 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -1099,13 +1099,16 @@ impl AppState { .and_then(|ws| ws.focused_pane_id()); let source_pane_id = previous_focused_pane_id.filter(|pane_id| *pane_id != info.id); - let has_manual_label = self + let pane_state = self .workspaces .get(ws_idx) - .and_then(|ws| ws.pane_state(info.id)) + .and_then(|ws| ws.pane_state(info.id)); + let has_manual_label = pane_state .and_then(|pane| self.terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.manual_label.as_ref()) .is_some(); + let right_click_passthrough = + pane_state.is_some_and(|pane| pane.right_click_passthrough); self.context_menu = Some(ContextMenuState { kind: ContextMenuKind::Pane { ws_idx, @@ -1113,6 +1116,7 @@ impl AppState { pane_id: info.id, source_pane_id, has_manual_label, + right_click_passthrough, }, x: mouse.column, y: mouse.row, @@ -1565,14 +1569,22 @@ impl AppState { return false; } - let Some(modifiers) = self.right_click_passthrough_modifiers else { + let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { return false; }; - if mouse.modifiers != modifiers { - return false; - } - - let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() else { + let configured_modifiers = self + .right_click_passthrough_modifiers + .filter(|modifiers| mouse.modifiers == *modifiers); + let pane_passthrough = mouse.modifiers.is_empty() + && self.active.is_some_and(|ws_idx| { + self.workspaces + .get(ws_idx) + .and_then(|workspace| workspace.pane_state(info.id)) + .is_some_and(|pane| pane.right_click_passthrough) + }); + let Some(modifiers) = configured_modifiers + .or_else(|| pane_passthrough.then(crossterm::event::KeyModifiers::empty)) + else { return false; }; @@ -2042,6 +2054,98 @@ mod tests { } #[tokio::test] + async fn pane_right_click_passthrough_is_isolated() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let passthrough_pane = ws.tabs[0].root_pane; + let default_pane = ws.test_split(Direction::Horizontal); + ws.pane_state_mut(passthrough_pane) + .unwrap() + .right_click_passthrough = true; + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + let passthrough_info = app.state.pane_info_by_id(passthrough_pane).unwrap().clone(); + let default_info = app.state.pane_info_by_id(default_pane).unwrap().clone(); + let (passthrough_runtime, mut passthrough_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + passthrough_info.inner_rect.width, + passthrough_info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + let (default_runtime, mut default_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + default_info.inner_rect.width, + default_info.inner_rect.height, + 0, + b"\x1b[?1002h\x1b[?1006h", + 4, + ); + app.state + .insert_test_runtime(passthrough_pane, passthrough_runtime); + app.state.insert_test_runtime(default_pane, default_runtime); + + let col = passthrough_info.inner_rect.x + 2; + let row = passthrough_info.inner_rect.y + 3; + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Right), col, row)); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.context_menu.is_none()); + assert_eq!( + passthrough_input.try_recv().unwrap(), + Bytes::from_static(b"\x1b[<2;3;4M") + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + default_info.inner_rect.x + 2, + default_info.inner_rect.y + 3, + )); + + assert!(default_input.try_recv().is_err()); + assert!(matches!( + app.state.context_menu.as_ref().map(|menu| &menu.kind), + Some(ContextMenuKind::Pane { pane_id, .. }) if *pane_id == default_pane + )); + } + + #[tokio::test] + async fn pane_right_click_passthrough_falls_back_when_mouse_reporting_is_off() { + let mut app = app_for_mouse_test(); + let mut ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + ws.pane_state_mut(pane_id).unwrap().right_click_passthrough = true; + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + let info = app.state.pane_info_by_id(pane_id).unwrap().clone(); + app.state.insert_test_runtime( + pane_id, + crate::terminal::TerminalRuntime::test_with_screen_bytes( + info.inner_rect.width, + info.inner_rect.height, + b"", + ), + ); + + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Right), + info.inner_rect.x + 2, + info.inner_rect.y + 3, + )); + + assert_eq!(app.state.mode, Mode::ContextMenu); + assert!(app.state.context_menu.is_some()); + } + + #[tokio::test] async fn configured_right_click_passthrough_forwards_full_gesture_to_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); @@ -2966,6 +3070,7 @@ mod tests { pane_id, source_pane_id: None, has_manual_label: false, + right_click_passthrough: false, }, x: 2, y: 2, diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 1c892028..64168035 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -578,6 +578,7 @@ impl App { ratio: None, cwd: None, focus: true, + right_click: Default::default(), env: Default::default(), }, ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5e3c86de..7fb8a01c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4381,6 +4381,7 @@ mod tests { ratio: None, cwd: None, focus: false, + right_click: Default::default(), env: Default::default(), }), }); @@ -4461,6 +4462,7 @@ mod tests { ratio: None, cwd: None, focus: true, + right_click: Default::default(), env: Default::default(), }), }); @@ -4507,6 +4509,7 @@ mod tests { ratio: Some(0.333), cwd: None, focus: false, + right_click: crate::api::schema::PaneRightClickTarget::Pane, env: Default::default(), }), }); @@ -4518,6 +4521,14 @@ mod tests { .splits(ratatui::layout::Rect::new(0, 0, 100, 20)); assert_eq!(splits.len(), 1); assert!((splits[0].ratio - 0.333).abs() < f32::EPSILON); + let response_pane_id = response["result"]["pane"]["pane_id"].as_str().unwrap(); + let (_, response_pane_id) = app.parse_pane_id(response_pane_id).unwrap(); + assert!( + app.state.workspaces[0] + .pane_state(response_pane_id) + .unwrap() + .right_click_passthrough + ); let runtimes: Vec<_> = app.terminal_runtimes.drain().collect(); for (_terminal_id, runtime) in runtimes { @@ -4553,6 +4564,7 @@ mod tests { ratio: None, cwd: None, focus: false, + right_click: Default::default(), env: Default::default(), }), }); diff --git a/src/app/runtime_mutations.rs b/src/app/runtime_mutations.rs index f765bf20..e9b3fde1 100644 --- a/src/app/runtime_mutations.rs +++ b/src/app/runtime_mutations.rs @@ -1,9 +1,9 @@ use crate::api::schema::{ - EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneRenameParams, - PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, TabCreateParams, - TabMoveParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceMoveBlockParams, - WorkspaceMoveParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, - WorktreeOpenParams, WorktreeRemoveParams, + EmptyParams, LayoutSetSplitRatioParams, Method, PaneFocusDirectionParams, PaneInputSetParams, + PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, + PaneZoomParams, TabCreateParams, TabMoveParams, TabRenameParams, TabTarget, + WorkspaceCreateParams, WorkspaceMoveBlockParams, WorkspaceMoveParams, WorkspaceRenameParams, + WorkspaceTarget, WorktreeCreateParams, WorktreeOpenParams, WorktreeRemoveParams, }; use super::App; @@ -117,6 +117,14 @@ impl App { self.dispatch_runtime_mutation(id, Method::PaneRename(params)) } + pub(crate) fn runtime_pane_input_set( + &mut self, + id: &'static str, + params: PaneInputSetParams, + ) -> String { + self.dispatch_runtime_mutation(id, Method::PaneInputSet(params)) + } + pub(crate) fn runtime_pane_focus_direction( &mut self, id: &'static str, diff --git a/src/app/state.rs b/src/app/state.rs index ca1b919b..97266344 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1203,6 +1203,7 @@ pub enum ContextMenuKind { pane_id: PaneId, source_pane_id: Option<PaneId>, has_manual_label: bool, + right_click_passthrough: bool, }, } @@ -1215,91 +1216,53 @@ pub struct ContextMenuState { } impl ContextMenuState { - pub fn items(&self) -> &'static [&'static str] { + pub fn items(&self) -> Vec<&'static str> { match self.kind { - ContextMenuKind::Workspace { .. } => &["Rename", "Close"], + ContextMenuKind::Workspace { .. } => vec!["Rename", "Close"], ContextMenuKind::GitWorkspace { is_linked_worktree: false, has_worktree_children: false, .. - } => &["Rename", "Close", "New worktree", "Open worktree..."], + } => vec!["Rename", "Close", "New worktree", "Open worktree..."], ContextMenuKind::GitWorkspace { is_linked_worktree: true, .. - } => &["Rename", "Close", "Delete worktree checkout..."], + } => vec!["Rename", "Close", "Delete worktree checkout..."], ContextMenuKind::GitWorkspace { is_linked_worktree: false, has_worktree_children: true, - collapsed: true, + collapsed, .. - } => &[ + } => vec![ "Rename", "Close group", "New worktree", "Open worktree...", - "Expand", + if collapsed { "Expand" } else { "Collapse" }, ], - ContextMenuKind::GitWorkspace { - is_linked_worktree: false, - has_worktree_children: true, - collapsed: false, - .. - } => &[ - "Rename", - "Close group", - "New worktree", - "Open worktree...", - "Collapse", - ], - ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"], + ContextMenuKind::Tab { .. } => vec!["New tab", "Rename", "Close"], ContextMenuKind::Pane { - has_manual_label: true, - source_pane_id: Some(_), + source_pane_id, + has_manual_label, + right_click_passthrough, .. - } => &[ - "Rename pane", - "Clear pane name", - "Swap with focused pane", - "Split right", - "Split down", - "Zoom", - "Close pane", - ], - ContextMenuKind::Pane { - has_manual_label: false, - source_pane_id: Some(_), - .. - } => &[ - "Rename pane", - "Swap with focused pane", - "Split right", - "Split down", - "Zoom", - "Close pane", - ], - ContextMenuKind::Pane { - has_manual_label: true, - source_pane_id: None, - .. - } => &[ - "Rename pane", - "Clear pane name", - "Split right", - "Split down", - "Zoom", - "Close pane", - ], - ContextMenuKind::Pane { - has_manual_label: false, - source_pane_id: None, - .. - } => &[ - "Rename pane", - "Split right", - "Split down", - "Zoom", - "Close pane", - ], + } => { + let mut items = vec!["Rename pane"]; + if has_manual_label { + items.push("Clear pane name"); + } + if source_pane_id.is_some() { + items.push("Swap with focused pane"); + } + items.extend(["Split right", "Split down", "Zoom"]); + items.push(if right_click_passthrough { + "Use Herdr right-click menu" + } else { + "Send right-clicks to pane" + }); + items.push("Close pane"); + items + } } } } diff --git a/src/cli/pane.rs b/src/cli/pane.rs index 5979fd99..363a2e05 100644 --- a/src/cli/pane.rs +++ b/src/cli/pane.rs @@ -1,11 +1,12 @@ use crate::api::schema::{ Method, OutputMatch, PaneCurrentParams, PaneDirection, PaneEdgesParams, - PaneFocusDirectionParams, PaneLayoutParams, PaneListParams, PaneMoveDestination, - PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams, + PaneFocusDirectionParams, PaneInputSetParams, PaneLayoutParams, PaneListParams, + PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneProcessInfoParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams, - PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, - PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneWaitForOutputParams, - PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, SplitDirection, + PaneReportMetadataParams, PaneResizeParams, PaneRightClickTarget, PaneSendInputParams, + PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, + PaneWaitForOutputParams, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request, + SplitDirection, }; pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { @@ -27,6 +28,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "zoom" => pane_zoom(&args[1..]), "read" => pane_read(&args[1..]), "rename" => pane_rename(&args[1..]), + "input" => pane_input(&args[1..]), "split" => pane_split(&args[1..]), "swap" => pane_swap(&args[1..]), "move" => pane_move(&args[1..]), @@ -538,6 +540,86 @@ fn parse_pane_read_args(args: &[String]) -> Result<PaneReadParams, String> { }) } +fn pane_input(args: &[String]) -> std::io::Result<i32> { + let env_pane_id = std::env::var("HERDR_PANE_ID") + .ok() + .filter(|value| !value.trim().is_empty()); + let params = match parse_pane_input_args(args, env_pane_id.as_deref()) { + Ok(params) => params, + Err(message) => { + eprintln!("{message}"); + return Ok(2); + } + }; + super::runtime::pane_input_set(params) +} + +fn parse_pane_input_args( + args: &[String], + env_pane_id: Option<&str>, +) -> Result<PaneInputSetParams, String> { + const USAGE: &str = + "usage: herdr pane input [<pane_id>|--pane ID|--current] --right-click herdr|pane"; + + let args = super::expand_equals_args(args, &["--pane", "--right-click"]); + let mut pane_id = None; + let mut right_click = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--pane" => { + if pane_id.is_some() { + return Err("provide only one pane selector".into()); + } + let Some(value) = args.get(index + 1) else { + return Err("missing value for --pane".into()); + }; + pane_id = Some(super::normalize_pane_id(value)); + index += 2; + } + "--current" => { + if pane_id.is_some() { + return Err("provide only one pane selector".into()); + } + pane_id = Some( + env_pane_id + .map(super::normalize_pane_id) + .ok_or("--current requires HERDR_PANE_ID")?, + ); + index += 1; + } + "--right-click" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --right-click".into()); + }; + right_click = Some(parse_right_click_target(value)?); + index += 2; + } + option if option.starts_with('-') => return Err(format!("unknown option: {option}")), + positional => { + if pane_id.is_some() { + return Err(format!("unexpected argument: {positional}")); + } + pane_id = Some(super::normalize_pane_id(positional)); + index += 1; + } + } + } + + Ok(PaneInputSetParams { + pane_id: pane_id.ok_or(USAGE)?, + right_click: right_click.ok_or(USAGE)?, + }) +} + +fn parse_right_click_target(value: &str) -> Result<PaneRightClickTarget, String> { + match value { + "herdr" => Ok(PaneRightClickTarget::Herdr), + "pane" => Ok(PaneRightClickTarget::Pane), + _ => Err(format!("invalid right-click target: {value}")), + } +} + fn pane_split(args: &[String]) -> std::io::Result<i32> { let env_pane_id = std::env::var("HERDR_PANE_ID") .ok() @@ -557,12 +639,14 @@ fn parse_pane_split_args( args: &[String], env_pane_id: Option<&str>, ) -> Result<PaneSplitParams, String> { + let args = super::expand_equals_args(args, &["--right-click"]); let mut env = std::collections::HashMap::new(); let mut pane_id = None; let mut direction = None; let mut ratio = None; let mut cwd = None; let mut focus = false; + let mut right_click = PaneRightClickTarget::Herdr; let mut index = 0; if args @@ -582,7 +666,11 @@ fn parse_pane_split_args( index += 2; } "--current" => { - pane_id = env_pane_id.map(super::normalize_pane_id); + pane_id = Some( + env_pane_id + .map(super::normalize_pane_id) + .ok_or("--current requires HERDR_PANE_ID")?, + ); index += 1; } "--direction" => { @@ -613,6 +701,13 @@ fn parse_pane_split_args( cwd = Some(value.clone()); index += 2; } + "--right-click" => { + let Some(value) = args.get(index + 1) else { + return Err("missing value for --right-click".into()); + }; + right_click = parse_right_click_target(value)?; + index += 2; + } "--focus" => { focus = true; index += 1; @@ -635,7 +730,7 @@ fn parse_pane_split_args( let Some(direction) = direction else { return Err( - "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]" + "usage: herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--right-click herdr|pane] [--focus] [--no-focus]" .into(), ); }; @@ -647,6 +742,7 @@ fn parse_pane_split_args( ratio, cwd, focus, + right_click, env, }) } @@ -1545,8 +1641,9 @@ fn print_pane_help() { eprintln!(" herdr pane zoom [<pane_id>|--pane ID|--current] [--toggle|--on|--off]"); eprintln!(" herdr pane rename <pane_id> <label>|--clear"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); + eprintln!(" herdr pane input [<pane_id>|--pane ID|--current] --right-click herdr|pane"); eprintln!( - " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--focus] [--no-focus]" + " herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--env KEY=VALUE] [--right-click herdr|pane] [--focus] [--no-focus]" ); eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]"); eprintln!(" herdr pane swap --source-pane ID --target-pane ID"); @@ -1583,6 +1680,56 @@ mod tests { assert_eq!(params.target_pane_id, Some("issue-1".into())); assert_eq!(params.direction, crate::api::schema::SplitDirection::Right); assert_eq!(params.ratio, Some(0.333)); + assert_eq!(params.right_click, PaneRightClickTarget::Herdr); + } + + #[test] + fn parse_pane_split_args_accepts_pane_right_click_target() { + let params = parse_pane_split_args( + &args(&["--direction", "right", "--right-click", "pane"]), + None, + ) + .unwrap(); + + assert_eq!(params.right_click, PaneRightClickTarget::Pane); + } + + #[test] + fn parse_pane_input_args_requires_and_uses_calling_pane() { + let params = parse_pane_input_args( + &args(&["--current", "--right-click", "pane"]), + Some("issue-1:p1"), + ) + .unwrap(); + + assert_eq!(params.pane_id, "issue-1:p1"); + assert_eq!(params.right_click, PaneRightClickTarget::Pane); + assert!( + parse_pane_input_args(&args(&["--current", "--right-click", "pane"]), None).is_err() + ); + } + + #[test] + fn parse_pane_input_args_rejects_conflicting_selectors() { + assert!(parse_pane_input_args( + &args(&["pane-a", "--pane", "pane-b", "--right-click", "pane"]), + None, + ) + .is_err()); + assert!(parse_pane_input_args( + &args(&["--pane", "pane-a", "--current", "--right-click", "pane",]), + Some("pane-b"), + ) + .is_err()); + } + + #[test] + fn parse_pane_split_args_accepts_equals_right_click_target() { + let params = + parse_pane_split_args(&args(&["--direction", "right", "--right-click=pane"]), None) + .unwrap(); + + assert_eq!(params.right_click, PaneRightClickTarget::Pane); } #[test] @@ -1598,12 +1745,8 @@ mod tests { } #[test] - fn parse_pane_split_args_current_without_env_keeps_focused_fallback() { - let params = - parse_pane_split_args(&args(&["--direction", "down", "--current"]), None).unwrap(); - - assert_eq!(params.target_pane_id, None); - assert_eq!(params.direction, crate::api::schema::SplitDirection::Down); + fn parse_pane_split_args_current_without_env_is_rejected() { + assert!(parse_pane_split_args(&args(&["--direction", "down", "--current"]), None).is_err()); } #[test] diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index 0d1251b1..16d2d8b3 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -1,9 +1,9 @@ use crate::api::schema::{ - EmptyParams, Method, PaneFocusDirectionParams, PaneMoveParams, PaneRenameParams, - PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomParams, Request, - TabCreateParams, TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, - WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, WorktreeListParams, - WorktreeOpenParams, WorktreeRemoveParams, + EmptyParams, Method, PaneFocusDirectionParams, PaneInputSetParams, PaneMoveParams, + PaneRenameParams, PaneResizeParams, PaneSplitParams, PaneSwapParams, PaneTarget, + PaneZoomParams, Request, TabCreateParams, TabListParams, TabRenameParams, TabTarget, + WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, WorktreeCreateParams, + WorktreeListParams, WorktreeOpenParams, WorktreeRemoveParams, }; fn print_method_response(id: &'static str, method: Method) -> std::io::Result<i32> { @@ -105,6 +105,10 @@ pub(super) fn pane_rename(params: PaneRenameParams) -> std::io::Result<i32> { print_method_response("cli:pane:rename", Method::PaneRename(params)) } +pub(super) fn pane_input_set(params: PaneInputSetParams) -> std::io::Result<i32> { + print_method_response("cli:pane:input:set", Method::PaneInputSet(params)) +} + pub(super) fn pane_split(params: PaneSplitParams) -> std::io::Result<i32> { print_method_response("cli:pane:split", Method::PaneSplit(params)) } diff --git a/src/cli/spec.rs b/src/cli/spec.rs index 3be83328..a1efdac4 100644 --- a/src/cli/spec.rs +++ b/src/cli/spec.rs @@ -525,6 +525,17 @@ fn pane_command() -> Command { .arg(flag("clear")), ) .subcommand( + Command::new("input") + .about("Set pane input routing") + .arg(Arg::new("pane_id").value_name("PANE_ID")) + .args(current_pane_args()) + .arg( + option("right-click", "TARGET") + .value_parser(["herdr", "pane"]) + .required(true), + ), + ) + .subcommand( Command::new("split") .about("Split a pane") .arg(Arg::new("pane_id").value_name("PANE_ID")) @@ -533,6 +544,7 @@ fn pane_command() -> Command { .arg(option("ratio", "FLOAT")) .arg(path_option("cwd", "PATH")) .arg(env_option()) + .arg(option("right-click", "TARGET").value_parser(["herdr", "pane"])) .arg(flag("focus")) .arg(flag("no-focus")), ) diff --git a/src/pane/state.rs b/src/pane/state.rs index be7ebfae..9a733c29 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -8,6 +8,8 @@ pub struct PaneState { /// Whether the user has seen this pane since its last state change to Idle. /// False = "Done" (agent finished while user was in another workspace). pub seen: bool, + /// Whether unmodified right-click gestures should be forwarded to the pane application. + pub right_click_passthrough: bool, } impl PaneState { @@ -15,6 +17,7 @@ impl PaneState { Self { attached_terminal_id, seen: true, + right_click_passthrough: false, } } } diff --git a/src/workspace.rs b/src/workspace.rs index bfda45f6..85377f23 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1185,6 +1185,12 @@ impl Workspace { self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id)) } + pub fn pane_state_mut(&mut self, pane_id: PaneId) -> Option<&mut PaneState> { + self.tabs + .iter_mut() + .find_map(|tab| tab.panes.get_mut(&pane_id)) + } + pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> { self.tabs.iter().find_map(|tab| tab.terminal_id(pane_id)) } |
