aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs1
-rw-r--r--src/api/schema.rs2
-rw-r--r--src/api/schema/panes.rs18
-rw-r--r--src/api/server.rs1
-rw-r--r--src/app/api.rs1
-rw-r--r--src/app/api/panes.rs70
-rw-r--r--src/app/input/modal.rs56
-rw-r--r--src/app/input/mouse.rs121
-rw-r--r--src/app/input/navigate.rs1
-rw-r--r--src/app/mod.rs12
-rw-r--r--src/app/runtime_mutations.rs18
-rw-r--r--src/app/state.rs95
-rw-r--r--src/cli/pane.rs171
-rw-r--r--src/cli/runtime.rs14
-rw-r--r--src/cli/spec.rs12
-rw-r--r--src/pane/state.rs3
-rw-r--r--src/workspace.rs6
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(&params.pane_id) else {
+ return pane_not_found(id, &params.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, &params.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(&params.pane_id) else {
return pane_not_found(id, &params.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))
}