fix: split panes in target tab without stealing focus

fixes #75
This commit is contained in:
Ogulcan Celik
2026-05-08 15:37:01 +03:00
parent ac32c1a19b
commit bb0dad86f8
5 changed files with 206 additions and 22 deletions
+9 -9
View File
@@ -273,7 +273,7 @@ notes:
- `cwd` is optional
- if `cwd` is omitted, herdr uses its current working directory and falls back to `/` if needed
- `focus` is optional in raw socket requests and defaults to `false`
- the cli wrapper is more ergonomic here: `herdr workspace create` focuses by default unless you pass `--no-focus`
- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new workspace
example response:
@@ -399,7 +399,7 @@ notes:
- `workspace_id` is optional and defaults to the active workspace
- `cwd` is optional; if omitted, herdr uses the focused pane cwd in that workspace when available
- `focus` is optional in raw socket requests and defaults to `false`
- the cli wrapper focuses by default unless you pass `--no-focus`
- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new tab
returns `tab_info` for the new tab.
@@ -542,7 +542,7 @@ notes:
- `direction` must be `right` or `down`
- `cwd` is optional
- `focus` is optional in raw socket requests and defaults to `false`
- the cli wrapper is more ergonomic here too: `herdr pane split ...` focuses by default unless you pass `--no-focus`
- the cli wrapper also defaults to no focus; pass `--focus` to switch to the new pane
returns `pane_info` for the new pane.
@@ -918,7 +918,7 @@ workspace commands:
```text
herdr workspace list
herdr workspace create [--cwd PATH] [--label TEXT] [--no-focus]
herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]
herdr workspace get <workspace_id>
herdr workspace focus <workspace_id>
herdr workspace rename <workspace_id> <label>
@@ -929,7 +929,7 @@ tab commands:
```text
herdr tab list [--workspace <workspace_id>]
herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--no-focus]
herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]
herdr tab get <tab_id>
herdr tab focus <tab_id>
herdr tab rename <tab_id> <label>
@@ -942,7 +942,7 @@ pane commands:
herdr pane list [--workspace <workspace_id>]
herdr pane get <pane_id>
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw]
herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]
herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]
herdr pane close <pane_id>
herdr pane send-text <pane_id> <text>
herdr pane send-keys <pane_id> <key> [key ...]
@@ -961,15 +961,15 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [
- `status` prints local client version/protocol, running server version/protocol when reachable, socket path, compatibility, and whether a restart is needed
- `status server` prints only the running server side; if no server is reachable it exits successfully and prints `status: not running`
- `status client` prints only the local executable version/protocol and binary path without contacting the server
- `workspace create` focuses by default; pass `--no-focus` to keep focus where it is
- `workspace create` keeps focus where it is by default; pass `--focus` to switch to the new workspace
- `workspace create` without `--label` keeps the default cwd-based workspace naming
- `workspace create --label` applies the custom workspace name immediately
- `workspace create` returns `result.workspace`, `result.tab`, and `result.root_pane`
- `tab create` focuses by default; pass `--no-focus` to keep focus where it is
- `tab create` keeps focus where it is by default; pass `--focus` to switch to the new tab
- `tab create` without `--label` keeps the default numbered tab naming
- `tab create --label` applies the custom tab name immediately
- `tab create` returns `result.tab` and `result.root_pane`
- `pane split` focuses the new pane by default; pass `--no-focus` to keep focus on the original pane
- `pane split` keeps focus where it is by default; pass `--focus` to switch to the new pane
- `pane read` prints **text**, not json
- `pane read --source recent-unwrapped` returns recent terminal text with soft wraps joined back together
- `pane send-text`, `pane send-keys`, and `pane run` print nothing on success
+19 -6
View File
@@ -819,7 +819,6 @@ impl App {
})
.unwrap();
};
ws.layout.focus_pane(target_pane_id);
let direction = match params.direction {
crate::api::schema::SplitDirection::Right => {
ratatui::layout::Direction::Horizontal
@@ -828,16 +827,18 @@ impl App {
ratatui::layout::Direction::Vertical
}
};
let new_pane_id = match ws.split_focused(
let (target_tab_idx, new_pane_id) = match ws.split_pane(
target_pane_id,
direction,
rows,
cols,
params.cwd.map(std::path::PathBuf::from),
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
params.focus,
) {
Ok(new_pane_id) => new_pane_id,
Err(err) => {
Some(Ok(result)) => result,
Some(Err(err)) => {
return serde_json::to_string(&ErrorResponse {
id: request.id,
error: ErrorBody {
@@ -847,9 +848,21 @@ impl App {
})
.unwrap();
}
None => {
return serde_json::to_string(&ErrorResponse {
id: request.id,
error: ErrorBody {
code: "pane_not_found".into(),
message: format!("pane {} not found", params.target_pane_id),
},
})
.unwrap();
}
};
if !params.focus {
ws.layout.focus_pane(target_pane_id);
if params.focus {
self.state.switch_workspace(ws_idx);
self.state.switch_tab(target_tab_idx);
self.state.mode = Mode::Terminal;
}
self.schedule_session_save();
let pane = self.pane_info(ws_idx, new_pane_id).unwrap();
+121
View File
@@ -1436,6 +1436,127 @@ mod tests {
assert!(app.state.should_quit);
}
#[tokio::test]
async fn pane_split_request_targets_pane_in_background_tab() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
std::env::set_var("SHELL", "/usr/bin/true");
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-background-tab");
let active_pane = workspace.tabs[0].root_pane;
let background_tab = workspace.test_add_tab(Some("worker"));
let target_pane = workspace.tabs[background_tab].root_pane;
workspace.switch_tab(background_tab);
let background_previous_focus =
workspace.test_split(ratatui::layout::Direction::Horizontal);
workspace.switch_tab(0);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id;
let target_tab_id = app.public_tab_id(0, background_tab).unwrap();
let response = app.handle_api_request(crate::api::schema::Request {
id: "req_pane_split_background_tab".into(),
method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
workspace_id: None,
target_pane_id,
direction: crate::api::schema::SplitDirection::Right,
cwd: None,
focus: false,
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "pane_info");
assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id);
assert_eq!(response["result"]["pane"]["focused"], false);
assert_eq!(app.state.active, Some(0));
assert_eq!(app.state.workspaces[0].active_tab, 0);
assert_eq!(
app.state.workspaces[0].tabs[0].layout.focused(),
active_pane
);
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
assert_eq!(
app.state.workspaces[0].tabs[background_tab]
.layout
.focused(),
background_previous_focus
);
assert_eq!(
app.state.workspaces[0].tabs[background_tab]
.layout
.pane_count(),
3
);
let runtimes: Vec<_> = app.state.workspaces[0]
.tabs
.iter_mut()
.flat_map(|tab| tab.runtimes.drain())
.collect();
for (pane_id, runtime) in runtimes {
runtime.shutdown(pane_id);
}
match original_shell {
Some(value) => std::env::set_var("SHELL", value),
None => std::env::remove_var("SHELL"),
}
}
#[tokio::test]
async fn pane_split_request_focuses_new_pane_when_requested() {
let _guard = config_env_lock().lock().unwrap();
let original_shell = std::env::var_os("SHELL");
std::env::set_var("SHELL", "/usr/bin/true");
let mut app = test_app();
let mut workspace = Workspace::test_new("api-pane-split-focus-background-tab");
let background_tab = workspace.test_add_tab(Some("worker"));
workspace.switch_tab(0);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
let target_pane = app.state.workspaces[0].tabs[background_tab].root_pane;
let target_pane_id = app.pane_info(0, target_pane).unwrap().pane_id;
let target_tab_id = app.public_tab_id(0, background_tab).unwrap();
let response = app.handle_api_request(crate::api::schema::Request {
id: "req_pane_split_focus_background_tab".into(),
method: crate::api::schema::Method::PaneSplit(crate::api::schema::PaneSplitParams {
workspace_id: None,
target_pane_id,
direction: crate::api::schema::SplitDirection::Right,
cwd: None,
focus: true,
}),
});
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
assert_eq!(response["result"]["type"], "pane_info");
assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id);
assert_eq!(response["result"]["pane"]["focused"], true);
assert_eq!(app.state.active, Some(0));
assert_eq!(app.state.workspaces[0].active_tab, background_tab);
let runtimes: Vec<_> = app.state.workspaces[0]
.tabs
.iter_mut()
.flat_map(|tab| tab.runtimes.drain())
.collect();
for (pane_id, runtime) in runtimes {
runtime.shutdown(pane_id);
}
match original_shell {
Some(value) => std::env::set_var("SHELL", value),
None => std::env::remove_var("SHELL"),
}
}
#[test]
fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() {
let mut app = test_app();
+21 -7
View File
@@ -475,7 +475,7 @@ fn workspace_list(args: &[String]) -> std::io::Result<i32> {
fn workspace_create(args: &[String]) -> std::io::Result<i32> {
let mut cwd = None;
let mut focus = true;
let mut focus = false;
let mut label = None;
let mut index = 0;
@@ -497,6 +497,10 @@ fn workspace_create(args: &[String]) -> std::io::Result<i32> {
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
@@ -613,7 +617,7 @@ fn tab_list(args: &[String]) -> std::io::Result<i32> {
fn tab_create(args: &[String]) -> std::io::Result<i32> {
let mut workspace_id = None;
let mut cwd = None;
let mut focus = true;
let mut focus = false;
let mut label = None;
let mut index = 0;
@@ -643,6 +647,10 @@ fn tab_create(args: &[String]) -> std::io::Result<i32> {
label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
@@ -844,7 +852,7 @@ fn pane_read(args: &[String]) -> std::io::Result<i32> {
fn pane_split(args: &[String]) -> std::io::Result<i32> {
let Some(raw_pane_id) = args.first() else {
eprintln!(
"usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]"
"usage: herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
);
return Ok(2);
};
@@ -852,7 +860,7 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> {
let pane_id = normalize_pane_id(raw_pane_id);
let mut direction = None;
let mut cwd = None;
let mut focus = true;
let mut focus = false;
let mut index = 1;
while index < args.len() {
@@ -873,6 +881,10 @@ fn pane_split(args: &[String]) -> std::io::Result<i32> {
cwd = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
@@ -1403,7 +1415,7 @@ fn print_status_help() {
fn print_workspace_help() {
eprintln!("herdr workspace commands:");
eprintln!(" herdr workspace list");
eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--no-focus]");
eprintln!(" herdr workspace create [--cwd PATH] [--label TEXT] [--focus] [--no-focus]");
eprintln!(" herdr workspace get <workspace_id>");
eprintln!(" herdr workspace focus <workspace_id>");
eprintln!(" herdr workspace rename <workspace_id> <label>");
@@ -1414,7 +1426,7 @@ fn print_tab_help() {
eprintln!("herdr tab commands:");
eprintln!(" herdr tab list [--workspace <workspace_id>]");
eprintln!(
" herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--no-focus]"
" herdr tab create [--workspace <workspace_id>] [--cwd PATH] [--label TEXT] [--focus] [--no-focus]"
);
eprintln!(" herdr tab get <tab_id>");
eprintln!(" herdr tab focus <tab_id>");
@@ -1427,7 +1439,9 @@ fn print_pane_help() {
eprintln!(" herdr pane list [--workspace <workspace_id>]");
eprintln!(" herdr pane get <pane_id>");
eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--raw]");
eprintln!(" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--no-focus]");
eprintln!(
" herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]"
);
eprintln!(" herdr pane close <pane_id>");
eprintln!(" herdr pane send-text <pane_id> <text>");
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
+36
View File
@@ -244,6 +244,42 @@ impl Workspace {
Ok(new_id)
}
pub fn split_pane(
&mut self,
pane_id: PaneId,
direction: Direction,
rows: u16,
cols: u16,
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
) -> Option<std::io::Result<(usize, PaneId)>> {
let tab_idx = self.find_tab_index_for_pane(pane_id)?;
let tab = &mut self.tabs[tab_idx];
let previous_focus = tab.layout.focused();
tab.layout.focus_pane(pane_id);
let new_id = match tab.split_focused(
direction,
rows,
cols,
cwd,
scrollback_limit_bytes,
host_terminal_theme,
) {
Ok(new_id) => new_id,
Err(err) => {
tab.layout.focus_pane(previous_focus);
return Some(Err(err));
}
};
if !focus_new_pane {
tab.layout.focus_pane(previous_focus);
}
self.register_new_pane(new_id);
Some(Ok((tab_idx, new_id)))
}
/// Close the focused pane. Returns true if the workspace should close.
pub fn close_focused(&mut self) -> bool {
let pane_count = self