mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
+9
-9
@@ -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
@@ -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
@@ -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
@@ -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 ...]");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user