feat: add pane move api and cli

refs #299
This commit is contained in:
Ogulcan Celik 2026-06-13 01:59:24 +03:00
parent 469cf9cede
commit d35c642ef3
26 changed files with 1782 additions and 75 deletions

View File

@ -2,7 +2,11 @@
## Unreleased
### Added
- Added `pane.move` and `herdr pane move` to relocate a running pane into another tab, a new tab, or a new workspace without restarting its terminal process. (#299)
### Changed
- Bumped the client/server protocol version to 14 for `pane.move` compatibility. (#299)
- Public workspace, tab, and pane ids are now short stable handles such as `w1`, `w1:t1`, and `w1:p1`; closed tab and pane ids no longer retarget later resources. (#569)
## [0.6.10] - 2026-06-11

View File

@ -121,6 +121,9 @@ herdr pane rename <pane_id> <label>|--clear
herdr pane split [<pane_id>|--pane ID|--current] --direction right|down [--ratio FLOAT] [--cwd PATH] [--focus] [--no-focus]
herdr pane swap --direction left|right|up|down [--pane ID|--current]
herdr pane swap --source-pane ID --target-pane ID
herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]
herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]
herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]
herdr pane close <pane_id>
```

View File

@ -85,15 +85,16 @@ Raw socket method names use dot notation:
| Workspace | `workspace.create`, `workspace.list`, `workspace.get`, `workspace.focus`, `workspace.rename`, `workspace.close` |
| Worktree | `worktree.list`, `worktree.create`, `worktree.open`, `worktree.remove` |
| Tab | `tab.create`, `tab.list`, `tab.get`, `tab.focus`, `tab.rename`, `tab.close` |
| Pane | `pane.split`, `pane.swap`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Pane | `pane.split`, `pane.swap`, `pane.move`, `pane.zoom`, `pane.layout`, `pane.neighbor`, `pane.edges`, `pane.focus_direction`, `pane.resize`, `pane.list`, `pane.get`, `pane.rename`, `pane.send_text`, `pane.send_keys`, `pane.send_input`, `pane.read`, `pane.report_agent`, `pane.report_agent_session`, `pane.report_metadata`, `pane.clear_agent_authority`, `pane.release_agent`, `pane.close`, `pane.wait_for_output` |
| Agent | `agent.list`, `agent.get`, `agent.read`, `agent.explain`, `agent.send`, `agent.rename`, `agent.focus`, `agent.start` |
| Events | `events.subscribe`, `events.wait` |
| Integrations | `integration.install`, `integration.uninstall` |
Some CLI commands are conveniences around these methods. For example, `herdr agent wait` resolves an agent target and then subscribes to pane agent state events.
Pane control methods use public pane ids such as `w1:p1`. Omit `pane_id` to use
the server's active focused pane.
Pane control methods use public pane ids such as `w1:p1`. Methods whose
schema makes `pane_id` optional use the server's active focused pane when it is
omitted. `pane.move` always requires the source `pane_id`.
```json
{"id":"req_layout","method":"pane.layout","params":{"pane_id":"w1:p1"}}
@ -124,6 +125,30 @@ running processes. The response is `type: "pane_swap"` with `changed`, optional
`cross_tab`. When a tab is zoomed, swap keeps zoom active and mutates the hidden
full-tab layout.
`pane.move` moves a running pane to a different tab, a new tab, or a new
workspace:
```json
{"id":"req_move_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"tab","tab_id":"w1:t2","target_pane_id":"w1:p3","split":"right","ratio":0.5},"focus":true}}
{"id":"req_move_new_tab","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_tab","workspace_id":"w1","label":"logs"},"focus":true}}
{"id":"req_move_new_workspace","method":"pane.move","params":{"pane_id":"w1:p2","destination":{"type":"new_workspace","label":"logs","tab_label":"main"},"focus":true}}
```
Existing-tab moves require `split: "right" | "down"`. `target_pane_id` is
optional and defaults to the target tab's focused pane. Same-tab layout changes
remain `pane.swap`; moving to the source tab returns `changed: false` with
`reason: "same_tab"`. Moves involving a zoomed source or target tab return
`changed: false` with `reason: "zoomed_tab"`.
The response is `type: "pane_move"` with `changed`, optional `reason`,
`previous_pane_id`, `previous_workspace_id`, `previous_tab_id`, the moved
`pane`, optional `source_layout`, `target_layout`, optional created workspace or
tab records, optional closed workspace or tab ids, and `focused_pane_id`.
Cross-workspace moves keep the internal pane and terminal alive but assign a new
public pane id in the destination workspace. Subscribers can listen for
`pane.moved`; Herdr does not emit fake pane close/create events for the moved
terminal process.
`pane.zoom` toggles, enables, or disables zoom for the target pane's tab:
```json
@ -319,6 +344,9 @@ Subscribe to events when you need a long-lived stream:
The first response acknowledges the subscription. Later lines are pushed events.
Workspace event subscriptions include `workspace.created`, `workspace.updated`, `workspace.renamed`, `workspace.closed`, and `workspace.focused`.
Pane event subscriptions include `pane.created`, `pane.closed`, `pane.focused`,
`pane.moved`, `pane.exited`, `pane.agent_detected`,
`pane.output_matched`, and `pane.agent_status_changed`.
Use `events.wait` when you want one matching event and then a response.

View File

@ -40,6 +40,7 @@ pub(crate) fn request_changes_ui(request: &Request) -> bool {
| Method::AgentStart(_)
| Method::PaneSplit(_)
| Method::PaneSwap(_)
| Method::PaneMove(_)
| Method::PaneZoom(_)
| Method::PaneFocusDirection(_)
| Method::PaneResize(_)

View File

@ -86,6 +86,8 @@ pub enum Method {
PaneSplit(PaneSplitParams),
#[serde(rename = "pane.swap")]
PaneSwap(PaneSwapParams),
#[serde(rename = "pane.move")]
PaneMove(PaneMoveParams),
#[serde(rename = "pane.zoom")]
PaneZoom(PaneZoomParams),
#[serde(rename = "pane.layout")]
@ -395,6 +397,8 @@ pub enum Subscription {
PaneClosed {},
#[serde(rename = "pane.focused")]
PaneFocused {},
#[serde(rename = "pane.moved")]
PaneMoved {},
#[serde(rename = "pane.exited")]
PaneExited {},
#[serde(rename = "pane.agent_detected")]
@ -521,6 +525,9 @@ pub enum EventMatch {
PaneFocused {
pane_id: String,
},
PaneMoved {
pane_id: String,
},
PaneOutputChanged {
pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -555,6 +562,7 @@ pub enum EventKind {
PaneCreated,
PaneClosed,
PaneFocused,
PaneMoved,
PaneOutputChanged,
PaneExited,
PaneAgentDetected,
@ -655,6 +663,9 @@ pub enum ResponseResult {
PaneSwap {
swap: PaneSwapResult,
},
PaneMove {
move_result: PaneMoveResult,
},
PaneZoom {
zoom: PaneZoomResult,
},
@ -942,6 +953,20 @@ pub enum EventData {
pane_id: String,
workspace_id: String,
},
PaneMoved {
previous_pane_id: String,
previous_workspace_id: String,
previous_tab_id: String,
pane: Box<PaneInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_workspace: Option<WorkspaceInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
created_tab: Option<TabInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
closed_workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
closed_tab_id: Option<String>,
},
PaneOutputChanged {
pane_id: String,
workspace_id: String,

View File

@ -47,6 +47,39 @@ pub struct PaneSwapParams {
pub target_pane_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneMoveParams {
pub pane_id: String,
pub destination: PaneMoveDestination,
#[serde(default)]
pub focus: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PaneMoveDestination {
Tab {
tab_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_pane_id: Option<String>,
split: SplitDirection,
#[serde(default, skip_serializing_if = "Option::is_none")]
ratio: Option<f32>,
},
NewTab {
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
},
NewWorkspace {
#[serde(default, skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tab_label: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PaneZoomParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -274,6 +307,36 @@ pub enum PaneSwapReason {
CrossTab,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneMoveResult {
pub changed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<PaneMoveReason>,
pub previous_pane_id: String,
pub previous_workspace_id: String,
pub previous_tab_id: String,
pub pane: Box<PaneInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_layout: Option<Box<PaneLayoutSnapshot>>,
pub target_layout: Box<PaneLayoutSnapshot>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_workspace: Option<super::WorkspaceInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_tab: Option<super::TabInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub closed_tab_id: Option<String>,
pub focused_pane_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PaneMoveReason {
SameTab,
ZoomedTab,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaneZoomResult {
pub changed: bool,

View File

@ -305,6 +305,7 @@ fn api_method_name(method: &Method) -> &'static str {
Method::AgentStart(_) => "agent.start",
Method::PaneSplit(_) => "pane.split",
Method::PaneSwap(_) => "pane.swap",
Method::PaneMove(_) => "pane.move",
Method::PaneZoom(_) => "pane.zoom",
Method::PaneLayout(_) => "pane.layout",
Method::PaneNeighbor(_) => "pane.neighbor",

View File

@ -156,6 +156,10 @@ impl ActiveSubscription {
event_kind: crate::api::schema::EventKind::PaneFocused,
last_sequence: 0,
})),
Subscription::PaneMoved {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneMoved,
last_sequence: 0,
})),
Subscription::PaneExited {} => Ok(Self::Event(ActiveEventSubscription {
event_kind: crate::api::schema::EventKind::PaneExited,
last_sequence: 0,

View File

@ -2813,6 +2813,8 @@ impl AppState {
let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
self.pane_id_aliases.retain(|_, alias| *alias != pane_id);
self.public_pane_id_aliases
.retain(|_, alias| *alias != pane_id);
let should_close_workspace = {
let ws = &mut self.workspaces[ws_idx];
ws.remove_pane(pane_id)

View File

@ -754,6 +754,7 @@ impl App {
Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params),
Method::PaneMove(params) => return self.handle_pane_move(request.id, params),
Method::PaneZoom(params) => return self.handle_pane_zoom(request.id, params),
Method::PaneLayout(params) => return self.handle_pane_layout(request.id, params),
Method::PaneNeighbor(params) => return self.handle_pane_neighbor(request.id, params),

File diff suppressed because it is too large Load Diff

View File

@ -88,6 +88,10 @@ impl App {
}
pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
if let Some(alias) = self.state.public_pane_id_aliases.get(id).copied() {
return self.find_pane(alias).map(|(ws_idx, _)| (ws_idx, alias));
}
if let Some(rest) = id.strip_prefix("p_") {
if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') {
let ws_idx = self.parse_workspace_id(ws_raw)?;

View File

@ -405,6 +405,7 @@ impl App {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
pane_id_aliases: std::collections::HashMap::new(),
public_pane_id_aliases: std::collections::HashMap::new(),
workspaces,
active,
previous_pane_focus: None,

View File

@ -1249,6 +1249,7 @@ pub struct AppState {
/// Terminal ids whose size is currently owned by a direct attach client.
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
pub(crate) pane_id_aliases: std::collections::HashMap<u32, PaneId>,
pub(crate) public_pane_id_aliases: std::collections::HashMap<String, PaneId>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
@ -1587,6 +1588,7 @@ impl AppState {
terminals: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
pane_id_aliases: std::collections::HashMap::new(),
public_pane_id_aliases: std::collections::HashMap::new(),
workspaces: Vec::new(),
active: None,
previous_pane_focus: None,

View File

@ -1,9 +1,10 @@
use crate::api::schema::{
Method, PaneDirection, PaneEdgesParams, PaneFocusDirectionParams, PaneLayoutParams,
PaneListParams, PaneNeighborParams, PaneReadParams, PaneReleaseAgentParams, PaneRenameParams,
PaneReportAgentParams, PaneReportAgentSessionParams, PaneReportMetadataParams,
PaneResizeParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams,
PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams, ReadFormat, ReadSource, Request,
PaneListParams, PaneMoveDestination, PaneMoveParams, PaneNeighborParams, PaneReadParams,
PaneReleaseAgentParams, PaneRenameParams, PaneReportAgentParams, PaneReportAgentSessionParams,
PaneReportMetadataParams, PaneResizeParams, PaneSendInputParams, PaneSendKeysParams,
PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneTarget, PaneZoomMode, PaneZoomParams,
ReadFormat, ReadSource, Request, SplitDirection,
};
pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
@ -25,6 +26,7 @@ pub(super) fn run_pane_command(args: &[String]) -> std::io::Result<i32> {
"rename" => pane_rename(&args[1..]),
"split" => pane_split(&args[1..]),
"swap" => pane_swap(&args[1..]),
"move" => pane_move(&args[1..]),
"close" => pane_close(&args[1..]),
"send-text" => pane_send_text(&args[1..]),
"send-keys" => pane_send_keys(&args[1..]),
@ -580,6 +582,170 @@ fn pane_swap(args: &[String]) -> std::io::Result<i32> {
})?)
}
fn pane_move(args: &[String]) -> std::io::Result<i32> {
let params = match parse_pane_move_args(args) {
Ok(params) => params,
Err(message) => {
eprintln!("{message}");
return Ok(2);
}
};
super::print_response(&super::send_request(&Request {
id: "cli:pane:move".into(),
method: Method::PaneMove(params),
})?)
}
fn parse_pane_move_args(args: &[String]) -> Result<PaneMoveParams, String> {
let Some(raw_pane_id) = args.first() else {
return Err(pane_move_usage());
};
if raw_pane_id.starts_with('-') {
return Err(pane_move_usage());
}
let pane_id = super::normalize_pane_id(raw_pane_id);
let mut tab_id = None;
let mut new_tab = false;
let mut new_workspace = false;
let mut workspace_id = None;
let mut target_pane_id = None;
let mut split = None;
let mut ratio = None;
let mut label = None;
let mut tab_label = None;
let mut focus = true;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--tab" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --tab".into());
};
tab_id = Some(super::normalize_tab_id(value));
index += 2;
}
"--new-tab" => {
new_tab = true;
index += 1;
}
"--new-workspace" => {
new_workspace = true;
index += 1;
}
"--workspace" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --workspace".into());
};
workspace_id = Some(super::normalize_workspace_id(value));
index += 2;
}
"--target-pane" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --target-pane".into());
};
target_pane_id = Some(super::normalize_pane_id(value));
index += 2;
}
"--split" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --split".into());
};
split = Some(parse_split_direction(value)?);
index += 2;
}
"--ratio" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --ratio".into());
};
let parsed = value
.parse::<f32>()
.map_err(|_| format!("invalid ratio: {value}"))?;
if !parsed.is_finite() {
return Err(format!("invalid ratio: {value}"));
}
ratio = Some(parsed);
index += 2;
}
"--label" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --label".into());
};
label = Some(value.clone());
index += 2;
}
"--tab-label" => {
let Some(value) = args.get(index + 1) else {
return Err("missing value for --tab-label".into());
};
tab_label = Some(value.clone());
index += 2;
}
"--focus" => {
focus = true;
index += 1;
}
"--no-focus" => {
focus = false;
index += 1;
}
other => return Err(format!("unknown option: {other}")),
}
}
let destination_count =
usize::from(tab_id.is_some()) + usize::from(new_tab) + usize::from(new_workspace);
if destination_count != 1 {
return Err(pane_move_usage());
}
let destination = if let Some(tab_id) = tab_id {
let Some(split) = split else {
return Err(pane_move_usage());
};
if workspace_id.is_some()
|| new_tab
|| new_workspace
|| label.is_some()
|| tab_label.is_some()
{
return Err(pane_move_usage());
}
PaneMoveDestination::Tab {
tab_id,
target_pane_id,
split,
ratio,
}
} else if new_tab {
if split.is_some() || target_pane_id.is_some() || new_workspace || tab_label.is_some() {
return Err(pane_move_usage());
}
PaneMoveDestination::NewTab {
workspace_id,
label,
}
} else {
if split.is_some() || target_pane_id.is_some() || workspace_id.is_some() || new_tab {
return Err(pane_move_usage());
}
PaneMoveDestination::NewWorkspace { label, tab_label }
};
Ok(PaneMoveParams {
pane_id,
destination,
focus,
})
}
fn pane_move_usage() -> String {
"usage: herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]\n herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--focus|--no-focus]"
.into()
}
fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> {
let mut pane_id = None;
let mut direction = None;
@ -647,6 +813,16 @@ fn parse_pane_swap_args(args: &[String]) -> Result<PaneSwapParams, String> {
}
}
fn parse_split_direction(value: &str) -> Result<SplitDirection, String> {
match value {
"right" => Ok(SplitDirection::Right),
"down" => Ok(SplitDirection::Down),
_ => Err(format!(
"invalid split direction: {value} (expected right or down)"
)),
}
}
fn parse_pane_direction(value: &str) -> Result<PaneDirection, String> {
match value {
"left" => Ok(PaneDirection::Left),
@ -1188,6 +1364,9 @@ fn print_pane_help() {
);
eprintln!(" herdr pane swap --direction left|right|up|down [--pane ID|--current]");
eprintln!(" herdr pane swap --source-pane ID --target-pane ID");
eprintln!(" herdr pane move <pane_id> --tab <tab_id> --split right|down [--target-pane ID] [--ratio FLOAT] [--focus|--no-focus]");
eprintln!(" herdr pane move <pane_id> --new-tab [--workspace ID] [--label TEXT] [--focus|--no-focus]");
eprintln!(" herdr pane move <pane_id> --new-workspace [--label TEXT] [--tab-label TEXT] [--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 ...]");
@ -1279,6 +1458,53 @@ mod tests {
assert!(err.contains("usage: herdr pane swap"));
}
#[test]
fn parse_pane_move_args_accepts_existing_tab_destination() {
let params = parse_pane_move_args(&args(&[
"issue-1",
"--tab",
"issue:2",
"--split",
"right",
"--target-pane",
"issue-3",
"--ratio",
"0.25",
"--no-focus",
]))
.unwrap();
assert_eq!(params.pane_id, "issue-1");
assert!(!params.focus);
assert_eq!(
params.destination,
PaneMoveDestination::Tab {
tab_id: "issue:2".into(),
target_pane_id: Some("issue-3".into()),
split: SplitDirection::Right,
ratio: Some(0.25),
}
);
}
#[test]
fn parse_pane_move_args_rejects_target_pane_without_tab() {
let err =
parse_pane_move_args(&args(&["issue-1", "--target-pane", "issue-2"])).unwrap_err();
assert!(err.contains("usage: herdr pane move"));
}
#[test]
fn parse_pane_move_args_rejects_non_finite_ratio() {
let err = parse_pane_move_args(&args(&[
"issue-1", "--tab", "issue:2", "--split", "right", "--ratio", "NaN",
]))
.unwrap_err();
assert!(err.contains("invalid ratio"));
}
#[test]
fn parse_pane_zoom_args_defaults_to_current_toggle() {
let params = parse_pane_zoom_args(&args(&[])).unwrap();

View File

@ -132,6 +132,30 @@ impl TileLayout {
new_id
}
/// Insert an existing pane id next to a target pane without allocating a new
/// pane or spawning a terminal runtime.
pub fn insert_pane_near(
&mut self,
target: PaneId,
moved: PaneId,
direction: Direction,
ratio: f32,
) -> bool {
if target == moved {
return false;
}
let ids = self.pane_ids();
if !ids.contains(&target) || ids.contains(&moved) {
return false;
}
let placeholder = PaneId::from_raw(0);
let old = std::mem::replace(&mut self.root, Node::Pane(placeholder));
self.root = split_at(old, target, direction, moved, valid_split_ratio(ratio));
self.focus = moved;
true
}
/// Close the focused pane. Returns false if it's the last pane.
pub fn close_focused(&mut self) -> bool {
if self.pane_count() <= 1 {
@ -708,6 +732,22 @@ mod tests {
assert_eq!(layout.focused(), before_focus);
}
#[test]
fn insert_existing_pane_near_target_preserves_existing_ids_and_focuses_moved_pane() {
let (mut layout, root) = TileLayout::new();
let moved = pane(99);
assert!(layout.insert_pane_near(root, moved, Direction::Horizontal, 0.25));
assert_eq!(layout.pane_count(), 2);
assert_eq!(layout.pane_ids(), vec![root, moved]);
assert_eq!(layout.focused(), moved);
let splits = split_snapshot(&layout);
assert_eq!(splits, vec![(Direction::Horizontal, 0.25)]);
assert_eq!(pane_rect(&layout, root), Rect::new(0, 0, 25, 40));
assert_eq!(pane_rect(&layout, moved), Rect::new(25, 0, 75, 40));
}
#[test]
fn split_focused_with_ratio_sets_new_split_ratio() {
let (mut layout, root) = TileLayout::new();

View File

@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
pub const PROTOCOL_VERSION: u32 = 13;
pub const PROTOCOL_VERSION: u32 = 14;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.

View File

@ -20,6 +20,7 @@ mod tab;
#[cfg(test)]
use self::git::git_ahead_behind;
pub(crate) use self::tab::MovedPane;
pub use self::{
git::{
derive_label_from_cwd, git_branch, git_space_metadata, git_status_cache_key,
@ -179,6 +180,48 @@ impl DerefMut for Workspace {
}
impl Workspace {
fn adjust_active_tab_after_removal(&mut self, removed_idx: usize) {
if self.tabs.is_empty() {
self.active_tab = 0;
} else if self.active_tab >= self.tabs.len() {
self.active_tab = self.tabs.len() - 1;
} else if removed_idx <= self.active_tab && self.active_tab > 0 {
self.active_tab -= 1;
}
}
pub(crate) fn from_existing_pane(
label: Option<String>,
tab_label: Option<String>,
identity_cwd: PathBuf,
moved: MovedPane,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> Self {
let id = generate_workspace_id();
let root_pane = moved.pane_id;
let tab = Tab::from_existing_pane(1, tab_label, moved, events, render_notify, render_dirty);
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(root_pane, 1);
Self {
id,
custom_name: label,
identity_cwd: identity_cwd.clone(),
cached_git_branch: git_branch(&identity_cwd),
cached_git_ahead_behind: None,
cached_git_space: git_space_metadata(&identity_cwd),
worktree_space: None,
public_pane_numbers,
next_public_pane_number: 2,
next_public_tab_number: 2,
tabs: vec![tab],
active_tab: 0,
#[cfg(test)]
test_runtimes: HashMap::new(),
}
}
pub fn new(
initial_cwd: PathBuf,
rows: u16,
@ -710,6 +753,85 @@ impl Workspace {
false
}
pub(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option<TakenPane> {
let tab_idx = self.find_tab_index_for_pane(pane_id)?;
let pane_count = self.tabs[tab_idx].layout.pane_count();
if pane_count <= 1 {
let mut tab = self.tabs.remove(tab_idx);
let moved = tab.take_pane_for_move(pane_id)?;
self.adjust_active_tab_after_removal(tab_idx);
return Some(TakenPane {
moved,
removed_tab_idx: Some(tab_idx),
workspace_empty: self.tabs.is_empty(),
});
}
let moved = self.tabs[tab_idx].take_pane_for_move(pane_id)?;
Some(TakenPane {
moved,
removed_tab_idx: None,
workspace_empty: false,
})
}
pub(crate) fn insert_moved_pane_into_tab(
&mut self,
tab_idx: usize,
target_pane_id: PaneId,
moved: MovedPane,
direction: Direction,
ratio: f32,
) -> Result<PaneId, MovedPane> {
let pane_id = moved.pane_id;
let Some(tab) = self.tabs.get_mut(tab_idx) else {
return Err(moved);
};
tab.insert_existing_pane(target_pane_id, moved, direction, ratio)?;
if !self.public_pane_numbers.contains_key(&pane_id) {
self.register_new_pane_with_number(pane_id, self.next_public_pane_number);
}
Ok(pane_id)
}
pub(crate) fn create_tab_from_existing_pane(
&mut self,
moved: MovedPane,
label: Option<String>,
fallback_events: mpsc::Sender<AppEvent>,
fallback_render_notify: Arc<Notify>,
fallback_render_dirty: Arc<AtomicBool>,
) -> usize {
let number = self.next_public_tab_number;
self.next_public_tab_number += 1;
let pane_id = moved.pane_id;
let (events, render_notify, render_dirty) = self
.active_tab()
.map(|tab| {
(
tab.events.clone(),
tab.render_notify.clone(),
tab.render_dirty.clone(),
)
})
.unwrap_or((
fallback_events,
fallback_render_notify,
fallback_render_dirty,
));
let tab =
Tab::from_existing_pane(number, label, moved, events, render_notify, render_dirty);
if !self.public_pane_numbers.contains_key(&pane_id) {
self.register_new_pane_with_number(pane_id, self.next_public_pane_number);
}
self.tabs.push(tab);
self.tabs.len() - 1
}
pub(crate) fn unregister_moved_pane(&mut self, pane_id: PaneId) {
self.unregister_pane(pane_id);
}
pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
self.public_pane_numbers.get(&pane_id).copied()
}
@ -866,6 +988,12 @@ impl Workspace {
}
}
pub(crate) struct TakenPane {
pub moved: MovedPane,
pub removed_tab_idx: Option<usize>,
pub workspace_empty: bool,
}
#[cfg(test)]
impl Workspace {
pub(crate) fn test_new(name: &str) -> Self {
@ -1037,6 +1165,24 @@ mod tests {
assert_eq!(ws.public_tab_number_for_pane(fourth_root), Some(4));
}
#[test]
fn failed_moved_pane_insert_returns_pane_for_recovery() {
let mut source = Workspace::test_new("source");
let source_pane = source.tabs[0].root_pane;
let taken = source
.take_pane_for_move(source_pane)
.expect("source pane should be movable");
let mut target = Workspace::test_new("target");
let missing_target = PaneId::alloc();
let recovered = target
.insert_moved_pane_into_tab(0, missing_target, taken.moved, Direction::Horizontal, 0.5)
.expect_err("invalid target should return the moved pane");
assert_eq!(recovered.pane_id, source_pane);
assert!(!target.tabs[0].panes.contains_key(&source_pane));
}
#[test]
fn workspace_identity_follows_first_tab_root_pane_cwd() {
let mut ws = Workspace::test_new("ignored");

View File

@ -7,12 +7,17 @@ use ratatui::layout::Direction;
use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
use crate::layout::{Node, PaneId, TileLayout};
use crate::pane::PaneState;
use crate::terminal::{TerminalId, TerminalRuntime, TerminalRuntimeRegistry, TerminalState};
pub(crate) type DetachedPane = (PaneId, TerminalId);
pub(crate) struct MovedPane {
pub pane_id: PaneId,
pub pane_state: PaneState,
}
pub struct NewPane {
pub pane_id: PaneId,
pub terminal: TerminalState,
@ -401,6 +406,79 @@ impl Tab {
self.detach_pane(pane_id)
}
pub(crate) fn from_existing_pane(
number: usize,
custom_name: Option<String>,
moved: MovedPane,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> Self {
let mut panes = HashMap::new();
let pane_id = moved.pane_id;
panes.insert(pane_id, moved.pane_state);
Self {
custom_name,
number,
root_pane: pane_id,
layout: TileLayout::from_saved(Node::Pane(pane_id), pane_id),
panes,
#[cfg(test)]
runtimes: HashMap::new(),
zoomed: false,
events,
render_notify,
render_dirty,
}
}
pub(crate) fn take_pane_for_move(&mut self, pane_id: PaneId) -> Option<MovedPane> {
if !self.panes.contains_key(&pane_id) {
return None;
}
if self.layout.pane_count() > 1 {
let next_root = self.promoted_root_if_needed(pane_id);
if self.layout.focused() == pane_id {
self.layout.close_focused();
} else {
let prev_focus = self.layout.focused();
self.layout.focus_pane(pane_id);
self.layout.close_focused();
self.layout.focus_pane(prev_focus);
}
if let Some(next_root) = next_root {
self.root_pane = next_root;
}
}
let pane_state = self.panes.remove(&pane_id)?;
self.zoomed = false;
Some(MovedPane {
pane_id,
pane_state,
})
}
pub(crate) fn insert_existing_pane(
&mut self,
target_pane_id: PaneId,
moved: MovedPane,
direction: Direction,
ratio: f32,
) -> Result<PaneId, MovedPane> {
if !self
.layout
.insert_pane_near(target_pane_id, moved.pane_id, direction, ratio)
{
return Err(moved);
}
let pane_id = moved.pane_id;
self.panes.insert(pane_id, moved.pane_state);
self.zoomed = false;
Ok(pane_id)
}
fn detach_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> {
if self.layout.pane_count() <= 1 {
return None;

View File

@ -304,7 +304,7 @@ fn ping_over_socket_returns_version() {
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
// Intentionally hardcoded so wire protocol bumps require updating this test.
// Changing this value means old clients/servers are no longer compatible.
assert_eq!(value["result"]["protocol"], 13);
assert_eq!(value["result"]["protocol"], 14);
cleanup_spawned_herdr(child, base);
}

View File

@ -1273,7 +1273,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {full_stdout}"
);
assert!(
full_stdout.contains(" protocol: 13"),
full_stdout.contains(" protocol: 14"),
"stdout: {full_stdout}"
);
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
@ -1306,7 +1306,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {server_stdout}"
);
assert!(
server_stdout.contains("protocol: 13"),
server_stdout.contains("protocol: 14"),
"stdout: {server_stdout}"
);
@ -1318,7 +1318,7 @@ fn status_commands_report_client_and_server_versions() {
"stdout: {client_stdout}"
);
assert!(
client_stdout.contains("protocol: 13"),
client_stdout.contains("protocol: 14"),
"stdout: {client_stdout}"
);
assert!(
@ -1328,7 +1328,7 @@ fn status_commands_report_client_and_server_versions() {
let full_json = run_cli_json(&socket_path, &["status", "--json"]);
assert_eq!(full_json["client"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(full_json["client"]["protocol"], 13);
assert_eq!(full_json["client"]["protocol"], 14);
assert_eq!(full_json["server"]["status"], "running");
assert_eq!(full_json["server"]["running"], true);
assert_eq!(full_json["server"]["compatible"], true);
@ -1342,12 +1342,12 @@ fn status_commands_report_client_and_server_versions() {
let server_json = run_cli_json(&socket_path, &["status", "server", "--json"]);
assert_eq!(server_json["status"], "running");
assert_eq!(server_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(server_json["protocol"], 13);
assert_eq!(server_json["protocol"], 14);
assert_eq!(server_json["compatible"], true);
let client_json = run_cli_json(&socket_path, &["status", "client", "--json"]);
assert_eq!(client_json["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(client_json["protocol"], 13);
assert_eq!(client_json["protocol"], 14);
assert!(client_json["binary"]
.as_str()
.is_some_and(|path| !path.is_empty()));

View File

@ -274,8 +274,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13, "server should report protocol version 13");
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14, "server should report protocol version 14");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@ -342,8 +342,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
stream
@ -391,8 +391,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@ -445,8 +445,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@ -504,8 +504,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@ -736,8 +736,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
read_next_frame_payload(&mut stream, Duration::from_secs(10))
@ -783,8 +783,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -901,8 +901,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@ -1000,8 +1000,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).

View File

@ -687,7 +687,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
client_handshake(&mut client_a, 13, 100, 30);
client_handshake(&mut client_a, 14, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@ -724,7 +724,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
client_handshake(&mut client_b, 13, 80, 24);
client_handshake(&mut client_b, 14, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@ -780,7 +780,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
client_handshake(&mut client_a, 13, 100, 30);
client_handshake(&mut client_a, 14, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@ -833,7 +833,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
client_handshake(&mut client_b, 13, 80, 24);
client_handshake(&mut client_b, 14, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@ -878,7 +878,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
client_handshake(&mut client, 13, 100, 30);
client_handshake(&mut client, 14, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@ -941,9 +941,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
client_handshake(&mut client_a, 13, 110, 30);
client_handshake(&mut client_a, 14, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
client_handshake(&mut client_b, 13, 100, 30);
client_handshake(&mut client_b, 14, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@ -1112,7 +1112,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
client_handshake(&mut reconnect_client, 13, 80, 24);
client_handshake(&mut reconnect_client, 14, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"

View File

@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
client_handshake(&mut stream_a, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
client_handshake(&mut stream_b, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_b, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
let (version, error) =
client_handshake(&mut stream, 13, 120, 40).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream, 14, 120, 40).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
drain_messages(&mut stream);
@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
client_handshake(&mut stream_a, 13, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_a, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
client_handshake(&mut stream_b, 13, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 13);
client_handshake(&mut stream_b, 14, 80, 24).expect("reattach handshake should succeed");
assert_eq!(version, 14);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.

View File

@ -566,7 +566,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
client_handshake(&mut stream, 13, cols, rows).expect("handshake should succeed");
client_handshake(&mut stream, 14, cols, rows).expect("handshake should succeed");
stream
}

View File

@ -595,9 +595,9 @@ fn client_handshake_succeeds() {
// Send Hello with the current protocol version, 80 cols, 24 rows.
let (version, error) =
client_handshake(&mut stream, 13, 80, 24).expect("handshake should succeed");
client_handshake(&mut stream, 14, 80, 24).expect("handshake should succeed");
assert_eq!(version, 13, "server should report protocol version 13");
assert_eq!(version, 14, "server should report protocol version 14");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@ -626,7 +626,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
assert_eq!(version, 13, "server should report its version 13");
assert_eq!(version, 14, "server should report its version 14");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@ -651,10 +651,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) = client_handshake(&mut stream, 13, 0, 0)
let (version, error) = client_handshake(&mut stream, 14, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
assert_eq!(version, 13);
assert_eq!(version, 14);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@ -714,9 +714,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
let (version, error) = client_handshake(&mut good_stream, 13, 80, 24)
let (version, error) = client_handshake(&mut good_stream, 14, 80, 24)
.expect("proper handshake should still work after no-hello client");
assert_eq!(version, 13);
assert_eq!(version, 14);
assert!(error.is_none());
// API should still work.