use bytes::Bytes; use crate::api::schema::{ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneCopyMotion, PaneCopyMotionParams, PaneCopySearchDirection, PaneCopySearchParams, PaneCurrentParams, PaneDirection, PaneEdgesParams, PaneEdgesResult, PaneFocusDirectionParams, 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, PaneScrollParams, PaneSelectionReadParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneSwapParams, PaneSwapReason, PaneSwapResult, PaneTarget, PaneTextPoint, PaneTextRange, PaneZoomMode, PaneZoomParams, PaneZoomReason, PaneZoomResult, ResponseResult, }; use crate::app::actions::{PaneZoomCommand, PaneZoomNoopReason}; use crate::app::App; #[cfg(test)] use crate::app::Mode; use crate::layout::{find_in_direction, NavDirection, PaneId}; use super::super::api_helpers::{ detect_state_from_api, encode_api_keys, normalize_metadata_source, normalize_metadata_tokens, normalize_metadata_ttl, normalize_reported_agent_label, MAX_METADATA_TOKEN_KEYS_PER_RESOURCE, }; #[cfg(test)] use super::super::api_helpers::{METADATA_SOURCE_MAX_CHARS, METADATA_TTL_MAX_MS}; use super::responses::{encode_error, encode_success}; impl App { pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String { let target = if let Some(target_pane_id) = params.target_pane_id.as_deref() { self.parse_pane_id(target_pane_id) } else if let Some(workspace_id) = params.workspace_id.as_deref() { self.parse_workspace_id(workspace_id).and_then(|ws_idx| { let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; Some((ws_idx, pane_id)) }) } else { self.state.active.and_then(|ws_idx| { let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; Some((ws_idx, pane_id)) }) }; let Some((ws_idx, target_pane_id)) = target else { return encode_error(id, "pane_not_found", "pane not found"); }; let extra_env = match super::env::normalize_launch_env(params.env) { Ok(env) => env, Err((code, message)) => return encode_error(id, &code, message), }; let (rows, cols) = self.state.estimate_pane_size(); let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| { let follow_cwd = self.launch_cwd_for_pane_in_workspace(ws_idx, target_pane_id); Some(self.resolve_new_terminal_cwd(follow_cwd)) }); let default_shell = self.state.default_shell.clone(); let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes; let host_terminal_theme = self.state.host_terminal_theme; let host_terminal_appearance = self.state.host_terminal_appearance; let previous_focus = self.state.current_pane_focus_target(); let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return encode_error(id, "pane_not_found", "pane not found"); }; let direction = match params.direction { crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, }; let shell_config = crate::pane::PaneShellConfig::new(&default_shell, self.state.shell_mode); let split_result = match params.ratio { Some(ratio) => ws.split_pane_with_ratio( target_pane_id, direction, ratio, rows, cols, split_cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, extra_env, params.focus, ), None => ws.split_pane( target_pane_id, direction, rows, cols, split_cwd, scrollback_limit_bytes, host_terminal_theme, host_terminal_appearance, shell_config, extra_env, params.focus, ), }; let (target_tab_idx, new_pane) = match split_result { Some(Ok(result)) => result, 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 .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); self.state.mode = crate::app::Mode::Terminal; } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); self.state .remove_alias_shadowed_by_new_pane(new_pane.pane_id); self.state .terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); self.schedule_session_save(); let pane = self.pane_info(ws_idx, new_pane.pane_id).unwrap(); self.emit_event(EventEnvelope { event: EventKind::PaneCreated, data: EventData::PaneCreated { pane: pane.clone() }, }); self.emit_layout_updated_event(ws_idx, target_tab_idx); encode_success(id, ResponseResult::PaneInfo { pane }) } pub(super) fn handle_pane_list(&mut self, id: String, params: PaneListParams) -> String { match self.collect_panes_for_workspace(params.workspace_id.as_deref()) { Ok(panes) => encode_success(id, ResponseResult::PaneList { panes }), Err((code, message)) => encode_error(id, &code, message), } } pub(super) fn handle_pane_current(&mut self, id: String, params: PaneCurrentParams) -> String { let target = match params.caller_pane_id.as_deref() { Some(caller_pane_id) => self.parse_pane_id(caller_pane_id), None => self.resolve_optional_pane(None), }; let Some((ws_idx, pane_id)) = target else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; encode_success(id, ResponseResult::PaneCurrent { pane }) } pub(super) fn handle_pane_get(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); }; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); }; encode_success(id, ResponseResult::PaneInfo { pane }) } pub(super) fn handle_pane_clear(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); }; match runtime.clear_screen() { Ok(()) => encode_success(id, ResponseResult::Ok {}), Err(err) => encode_error(id, "pane_clear_failed", err.to_string()), } } pub(super) fn handle_pane_scroll(&mut self, id: String, params: PaneScrollParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; runtime.set_scroll_offset_from_bottom( usize::try_from(params.offset_from_bottom).unwrap_or(usize::MAX), ); let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; encode_success(id, ResponseResult::PaneInfo { pane }) } pub(super) fn handle_pane_edit_scrollback(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); }; let is_focused = self.state.active == Some(ws_idx) && self .state .workspaces .get(ws_idx) .and_then(crate::workspace::Workspace::focused_pane_id) == Some(pane_id); if !is_focused { return encode_error(id, "stale_pane_target", "pane is no longer focused"); } match self.open_focused_scrollback_in_editor() { Ok(()) => encode_success(id, ResponseResult::Ok {}), Err(err) => encode_error(id, "scrollback_editor_failed", err.to_string()), } } pub(crate) fn pane_selection_text( &self, params: &PaneSelectionReadParams, ) -> Result { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return Err(( "pane_not_found", format!("pane not found: {}", params.pane_id), )); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return Err(( "pane_not_found", format!("pane not found: {}", params.pane_id), )); }; let before = runtime.content_seq(); if params .content_revision .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) { return Err(("stale_content", "pane content changed".to_owned())); } let selection = crate::selection::Selection::absolute_range( pane_id, (params.anchor.row, params.anchor.col), (params.cursor.row, params.cursor.col), ); let Some(text) = runtime.extract_selection(&selection) else { return Err(( "selection_unavailable", "selection text is unavailable".to_owned(), )); }; if params.content_revision.is_some() && runtime.content_seq() != before { return Err(("stale_content", "pane content changed".to_owned())); } Ok(text) } pub(super) fn handle_pane_selection_read( &mut self, id: String, params: PaneSelectionReadParams, ) -> String { match self.pane_selection_text(¶ms) { Ok(text) => encode_success( id, ResponseResult::PaneSelection { pane_id: params.pane_id, text, }, ), Err((code, message)) => encode_error(id, code, message), } } pub(super) fn handle_pane_copy_motion( &mut self, id: String, params: PaneCopyMotionParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let before = runtime.content_seq(); if params .content_revision .is_some_and(|revision| revision != before || !before.is_multiple_of(2)) { return encode_error(id, "stale_content", "pane content changed"); } let target = match params.motion { PaneCopyMotion::LineEnd | PaneCopyMotion::FirstNonBlank => { let width = runtime .terminal_dimensions() .map_or(1, |(cols, _)| cols.max(1)); let selection = crate::selection::Selection::absolute_range( pane_id, (params.cursor.row, 0), (params.cursor.row, width.saturating_sub(1)), ); let Some(text) = runtime.extract_selection(&selection) else { return encode_error( id, "copy_motion_unavailable", "terminal row is unavailable", ); }; let col = match params.motion { PaneCopyMotion::LineEnd => { crate::copy_mode::last_character_col(&text).unwrap_or(0) } PaneCopyMotion::FirstNonBlank => { crate::copy_mode::first_non_blank_col(&text).unwrap_or(0) } _ => unreachable!(), }; crate::pane::TerminalTextPoint { row: params.cursor.row, col: col.min(width.saturating_sub(1)), } } PaneCopyMotion::NextWordStart | PaneCopyMotion::PreviousWordStart | PaneCopyMotion::NextWordEnd | PaneCopyMotion::NextBigWordStart | PaneCopyMotion::PreviousBigWordStart | PaneCopyMotion::NextBigWordEnd => { let motion = match params.motion { PaneCopyMotion::NextWordStart => crate::pane::TerminalWordMotion::NextStart, PaneCopyMotion::PreviousWordStart => { crate::pane::TerminalWordMotion::PreviousStart } PaneCopyMotion::NextWordEnd => crate::pane::TerminalWordMotion::NextEnd, PaneCopyMotion::NextBigWordStart => { crate::pane::TerminalWordMotion::NextBigStart } PaneCopyMotion::PreviousBigWordStart => { crate::pane::TerminalWordMotion::PreviousBigStart } PaneCopyMotion::NextBigWordEnd => crate::pane::TerminalWordMotion::NextBigEnd, _ => unreachable!(), }; runtime .word_motion_target(params.cursor.row, params.cursor.col, motion) .unwrap_or(crate::pane::TerminalTextPoint { row: params.cursor.row, col: params.cursor.col, }) } PaneCopyMotion::PreviousParagraph | PaneCopyMotion::NextParagraph => runtime .paragraph_motion_target( params.cursor.row, if params.motion == PaneCopyMotion::PreviousParagraph { -1 } else { 1 }, ) .map(|target| crate::pane::TerminalTextPoint { row: target.row, col: params.cursor.col, }) .unwrap_or(crate::pane::TerminalTextPoint { row: params.cursor.row, col: params.cursor.col, }), }; let after = runtime.content_seq(); if params.content_revision.is_some() && after != before { return encode_error(id, "stale_content", "pane content changed"); } encode_success( id, ResponseResult::PaneCopyMotion { pane_id: params.pane_id, cursor: crate::api::schema::PaneTextPoint { row: target.row, col: target.col, }, content_revision: after, }, ) } pub(super) fn handle_pane_copy_search( &mut self, id: String, params: PaneCopySearchParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; const MAX_QUERY_BYTES: usize = 4096; const MAX_RETURNED_MATCHES: usize = 1024; if params.query.len() > MAX_QUERY_BYTES { return encode_error(id, "query_too_large", "copy search query is too large"); } let before = runtime.content_seq(); if before != params.content_revision || !before.is_multiple_of(2) { return encode_error(id, "stale_content", "pane content changed"); } let cursor = crate::pane::TerminalTextPoint { row: params.cursor.row, col: params.cursor.col, }; let previous = params.previous.map(|previous| { ( crate::pane::TerminalTextPoint { row: previous.start.row, col: previous.start.col, }, crate::pane::TerminalTextPoint { row: previous.end.row, col: previous.end.col, }, ) }); let direction = match params.direction { PaneCopySearchDirection::Forward => crate::pane::TerminalSearchDirection::Forward, PaneCopySearchDirection::Backward => crate::pane::TerminalSearchDirection::Backward, }; let result = runtime.search_text_window( ¶ms.query, params.query.chars().any(char::is_uppercase), direction, cursor, previous, MAX_RETURNED_MATCHES, ); let after = runtime.content_seq(); if after != before || !after.is_multiple_of(2) { return encode_error(id, "stale_content", "pane content changed"); } let matches = result .matches .into_iter() .map(|text_match| PaneTextRange { start: PaneTextPoint { row: text_match.start.row, col: text_match.start.col, }, end: PaneTextPoint { row: text_match.end.row, col: text_match.end.col, }, }) .collect(); encode_success( id, ResponseResult::PaneCopySearch { pane_id: params.pane_id, content_revision: after, matches, total: u64::try_from(result.total).unwrap_or(u64::MAX), current: result.current.and_then(|index| u32::try_from(index).ok()), current_global: result .current_global .and_then(|index| u64::try_from(index).ok()), }, ) } pub(super) fn handle_pane_focus(&mut self, id: String, target: PaneTarget) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return pane_not_found(id, &target.pane_id); }; let Some(_tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found(id, &target.pane_id); }; self.state.focus_pane_in_workspace(ws_idx, pane_id); self.state.mark_active_tab_seen(); self.state.mode = crate::app::Mode::Terminal; let Some(pane) = self.pane_info(ws_idx, pane_id) else { return pane_not_found(id, &target.pane_id); }; encode_success(id, ResponseResult::PaneInfo { pane }) } pub(super) fn handle_pane_layout(&mut self, id: String, params: PaneLayoutParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; encode_success(id, ResponseResult::PaneLayout { layout }) } pub(super) fn handle_pane_process_info( &mut self, id: String, params: PaneProcessInfoParams, ) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some((runtime, _workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let shell_pid = runtime.child_pid(); let foreground_job = shell_pid.and_then(crate::detect::foreground_job); let foreground_process_group_id = foreground_job.as_ref().map(|job| job.process_group_id); let foreground_processes = foreground_job .map(|job| { job.processes .into_iter() .map(|process| PaneProcessInfoProcess { pid: process.pid, name: process.name, argv0: process.argv0, argv: process.argv, cmdline: process.cmdline, cwd: crate::platform::process_cwd(process.pid) .map(|cwd| cwd.display().to_string()), }) .collect() }) .unwrap_or_default(); encode_success( id, ResponseResult::PaneProcessInfo { process_info: PaneProcessInfo { pane_id: public_pane_id, shell_pid, foreground_process_group_id, tty: None, foreground_processes, }, }, ) } pub(super) fn handle_pane_neighbor( &mut self, id: String, params: PaneNeighborParams, ) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(source_public_id) = self.public_pane_id(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let neighbor_pane_id = self .directional_pane_target(ws_idx, tab_idx, pane_id, params.direction) .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id)); let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; encode_success( id, ResponseResult::PaneNeighbor { neighbor: PaneNeighborResult { pane_id: source_public_id, direction: params.direction, neighbor_pane_id, layout, }, }, ) } pub(super) fn handle_pane_edges(&mut self, id: String, params: PaneEdgesParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(tab) = self .state .workspaces .get(ws_idx) .and_then(|ws| ws.tabs.get(tab_idx)) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let area = self.state.view.terminal_area; let Some(info) = tab .layout .panes(area) .into_iter() .find(|info| info.id == pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; encode_success( id, ResponseResult::PaneEdges { edges: PaneEdgesResult { pane_id: pane_public_id, left: info.rect.x <= area.x, right: info.rect.x + info.rect.width >= area.x + area.width, up: info.rect.y <= area.y, down: info.rect.y + info.rect.height >= area.y + area.height, layout, }, }, ) } pub(super) fn handle_pane_focus_direction( &mut self, id: String, params: PaneFocusDirectionParams, ) -> String { let Some((ws_idx, source_pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id) else { return pane_not_found( id, &self .public_pane_id(ws_idx, source_pane_id) .unwrap_or_default(), ); }; let Some(source_public_id) = self.public_pane_id(ws_idx, source_pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let target = self.directional_pane_target(ws_idx, tab_idx, source_pane_id, params.direction); let reason = target .is_none() .then_some(PaneFocusDirectionReason::NoNeighbor); if let Some(target_pane_id) = target { self.state.focus_pane_in_workspace(ws_idx, target_pane_id); self.state.switch_workspace_tab(ws_idx, tab_idx); self.state.mode = crate::app::Mode::Terminal; } let focused_pane_id = self .state .workspaces .get(ws_idx) .and_then(|ws| ws.tabs.get(tab_idx)) .map(|tab| tab.layout.focused()) .and_then(|pane_id| self.public_pane_id(ws_idx, pane_id)); let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; encode_success( id, ResponseResult::PaneFocusDirection { focus: PaneFocusDirectionResult { changed: target.is_some(), reason, source_pane_id: source_public_id, focused_pane_id, layout, }, }, ) } pub(super) fn handle_pane_resize(&mut self, id: String, params: PaneResizeParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let amount = params .amount .filter(|amount| amount.is_finite()) .unwrap_or(0.05) .abs() .min(0.5); let direction: NavDirection = params.direction.into(); let area = self.state.view.terminal_area; let changed = self .state .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) .is_some_and(|tab| tab.layout.resize_pane(pane_id, direction, amount, area)); if changed { self.schedule_session_save(); } let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); if changed { self.emit_layout_updated_snapshot(layout.clone()); } encode_success( id, ResponseResult::PaneResize { resize: PaneResizeResult { changed, reason: (!changed).then_some(PaneResizeReason::Unchanged), pane_id: pane_public_id, focused_pane_id, layout, }, }, ) } pub(super) fn handle_pane_swap(&mut self, id: String, params: PaneSwapParams) -> String { let directional = params.direction.is_some(); let explicit = params.source_pane_id.is_some() || params.target_pane_id.is_some(); if directional == explicit { return encode_error( id, "invalid_pane_swap", "provide either direction with optional pane_id, or source_pane_id and target_pane_id", ); } let (ws_idx, tab_idx, source_pane_id, target_pane_id, reason) = if let Some(direction) = params.direction { let Some((ws_idx, source_pane_id)) = self.resolve_swap_source(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "source pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(source_pane_id) else { return pane_not_found( id, &self .public_pane_id(ws_idx, source_pane_id) .unwrap_or_default(), ); }; let target = self.directional_pane_target(ws_idx, tab_idx, source_pane_id, direction); match target { Some(target_pane_id) => { (ws_idx, tab_idx, source_pane_id, Some(target_pane_id), None) } None => ( ws_idx, tab_idx, source_pane_id, None, Some(PaneSwapReason::NoNeighbor), ), } } else { let Some(source_raw) = params.source_pane_id.as_deref() else { return encode_error(id, "invalid_pane_swap", "missing source_pane_id"); }; let Some(target_raw) = params.target_pane_id.as_deref() else { return encode_error(id, "invalid_pane_swap", "missing target_pane_id"); }; let source = self .parse_pane_id(source_raw) .and_then(|(ws_idx, pane_id)| { let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?; Some((ws_idx, tab_idx, pane_id)) }); let target = self .parse_pane_id(target_raw) .and_then(|(ws_idx, pane_id)| { let tab_idx = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id)?; Some((ws_idx, tab_idx, pane_id)) }); let response_context = source .map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx)) .or_else(|| target.map(|(ws_idx, tab_idx, _)| (ws_idx, tab_idx))) .or_else(|| { let ws_idx = self.state.active?; let tab_idx = self.state.workspaces.get(ws_idx)?.active_tab_index(); Some((ws_idx, tab_idx)) }); let Some((ws_idx, tab_idx)) = response_context else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let source_pane_id = source .map(|(_, _, pane_id)| pane_id) .or_else(|| { self.state .workspaces .get(ws_idx)? .tabs .get(tab_idx) .map(|tab| tab.layout.focused()) }) .unwrap_or(PaneId::from_raw(0)); let target_pane_id = target.map(|(_, _, pane_id)| pane_id); let reason = match (source, target) { (None, _) | (_, None) => Some(PaneSwapReason::NotFound), (Some((_, _, source)), Some((_, _, target))) if source == target => { Some(PaneSwapReason::SamePane) } (Some((source_ws, source_tab, _)), Some((target_ws, target_tab, _))) if source_ws != target_ws || source_tab != target_tab => { Some(PaneSwapReason::CrossTab) } _ => None, }; (ws_idx, tab_idx, source_pane_id, target_pane_id, reason) }; let mut changed = false; if reason.is_none() { if let Some(target_pane_id) = target_pane_id { let previous_focus = self.state.current_pane_focus_target(); if let Some(tab) = self .state .workspaces .get_mut(ws_idx) .and_then(|ws| ws.tabs.get_mut(tab_idx)) { changed = tab.layout.swap_panes(source_pane_id, target_pane_id); tab.layout.focus_pane(source_pane_id); if changed { self.state.switch_workspace_tab(ws_idx, tab_idx); self.state .record_pane_focus_change(previous_focus, ws_idx, source_pane_id); self.state.mark_session_dirty(); self.schedule_session_save(); } } } } let source_public_id = match params.source_pane_id { Some(raw) => self .parse_pane_id(&raw) .and_then(|(idx, pane_id)| { self.state .workspaces .get(idx)? .find_tab_index_for_pane(pane_id)?; self.public_pane_id(idx, pane_id) }) .unwrap_or(raw), None => self .public_pane_id(ws_idx, source_pane_id) .unwrap_or_default(), }; let target_public_id = match params.target_pane_id { Some(raw) => self .parse_pane_id(&raw) .and_then(|(idx, pane_id)| { self.state .workspaces .get(idx)? .find_tab_index_for_pane(pane_id)?; self.public_pane_id(idx, pane_id) }) .or(Some(raw)), None => target_pane_id.and_then(|pane_id| self.public_pane_id(ws_idx, pane_id)), }; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); if changed { self.emit_layout_updated_snapshot(layout.clone()); } encode_success( id, ResponseResult::PaneSwap { swap: PaneSwapResult { changed, reason, source_pane_id: source_public_id, target_pane_id: target_public_id, focused_pane_id, layout, }, }, ) } pub(super) fn handle_pane_move(&mut self, id: String, params: PaneMoveParams) -> String { let PaneMoveParams { pane_id, destination, focus, } = params; let Some((source_ws_idx, source_pane_id)) = self.parse_pane_id(&pane_id) else { return encode_error(id, "pane_not_found", "source pane not found"); }; let Some(source_tab_idx) = self.state.workspaces[source_ws_idx].find_tab_index_for_pane(source_pane_id) else { return encode_error(id, "pane_not_found", "source pane not found"); }; let previous_pane_id = self .public_pane_id(source_ws_idx, source_pane_id) .unwrap_or_else(|| pane_id.clone()); let previous_workspace_id = self.public_workspace_id(source_ws_idx); let Some(previous_tab_id) = self.public_tab_id(source_ws_idx, source_tab_idx) else { return encode_error(id, "tab_not_found", "source tab not found"); }; let Some(source_terminal_id) = self .state .workspaces .get(source_ws_idx) .and_then(|ws| ws.tabs.get(source_tab_idx)) .and_then(|tab| tab.terminal_id(source_pane_id)) .cloned() else { return encode_error(id, "pane_not_found", "source pane not found"); }; let recovery_context = PaneMoveRecoveryContext { source_ws_idx, previous_workspace_id: previous_workspace_id.clone(), previous_workspace_label: self.state.workspaces[source_ws_idx].custom_name.clone(), previous_tab_label: self.state.workspaces[source_ws_idx].tabs[source_tab_idx] .custom_name .clone(), previous_worktree_space: self.state.workspaces[source_ws_idx].worktree_space.clone(), identity_cwd: self.state.workspaces[source_ws_idx].identity_cwd.clone(), }; if self.state.workspaces[source_ws_idx].tabs[source_tab_idx].zoomed { let Some(layout) = self.pane_layout_snapshot(source_ws_idx, source_tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else { return encode_error(id, "pane_not_found", "source pane not found"); }; return encode_unchanged_pane_move( id, PaneMoveReason::ZoomedTab, previous_pane_id, previous_workspace_id, previous_tab_id, pane, Some(layout.clone()), layout, ); } let resolved = match destination { PaneMoveDestination::Tab { tab_id, target_pane_id, split, ratio, } => { let Some((target_ws_idx, target_tab_idx)) = self.parse_tab_id(&tab_id) else { return encode_error(id, "tab_not_found", format!("tab {tab_id} not found")); }; if source_ws_idx == target_ws_idx && source_tab_idx == target_tab_idx { let Some(layout) = self.pane_layout_snapshot(source_ws_idx, source_tab_idx) else { return encode_error( id, "pane_layout_unavailable", "pane layout unavailable", ); }; let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else { return encode_error(id, "pane_not_found", "source pane not found"); }; return encode_unchanged_pane_move( id, PaneMoveReason::SameTab, previous_pane_id, previous_workspace_id, previous_tab_id, pane, Some(layout.clone()), layout, ); } if self.state.workspaces[target_ws_idx].tabs[target_tab_idx].zoomed { let Some(source_layout) = self.pane_layout_snapshot(source_ws_idx, source_tab_idx) else { return encode_error( id, "pane_layout_unavailable", "pane layout unavailable", ); }; let Some(target_layout) = self.pane_layout_snapshot(target_ws_idx, target_tab_idx) else { return encode_error( id, "pane_layout_unavailable", "pane layout unavailable", ); }; let Some(pane) = self.pane_info(source_ws_idx, source_pane_id) else { return encode_error(id, "pane_not_found", "source pane not found"); }; return encode_unchanged_pane_move( id, PaneMoveReason::ZoomedTab, previous_pane_id, previous_workspace_id, previous_tab_id, pane, Some(source_layout), target_layout, ); } let target_pane_id = match target_pane_id { Some(raw) => { let Some((pane_ws_idx, pane_id)) = self.parse_pane_id(&raw) else { return encode_error( id, "target_pane_not_found", format!("target pane {raw} not found"), ); }; let pane_tab_idx = self.state.workspaces[pane_ws_idx].find_tab_index_for_pane(pane_id); if pane_ws_idx != target_ws_idx || pane_tab_idx != Some(target_tab_idx) { return encode_error( id, "target_pane_not_found", format!("target pane {raw} is not in tab {tab_id}"), ); } pane_id } None => self.state.workspaces[target_ws_idx].tabs[target_tab_idx] .layout .focused(), }; let Some(target_tab_id) = self.public_tab_id(target_ws_idx, target_tab_idx) else { return encode_error(id, "tab_not_found", format!("tab {tab_id} not found")); }; ResolvedPaneMoveDestination::ExistingTab { tab_id: target_tab_id, target_pane_id, split, ratio: ratio.unwrap_or(0.5), cross_workspace: source_ws_idx != target_ws_idx, } } PaneMoveDestination::NewTab { workspace_id, label, } => { let target_workspace_id = if let Some(workspace_id) = workspace_id { let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { return encode_error( id, "workspace_not_found", format!("workspace {workspace_id} not found"), ); }; self.public_workspace_id(ws_idx) } else { previous_workspace_id.clone() }; ResolvedPaneMoveDestination::NewTab { workspace_id: target_workspace_id, label, } } PaneMoveDestination::NewWorkspace { label, tab_label } => { ResolvedPaneMoveDestination::NewWorkspace { label, tab_label } } }; let previous_focus = self.state.current_pane_focus_target(); let taken = match self .state .workspaces .get_mut(source_ws_idx) .and_then(|ws| ws.take_pane_for_move(source_pane_id)) { Some(taken) => taken, None => return encode_error(id, "pane_move_failed", "source pane could not be moved"), }; let source_removed_tab_id = taken.removed_tab_idx.map(|_| previous_tab_id.clone()); let source_workspace_empty = taken.workspace_empty; let moved = taken.moved; let cross_workspace = match &resolved { ResolvedPaneMoveDestination::ExistingTab { cross_workspace, .. } => *cross_workspace, ResolvedPaneMoveDestination::NewTab { workspace_id, .. } => { workspace_id != &previous_workspace_id } ResolvedPaneMoveDestination::NewWorkspace { .. } => true, }; if cross_workspace { if let Some(ws) = self.state.workspaces.get_mut(source_ws_idx) { ws.unregister_moved_pane(source_pane_id); } self.state .public_pane_id_aliases .insert(previous_pane_id.clone(), source_pane_id); } let mut closed_workspace_id = None; if source_workspace_empty && cross_workspace { self.state.workspaces.remove(source_ws_idx); closed_workspace_id = Some(previous_workspace_id.clone()); if self.state.workspaces.is_empty() { self.state.active = None; self.state.selected = 0; } else { if let Some(active) = self.state.active { if active == source_ws_idx { self.state.active = Some(source_ws_idx.min(self.state.workspaces.len() - 1)); } else if active > source_ws_idx { self.state.active = Some(active - 1); } } if self.state.selected == source_ws_idx { self.state.selected = source_ws_idx.min(self.state.workspaces.len() - 1); } else if self.state.selected > source_ws_idx { self.state.selected -= 1; } } } let mut created_workspace = false; let mut created_tab = false; let (target_ws_idx, target_tab_idx, moved_pane_id) = match resolved { ResolvedPaneMoveDestination::ExistingTab { tab_id, target_pane_id, split, ratio, cross_workspace: _, } => { let Some((target_ws_idx, target_tab_idx)) = self.parse_tab_id(&tab_id) else { self.recover_failed_pane_move(recovery_context, moved); return encode_error(id, "pane_move_failed", "target tab disappeared"); }; let direction = split_direction_to_layout(split); let moved_pane_id = match self.state.workspaces[target_ws_idx] .insert_moved_pane_into_tab( target_tab_idx, target_pane_id, moved, direction, ratio, focus, ) { Ok(pane_id) => pane_id, Err(moved) => { self.recover_failed_pane_move(recovery_context, moved); return encode_error( id, "pane_move_failed", "target pane could not be split", ); } }; (target_ws_idx, target_tab_idx, moved_pane_id) } ResolvedPaneMoveDestination::NewTab { workspace_id, label, } => { let Some(target_ws_idx) = self.parse_workspace_id(&workspace_id) else { self.recover_failed_pane_move(recovery_context, moved); return encode_error(id, "pane_move_failed", "target workspace disappeared"); }; let moved_pane_id = moved.pane_id; let target_tab_idx = self.state.workspaces[target_ws_idx] .create_tab_from_existing_pane( moved, label, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), ); created_tab = true; (target_ws_idx, target_tab_idx, moved_pane_id) } ResolvedPaneMoveDestination::NewWorkspace { label, tab_label } => { let identity_cwd = self .state .terminals .get(&source_terminal_id) .map(|terminal| terminal.cwd.clone()) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); let moved_pane_id = moved.pane_id; let workspace = crate::workspace::Workspace::from_existing_pane( label, tab_label, identity_cwd, moved, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), ); self.state.workspaces.push(workspace); let target_ws_idx = self.state.workspaces.len() - 1; created_workspace = true; created_tab = true; (target_ws_idx, 0, moved_pane_id) } }; if focus || self.state.active.is_none() { self.state .switch_workspace_tab(target_ws_idx, target_tab_idx); self.state .record_pane_focus_change(previous_focus, target_ws_idx, moved_pane_id); self.state.mode = crate::app::Mode::Terminal; } let created_workspace = created_workspace.then(|| self.workspace_info(target_ws_idx)); let created_tab = if created_tab { self.tab_info(target_ws_idx, target_tab_idx) } else { None }; self.state.remove_alias_shadowed_by_new_pane(moved_pane_id); self.state.mark_session_dirty(); self.schedule_session_save(); let Some(pane) = self.pane_info(target_ws_idx, moved_pane_id) else { return encode_error(id, "pane_move_failed", "moved pane is unavailable"); }; let source_layout = if closed_workspace_id.is_none() { self.parse_tab_id(&previous_tab_id) .and_then(|(ws_idx, tab_idx)| self.pane_layout_snapshot(ws_idx, tab_idx)) } else { None }; let Some(target_layout) = self.pane_layout_snapshot(target_ws_idx, target_tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = target_layout.focused_pane_id.clone(); let move_result = PaneMoveResult { changed: true, reason: None, previous_pane_id: previous_pane_id.clone(), previous_workspace_id: previous_workspace_id.clone(), previous_tab_id: previous_tab_id.clone(), pane: Box::new(pane.clone()), source_layout: source_layout.clone().map(Box::new), target_layout: Box::new(target_layout), created_workspace: created_workspace.clone(), created_tab: created_tab.clone(), closed_workspace_id: closed_workspace_id.clone(), closed_tab_id: source_removed_tab_id.clone(), focused_pane_id, }; if let Some(closed_tab_id) = &source_removed_tab_id { self.emit_event(EventEnvelope { event: EventKind::TabClosed, data: EventData::TabClosed { tab_id: closed_tab_id.clone(), workspace_id: previous_workspace_id.clone(), }, }); } if let Some(closed_workspace_id) = &closed_workspace_id { self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id: closed_workspace_id.clone(), workspace: None, }, }); } if let Some(workspace) = &created_workspace { self.emit_event(EventEnvelope { event: EventKind::WorkspaceCreated, data: EventData::WorkspaceCreated { workspace: workspace.clone(), }, }); } if let Some(tab) = &created_tab { self.emit_event(EventEnvelope { event: EventKind::TabCreated, data: EventData::TabCreated { tab: tab.clone() }, }); } self.emit_event(EventEnvelope { event: EventKind::PaneMoved, data: EventData::PaneMoved { previous_pane_id, previous_workspace_id, previous_tab_id, pane: Box::new(pane), created_workspace, created_tab, closed_workspace_id, closed_tab_id: source_removed_tab_id, }, }); if let Some(source_layout) = source_layout { self.emit_layout_updated_snapshot(source_layout); } self.emit_layout_updated_snapshot((*move_result.target_layout).clone()); encode_success(id, ResponseResult::PaneMove { move_result }) } fn recover_failed_pane_move( &mut self, context: PaneMoveRecoveryContext, moved: crate::workspace::MovedPane, ) { if let Some(ws_idx) = self.parse_workspace_id(&context.previous_workspace_id) { self.state.workspaces[ws_idx].create_tab_from_existing_pane( moved, context.previous_tab_label, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), ); } else { let mut workspace = crate::workspace::Workspace::from_existing_pane( context.previous_workspace_label, context.previous_tab_label, context.identity_cwd, moved, self.event_tx.clone(), self.render_notify.clone(), self.render_dirty.clone(), ); workspace.id = context.previous_workspace_id; workspace.worktree_space = context.previous_worktree_space; let insert_idx = context.source_ws_idx.min(self.state.workspaces.len()); if let Some(active) = self.state.active { if active >= insert_idx { self.state.active = Some(active + 1); } } if self.state.selected >= insert_idx && !self.state.workspaces.is_empty() { self.state.selected += 1; } self.state.workspaces.insert(insert_idx, workspace); } self.state.mark_session_dirty(); self.schedule_session_save(); } pub(super) fn handle_pane_zoom(&mut self, id: String, params: PaneZoomParams) -> String { let Some((ws_idx, pane_id)) = self.resolve_optional_pane(params.pane_id.as_deref()) else { return encode_error(id, "pane_not_found", "pane not found"); }; let Some(tab_idx) = self.state.workspaces[ws_idx].find_tab_index_for_pane(pane_id) else { return pane_not_found( id, &self.public_pane_id(ws_idx, pane_id).unwrap_or_default(), ); }; let Some(pane_public_id) = self.public_pane_id(ws_idx, pane_id) else { return encode_error(id, "pane_not_found", "pane not found"); }; let command = match params.mode { PaneZoomMode::Toggle => PaneZoomCommand::Toggle, PaneZoomMode::On => PaneZoomCommand::On, PaneZoomMode::Off => PaneZoomCommand::Off, }; let Some(outcome) = self.state.apply_pane_zoom(ws_idx, pane_id, command) else { return pane_not_found(id, &pane_public_id); }; if outcome.changed || outcome.focus_changed { self.schedule_session_save(); } self.state.mode = crate::app::Mode::Terminal; let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) else { return encode_error(id, "pane_layout_unavailable", "pane layout unavailable"); }; let focused_pane_id = layout.focused_pane_id.clone(); if outcome.changed || outcome.focus_changed { self.emit_layout_updated_snapshot(layout.clone()); } encode_success( id, ResponseResult::PaneZoom { zoom: PaneZoomResult { changed: outcome.changed || outcome.focus_changed, zoom_changed: outcome.changed, focus_changed: outcome.focus_changed, reason: outcome.reason.map(|reason| match reason { PaneZoomNoopReason::SinglePane => PaneZoomReason::SinglePane, PaneZoomNoopReason::AlreadyZoomed => PaneZoomReason::AlreadyZoomed, PaneZoomNoopReason::AlreadyUnzoomed => PaneZoomReason::AlreadyUnzoomed, }), pane_id: pane_public_id, focused_pane_id, zoomed: outcome.zoomed, layout, }, }, ) } pub(super) fn handle_pane_input_set( &mut self, id: String, params: PaneInputSetParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(pane) = self .state .workspaces .get_mut(ws_idx) .and_then(|workspace| workspace.pane_state_mut(pane_id)) else { return pane_not_found(id, ¶ms.pane_id); }; pane.right_click_passthrough = matches!( params.right_click, crate::api::schema::PaneRightClickTarget::Pane ); encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_rename(&mut self, id: String, params: PaneRenameParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(terminal_id) = self .state .workspaces .get(ws_idx) .and_then(|ws| ws.terminal_id(pane_id)) .cloned() else { return pane_not_found(id, ¶ms.pane_id); }; let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return pane_not_found(id, ¶ms.pane_id); }; match params.label.map(|label| label.trim().to_string()) { Some(label) if !label.is_empty() => terminal.set_manual_label(label), _ => terminal.clear_manual_label(), } self.state.mark_session_dirty(); let pane = self.pane_info(ws_idx, pane_id).unwrap(); encode_success(id, ResponseResult::PaneInfo { pane }) } pub(super) fn handle_pane_read(&mut self, id: String, params: PaneReadParams) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(tab_idx) = self .state .workspaces .get(ws_idx) .and_then(|ws| ws.find_tab_index_for_pane(pane_id)) else { return pane_not_found(id, ¶ms.pane_id); }; let snapshot = crate::app::api_helpers::read_terminal_snapshot( pane, params.source, params.format, params.lines, ); encode_success( id, ResponseResult::PaneRead { read: PaneReadResult { pane_id: public_pane_id, workspace_id, tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(), source: params.source, format: params.format, text: snapshot.text, revision: 0, truncated: snapshot.truncated, }, }, ) } pub(super) fn handle_pane_report_agent( &mut self, id: String, params: PaneReportAgentParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { return encode_error(id, "invalid_resume_argv", message); } let report_is_newer = self .pane_terminal(ws_idx, pane_id) .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); let session_ref = crate::agent_resume::session_ref_from_report( ¶ms.source, &agent_label, params.agent_session_id, params.agent_session_path, ); self.handle_internal_event(crate::events::AppEvent::HookStateReported { pane_id, session_ref: session_ref.clone(), source: params.source.clone(), agent_label: agent_label.clone(), state: detect_state_from_api(params.state), message: params.message, seq: params.seq, }); let applied = report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); self.report_agent_resume( id, ws_idx, pane_id, params.source, agent_label, params.seq.filter(|_| applied), applied.then_some(params.resume_argv).flatten(), ) } pub(super) fn handle_pane_report_agent_session( &mut self, id: String, params: PaneReportAgentSessionParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; if let Err(message) = validate_optional_resume_argv(params.resume_argv.as_deref()) { return encode_error(id, "invalid_resume_argv", message); } let report_is_newer = self .pane_terminal(ws_idx, pane_id) .is_some_and(|terminal| terminal.hook_report_is_newer(¶ms.source, params.seq)); let session_ref = crate::agent_resume::session_ref_from_report( ¶ms.source, &agent_label, params.agent_session_id, params.agent_session_path, ); self.handle_internal_event(crate::events::AppEvent::AgentSessionReported { pane_id, session_ref: session_ref.clone(), source: params.source.clone(), agent_label: agent_label.clone(), seq: params.seq, session_start_source: crate::agent_resume::normalize_session_start_source( params.session_start_source, ), }); let applied = report_is_newer && self.session_report_applied(ws_idx, pane_id, session_ref.as_ref()); self.report_agent_resume( id, ws_idx, pane_id, params.source, agent_label, params.seq.filter(|_| applied), applied.then_some(params.resume_argv).flatten(), ) } /// A resume command belongs to the session it was reported with, so it is /// kept only when Herdr accepted that session. fn session_report_applied( &self, ws_idx: usize, pane_id: crate::layout::PaneId, session_ref: Option<&crate::agent_resume::AgentSessionRef>, ) -> bool { session_ref.is_none_or(|session_ref| { self.pane_terminal(ws_idx, pane_id) .is_some_and(|terminal| terminal.session_ref_is_current(session_ref)) }) } fn pane_terminal( &self, ws_idx: usize, pane_id: crate::layout::PaneId, ) -> Option<&crate::terminal::TerminalState> { let pane = self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?; self.state.terminals.get(&pane.attached_terminal_id) } fn report_agent_resume( &mut self, id: String, ws_idx: usize, pane_id: crate::layout::PaneId, source: String, agent_label: String, seq: Option, resume_argv: Option>, ) -> String { let Some(argv) = resume_argv else { return encode_success(id, ResponseResult::Ok {}); }; let can_record = self .pane_terminal(ws_idx, pane_id) .is_some_and(|terminal| terminal.can_record_reported_resume(&source, &agent_label)); if !can_record { return encode_error( id, "resume_not_accepted", "resume_argv requires the reporter to hold the pane; report its state with pane.report_agent first", ); } self.handle_internal_event(crate::events::AppEvent::AgentResumeReported { pane_id, source, agent_label, seq, argv, }); encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_report_metadata( &mut self, id: String, params: PaneReportMetadataParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let agent_label = match params.agent.as_deref() { Some(agent) => match normalize_reported_agent_label(agent) { Some(agent_label) => Some(agent_label), None => return invalid_agent(id), }, None => None, }; let source = match normalize_metadata_source(params.source) { Ok(source) => source, Err(message) => return encode_error(id, "invalid_metadata_source", message), }; let raw_title_set = params.title.is_some(); let raw_display_agent_set = params.display_agent.is_some(); let raw_state_labels_set = !params.state_labels.is_empty(); let tokens = if params.tokens.is_empty() { None } else { match normalize_metadata_tokens(params.tokens) { Ok(tokens) => Some(tokens), Err(message) => return encode_error(id, "invalid_metadata_token", message), } }; let ttl = match normalize_metadata_ttl(params.ttl_ms) { Ok(ttl) => ttl, Err(message) => return encode_error(id, "invalid_metadata_ttl", message), }; let title = normalize_presentation_text(params.title); let display_agent = normalize_presentation_text(params.display_agent); let applies_to_source = match params.applies_to_source { Some(applies_to_source) => match normalize_metadata_source(applies_to_source) { Ok(applies_to_source) => Some(applies_to_source), Err(message) => return encode_error(id, "invalid_metadata_source", message), }, None => None, }; let state_labels = match normalize_state_labels(params.state_labels) { Ok(labels) => labels, Err(status) => { return encode_error( id, "invalid_state_label", format!("unknown state label: {status}"), ); } }; if raw_title_set && params.clear_title || raw_display_agent_set && params.clear_display_agent || raw_state_labels_set && params.clear_state_labels { return encode_error( id, "invalid_metadata_request", "cannot set and clear the same metadata field", ); } if title.is_none() && display_agent.is_none() && state_labels.is_empty() && tokens.is_none() && !params.clear_title && !params.clear_display_agent && !params.clear_state_labels { return encode_error( id, "invalid_metadata_request", "missing metadata field to set or clear", ); } let presentation_requested = title.is_some() || display_agent.is_some() || !state_labels.is_empty() || params.clear_title || params.clear_display_agent || params.clear_state_labels; let Some(terminal_id) = self .state .workspaces .get(ws_idx) .and_then(|workspace| workspace.pane_state(pane_id)) .map(|pane| pane.attached_terminal_id.clone()) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else { return pane_not_found(id, ¶ms.pane_id); }; if terminal.metadata_report_blocked_by_process_exit( &source, agent_label.as_deref(), applies_to_source.as_deref(), ) { return encode_success(id, ResponseResult::Ok {}); } if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) { return encode_success(id, ResponseResult::Ok {}); } let metadata_agent = crate::terminal::TerminalState::metadata_report_agent( &source, agent_label.as_deref(), applies_to_source.as_deref(), ); if let Some(tokens) = tokens.as_ref() { if terminal.metadata_tokens.key_count_after_patch(tokens) > MAX_METADATA_TOKEN_KEYS_PER_RESOURCE { return encode_error( id, "metadata_token_limit", format!( "pane metadata may contain at most {MAX_METADATA_TOKEN_KEYS_PER_RESOURCE} tokens" ), ); } } match terminal.accept_metadata_report(&source, params.seq, tokens.is_some(), metadata_agent) { Ok(true) => {} Ok(false) => return encode_success(id, ResponseResult::Ok {}), Err(()) => { return encode_error( id, "metadata_sequence_source_limit", format!( "pane metadata may track at most {} sequenced sources", crate::metadata_tokens::MAX_SEQUENCE_SOURCES ), ); } } let token_changed = tokens.is_some_and(|tokens| { let changed = terminal .metadata_tokens .patch(tokens, ttl, std::time::Instant::now()); if changed { terminal.revision = terminal.revision.saturating_add(1); } changed }); if presentation_requested { self.handle_internal_event(crate::events::AppEvent::HookMetadataReported { pane_id, source, agent_label, applies_to_source, title, display_agent, state_labels, clear_title: params.clear_title, clear_display_agent: params.clear_display_agent, clear_state_labels: params.clear_state_labels, seq: None, ttl, }); } if token_changed { self.sync_agent_metadata_deadline(); self.emit_pane_updated(ws_idx, pane_id); } encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_clear_agent_authority( &mut self, id: String, params: PaneClearAgentAuthorityParams, ) -> String { let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared { pane_id, source: params.source, seq: params.seq, }); encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_release_agent( &mut self, id: String, params: PaneReleaseAgentParams, ) -> String { let Some((_ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(agent_label) = normalize_reported_agent_label(¶ms.agent) else { return invalid_agent(id); }; self.handle_internal_event(crate::events::AppEvent::HookAgentReleased { pane_id, source: params.source, known_agent: crate::detect::parse_agent_label(&agent_label), agent_label, seq: params.seq, }); encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_send_text( &mut self, id: String, params: PaneSendTextParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) { return encode_error(id, "pane_send_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_send_input( &mut self, id: String, params: PaneSendInputParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let bytes = match super::super::api_helpers::encode_api_input( runtime, ¶ms.text, ¶ms.keys, ) { Ok(bytes) => bytes, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { return encode_error(id, "pane_send_failed", err.to_string()); } encode_success(id, ResponseResult::Ok {}) } pub(super) fn handle_pane_close(&mut self, id: String, target: PaneTarget) -> String { match self.close_pane(id.clone(), &target) { Ok(()) => encode_success(id, ResponseResult::Ok {}), Err(response) => response, } } /// Close a pane; `Err` carries the encoded error response. pub(super) fn close_pane(&mut self, id: String, target: &PaneTarget) -> Result<(), String> { let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else { return Err(pane_not_found(id, &target.pane_id)); }; let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) else { return Err(pane_not_found(id, &target.pane_id)); }; let workspace_id = self.public_workspace_id(ws_idx); let layout_update_target = self.layout_update_target_after_pane_removal(ws_idx, pane_id); if self.state.close_pane_would_close_workspace(ws_idx, pane_id) && self.state.confirm_implicit_worktree_group_close(ws_idx) { return Err(encode_error( id, "confirmation_required", "closing this pane would close a worktree group", )); } let workspace_snapshot = self.workspace_info(ws_idx); let terminal_id = self.state.terminal_id_for_pane(ws_idx, pane_id); let should_close_workspace = { let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { return Err(pane_not_found(id, &target.pane_id)); }; ws.close_pane(pane_id) }; self.state.remove_plugin_pane_records([pane_id]); if should_close_workspace { self.state.selected = ws_idx; self.state.close_selected_workspace(); self.shutdown_detached_terminal_runtimes(); self.emit_event(EventEnvelope { event: EventKind::PaneClosed, data: EventData::PaneClosed { pane_id: public_pane_id, workspace_id: workspace_id.clone(), }, }); self.emit_event(EventEnvelope { event: EventKind::WorkspaceClosed, data: EventData::WorkspaceClosed { workspace_id, workspace: Some(workspace_snapshot), }, }); } else { self.state.remove_unattached_terminal_ids(terminal_id); self.shutdown_detached_terminal_runtimes(); self.schedule_session_save(); self.emit_event(EventEnvelope { event: EventKind::PaneClosed, data: EventData::PaneClosed { pane_id: public_pane_id, workspace_id, }, }); if let Some((ws_idx, tab_idx)) = layout_update_target { self.emit_layout_updated_event(ws_idx, tab_idx); } } Ok(()) } pub(super) fn handle_pane_send_keys( &mut self, id: String, params: PaneSendKeysParams, ) -> String { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else { return pane_not_found(id, ¶ms.pane_id); }; let encoded_keys = match encode_api_keys(runtime, ¶ms.keys) { Ok(encoded_keys) => encoded_keys, Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")), }; for bytes in encoded_keys { if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) { return encode_error(id, "pane_send_failed", err.to_string()); } } encode_success(id, ResponseResult::Ok {}) } } fn normalize_presentation_text(value: Option) -> Option { let trimmed = value?.trim().to_string(); let normalized: String = trimmed .chars() .filter(|ch| !ch.is_control()) .take(80) .collect(); (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) } fn normalize_state_labels( labels: std::collections::HashMap, ) -> Result, String> { labels .into_iter() .map(|(status, label)| { let status = status.trim().to_ascii_lowercase(); if !matches!( status.as_str(), "idle" | "working" | "blocked" | "done" | "unknown" ) { return Err(status); } Ok(normalize_presentation_text(Some(label)).map(|label| (status, label))) }) .filter_map(Result::transpose) .collect() } fn pane_not_found(id: String, pane_id: &str) -> String { encode_error(id, "pane_not_found", format!("pane {pane_id} not found")) } impl App { fn resolve_optional_pane(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> { match pane_id { Some(pane_id) => self.parse_pane_id(pane_id), None => { let ws_idx = self.state.active?; let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; Some((ws_idx, pane_id)) } } } fn resolve_swap_source(&self, pane_id: Option<&str>) -> Option<(usize, PaneId)> { self.resolve_optional_pane(pane_id) } fn directional_pane_target( &self, ws_idx: usize, tab_idx: usize, source_pane_id: PaneId, direction: PaneDirection, ) -> Option { let tab = self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?; let panes = tab.layout.panes(self.state.view.terminal_area); let source = panes.iter().find(|pane| pane.id == source_pane_id)?; find_in_direction(source, direction.into(), &panes) } pub(super) fn pane_layout_snapshot( &self, ws_idx: usize, tab_idx: usize, ) -> Option { let ws = self.state.workspaces.get(ws_idx)?; let tab = ws.tabs.get(tab_idx)?; let area = self.state.view.terminal_area; let focused_pane_id = self.public_pane_id(ws_idx, tab.layout.focused())?; let panes = crate::ui::apply_pane_chrome( tab.layout.panes(area), self.state.pane_borders, self.state.pane_gaps, self.state.pane_outer_borders, ) .into_iter() .filter_map(|pane| { Some(PaneLayoutPane { pane_id: self.public_pane_id(ws_idx, pane.id)?, focused: pane.is_focused, rect: pane.rect.into(), }) }) .collect(); let splits = tab .layout .splits(area) .into_iter() .enumerate() .map(|(idx, split)| PaneLayoutSplit { id: split_path_id(idx, &split.path), direction: match split.direction { ratatui::layout::Direction::Horizontal => { crate::api::schema::SplitDirection::Right } ratatui::layout::Direction::Vertical => { crate::api::schema::SplitDirection::Down } }, ratio: split.ratio, rect: split.area.into(), }) .collect(); Some(PaneLayoutSnapshot { workspace_id: self.public_workspace_id(ws_idx), tab_id: self.public_tab_id(ws_idx, tab_idx)?, zoomed: tab.zoomed, area: area.into(), focused_pane_id, panes, splits, }) } pub(crate) fn emit_layout_updated_event(&mut self, ws_idx: usize, tab_idx: usize) { if let Some(layout) = self.pane_layout_snapshot(ws_idx, tab_idx) { self.emit_layout_updated_snapshot(layout); } } pub(super) fn emit_layout_updated_snapshot(&mut self, layout: PaneLayoutSnapshot) { self.emit_event(EventEnvelope { event: EventKind::LayoutUpdated, data: EventData::LayoutUpdated { layout }, }); } pub(crate) fn layout_update_target_after_pane_removal( &self, ws_idx: usize, pane_id: PaneId, ) -> Option<(usize, usize)> { let tab_idx = self .state .workspaces .get(ws_idx)? .find_tab_index_for_pane(pane_id)?; let pane_count = self .state .workspaces .get(ws_idx)? .tabs .get(tab_idx)? .layout .pane_count(); (pane_count > 1).then_some((ws_idx, tab_idx)) } } impl From for NavDirection { fn from(direction: PaneDirection) -> Self { match direction { PaneDirection::Left => NavDirection::Left, PaneDirection::Right => NavDirection::Right, PaneDirection::Up => NavDirection::Up, PaneDirection::Down => NavDirection::Down, } } } enum ResolvedPaneMoveDestination { ExistingTab { tab_id: String, target_pane_id: PaneId, split: crate::api::schema::SplitDirection, ratio: f32, cross_workspace: bool, }, NewTab { workspace_id: String, label: Option, }, NewWorkspace { label: Option, tab_label: Option, }, } struct PaneMoveRecoveryContext { source_ws_idx: usize, previous_workspace_id: String, previous_workspace_label: Option, previous_tab_label: Option, previous_worktree_space: Option, identity_cwd: std::path::PathBuf, } fn encode_unchanged_pane_move( id: String, reason: PaneMoveReason, previous_pane_id: String, previous_workspace_id: String, previous_tab_id: String, pane: PaneInfo, source_layout: Option, target_layout: PaneLayoutSnapshot, ) -> String { let focused_pane_id = target_layout.focused_pane_id.clone(); encode_success( id, ResponseResult::PaneMove { move_result: PaneMoveResult { changed: false, reason: Some(reason), previous_pane_id, previous_workspace_id, previous_tab_id, pane: Box::new(pane), source_layout: source_layout.map(Box::new), target_layout: Box::new(target_layout), created_workspace: None, created_tab: None, closed_workspace_id: None, closed_tab_id: None, focused_pane_id, }, }, ) } fn split_direction_to_layout( direction: crate::api::schema::SplitDirection, ) -> ratatui::layout::Direction { match direction { crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal, crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical, } } impl From for PaneLayoutRect { fn from(rect: ratatui::layout::Rect) -> Self { Self { x: rect.x, y: rect.y, width: rect.width, height: rect.height, } } } fn split_path_id(idx: usize, path: &[bool]) -> String { if path.is_empty() { return format!("split_{idx}_root"); } let path = path .iter() .map(|right| if *right { "1" } else { "0" }) .collect::>() .join(""); format!("split_{idx}_{path}") } fn invalid_agent(id: String) -> String { encode_error(id, "invalid_agent", "agent label must not be empty") } fn validate_optional_resume_argv(argv: Option<&[String]>) -> Result<(), String> { argv.map_or(Ok(()), crate::agent_resume::validate_resume_argv) } #[cfg(test)] mod tests { use super::*; use crate::{ api::schema::{ErrorResponse, SplitDirection, SuccessResponse}, config::Config, detect::{Agent, AgentState}, workspace::Workspace, }; fn app_with_test_workspace() -> (App, String) { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![Workspace::test_new("metadata")]; app.state.ensure_test_terminals(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); (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) { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let (runtime, rx) = crate::terminal::TerminalRuntime::test_with_channel_capacity(80, 24, capacity); app.state.insert_test_runtime(pane_id, runtime); (app, public_pane_id, rx) } fn app_with_scrollback_runtime() -> (App, String, PaneId) { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let lines = (0..20) .map(|line| format!("line {line:02}\n")) .collect::(); let runtime = crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, lines.as_bytes(), ); app.state.insert_test_runtime(pane_id, runtime); (app, public_pane_id, pane_id) } fn metadata_params(pane_id: String) -> PaneReportMetadataParams { PaneReportMetadataParams { pane_id, source: "user:metadata.test-1".into(), agent: None, applies_to_source: None, title: Some("activity".into()), display_agent: None, state_labels: std::collections::HashMap::new(), tokens: std::collections::HashMap::new(), clear_title: false, clear_display_agent: false, clear_state_labels: false, seq: None, ttl_ms: None, } } fn metadata_error_code(response: &str) -> String { let response: ErrorResponse = serde_json::from_str(response).unwrap(); response.error.code } #[tokio::test] async fn api_pane_send_keys_accepts_control_navigation_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(4); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec![ "ctrl+h".into(), "ctrl+j".into(), "ctrl+k".into(), "ctrl+l".into(), ], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x08])); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0a])); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0b])); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0c])); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_keys_encodes_shift_tab_as_backtab() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["shift+tab".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[Z")); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_clear_pane_mutates_endpoint_owned_history() { let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); let request = crate::api::schema::Request { id: "clear".into(), method: crate::api::schema::Method::PaneClear(PaneTarget { pane_id: public_pane_id, }), }; assert!(crate::api::request_changes_ui(&request)); let response = app.handle_api_request(request); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.result, ResponseResult::Ok {}); let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .unwrap(); assert_eq!(runtime.scroll_metrics().unwrap().max_offset_from_bottom, 0); } #[tokio::test] async fn api_pane_get_exposes_scroll_metrics() { let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .expect("runtime"); runtime.scroll_up(3); let response = app.handle_pane_get( "req".into(), PaneTarget { pane_id: public_pane_id, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneInfo { pane } = success.result else { panic!("expected pane info response"); }; let scroll = pane.scroll.expect("scroll metrics"); assert_eq!(scroll.offset_from_bottom, 3); assert!(scroll.max_offset_from_bottom >= scroll.offset_from_bottom); assert_eq!(scroll.viewport_rows, 5); } #[tokio::test] async fn api_pane_scroll_sets_and_clamps_endpoint_owned_history() { let (mut app, public_pane_id, pane_id) = app_with_scrollback_runtime(); let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .expect("runtime"); let max_offset = runtime .scroll_metrics() .expect("scroll metrics") .max_offset_from_bottom; let response = app.handle_pane_scroll( "req".into(), PaneScrollParams { pane_id: public_pane_id, offset_from_bottom: u64::MAX, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneInfo { pane } = success.result else { panic!("expected pane info response"); }; assert_eq!( pane.scroll.expect("scroll metrics").offset_from_bottom, max_offset as u64 ); } #[tokio::test] async fn api_pane_selection_read_uses_endpoint_terminal_text() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, b"hello world", ), ); let runtime = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .unwrap(); let revision = runtime.content_seq(); runtime.test_process_pty_bytes(b"\r\nagent is still working"); assert_ne!(runtime.content_seq(), revision); let mut params = PaneSelectionReadParams { pane_id: public_pane_id.clone(), anchor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, cursor: crate::api::schema::PaneTextPoint { row: 0, col: 4 }, content_revision: Some(revision), }; assert_eq!( app.pane_selection_text(¶ms).unwrap_err().0, "stale_content" ); params.content_revision = None; let response = app.handle_pane_selection_read("req".into(), params); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( success.result, ResponseResult::PaneSelection { pane_id: public_pane_id, text: "hello".into(), } ); } #[tokio::test] async fn api_copy_motion_uses_endpoint_terminal_word_semantics() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, b"hello world", ), ); let response = app.handle_pane_copy_motion( "req".into(), PaneCopyMotionParams { pane_id: public_pane_id.clone(), cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, motion: PaneCopyMotion::NextWordStart, content_revision: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( success.result, ResponseResult::PaneCopyMotion { pane_id: public_pane_id, cursor: crate::api::schema::PaneTextPoint { row: 0, col: 6 }, content_revision: 0, } ); } #[tokio::test] async fn api_paragraph_motion_preserves_the_copy_cursor_column() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, b"one\r\n\r\nthree", ), ); let response = app.handle_pane_copy_motion( "req".into(), PaneCopyMotionParams { pane_id: public_pane_id.clone(), cursor: PaneTextPoint { row: 0, col: 2 }, motion: PaneCopyMotion::NextParagraph, content_revision: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( success.result, ResponseResult::PaneCopyMotion { pane_id: public_pane_id, cursor: PaneTextPoint { row: 1, col: 2 }, content_revision: 0, } ); } #[tokio::test] async fn api_copy_search_uses_endpoint_terminal_matches_and_wraps() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, b"alpha beta alpha", ), ); let content_revision = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .expect("runtime") .content_seq(); let response = app.handle_pane_copy_search( "req".into(), PaneCopySearchParams { pane_id: public_pane_id.clone(), query: "alpha".into(), direction: PaneCopySearchDirection::Forward, cursor: PaneTextPoint { row: 0, col: 0 }, content_revision, previous: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneCopySearch { pane_id, matches, current, total, current_global, .. } = success.result else { panic!("expected copy search response"); }; assert_eq!(pane_id, public_pane_id); assert_eq!(matches.len(), 2); assert_eq!(matches[0].start, PaneTextPoint { row: 0, col: 0 }); assert_eq!(matches[1].start, PaneTextPoint { row: 0, col: 11 }); assert_eq!(current, Some(1)); assert_eq!(current_global, Some(1)); assert_eq!(total, 2); } #[tokio::test] async fn api_copy_search_bounds_returned_matches_but_keeps_exact_total() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let text = "a ".repeat(1500); app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 200, 20, 4000, text.as_bytes(), ), ); let content_revision = app .state .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) .expect("runtime") .content_seq(); let response = app.handle_pane_copy_search( "req".into(), PaneCopySearchParams { pane_id: public_pane_id, query: "a".into(), direction: PaneCopySearchDirection::Forward, cursor: PaneTextPoint { row: 0, col: 0 }, content_revision, previous: None, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneCopySearch { matches, total, .. } = success.result else { panic!("expected copy search response"); }; assert_eq!(total, 1500); assert_eq!(matches.len(), 1024); } #[tokio::test] async fn api_copy_search_rejects_stale_content_revision() { let (mut app, public_pane_id) = app_with_test_workspace(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; app.state.insert_test_runtime( pane_id, crate::terminal::TerminalRuntime::test_with_scrollback_bytes( 20, 5, 1000, b"alpha beta", ), ); let response = app.handle_pane_copy_search( "req".into(), PaneCopySearchParams { pane_id: public_pane_id, query: "alpha".into(), direction: PaneCopySearchDirection::Forward, cursor: PaneTextPoint { row: 0, col: 0 }, content_revision: 2, previous: None, }, ); assert!(response.contains("stale_content")); } #[tokio::test] async fn api_edit_scrollback_rejects_a_pane_that_is_no_longer_focused() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); app.state.active = None; let response = app.handle_pane_edit_scrollback( "req".into(), PaneTarget { pane_id: public_pane_id, }, ); assert_eq!(metadata_error_code(&response), "stale_pane_target"); assert!(app.overlay_panes.is_empty()); } #[tokio::test] async fn api_pane_read_reports_when_older_rows_are_omitted() { let (mut app, public_pane_id, _pane_id) = app_with_scrollback_runtime(); let response = app.handle_pane_read( "req".into(), PaneReadParams { pane_id: public_pane_id, source: crate::api::schema::ReadSource::Recent, lines: Some(2), format: crate::api::schema::ReadFormat::Text, strip_ansi: true, intent: crate::api::schema::ReadIntent::Interactive, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneRead { read } = success.result else { panic!("expected pane read response"); }; assert!(read.text.contains("line 19")); assert!(read.truncated); } #[tokio::test] async fn api_pane_send_keys_preserves_legacy_control_c_aliases() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(3); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["C-c".into(), "c-c".into(), "ctrl+c".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x03])); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_keys_preserves_super_chord_in_legacy_pane() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; assert_eq!( app.lookup_runtime_sender(0, internal_pane_id) .unwrap() .keyboard_protocol(), crate::input::KeyboardProtocol::Legacy ); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["cmd+c".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!( rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[99;9u") ); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_keys_accepts_literal_plus() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["+".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from_static(b"+")); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_keys_sends_shifted_punctuation_as_text_in_kitty_mode() { let (mut app, pane_id) = app_with_test_workspace(); let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( 80, 24, 0, b"\x1b[>7u", 1, ); app.state.insert_test_runtime(internal_pane_id, runtime); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["shift+?".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from_static(b"?")); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_input_brackets_text_and_enter_atomically() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let internal_pane_id = app.state.workspaces[0].tabs[0].root_pane; app.lookup_runtime_sender(0, internal_pane_id) .unwrap() .test_process_pty_bytes(b"\x1b[?2004h"); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { pane_id, text: "A != B".into(), keys: vec!["Enter".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!( rx.try_recv().unwrap(), bytes::Bytes::from_static(b"\x1b[200~A != B\x1b[201~\r") ); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_input_keys_accept_key_combo_chords() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(1); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { pane_id, text: String::new(), keys: vec!["ctrl+j".into()], }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert_eq!(success.result, ResponseResult::Ok {}); assert_eq!(rx.try_recv().unwrap(), bytes::Bytes::from(vec![0x0a])); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_keys_rejects_invalid_keys_before_writing() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(2); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendKeys(PaneSendKeysParams { pane_id, keys: vec!["ctrl+h".into(), "not-a-key".into()], }), }); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "invalid_key"); assert_eq!(error.error.message, "unsupported key not-a-key"); assert!(rx.try_recv().is_err()); } #[tokio::test] async fn api_pane_send_input_rejects_prefix_bindings_before_writing_text_or_keys() { let (mut app, pane_id, mut rx) = app_with_send_key_runtime(4); let raw_key = " prefix+h ".to_string(); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneSendInput(PaneSendInputParams { pane_id, text: "hello".into(), keys: vec!["ctrl+h".into(), raw_key.clone()], }), }); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "invalid_key"); assert_eq!(error.error.message, format!("unsupported key {raw_key}")); assert!(rx.try_recv().is_err()); } fn app_with_linked_worktree() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( &Config::default(), crate::app::AppPolicy::TEST, None, api_rx, crate::api::EventHub::default(), ); app.state.workspaces = vec![Workspace::test_new("issue")]; app.state.ensure_test_terminals(); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), repo_root: "/repo/herdr".into(), checkout_path: "/repo/herdr-issue".into(), is_linked_worktree: true, }); app } fn seed_terminal_states(app: &mut App) { for ws in &app.state.workspaces { for tab in &ws.tabs { for pane in tab.panes.values() { app.state .terminals .entry(pane.attached_terminal_id.clone()) .or_insert_with(|| { crate::terminal::TerminalState::new( pane.attached_terminal_id.clone(), std::path::PathBuf::from("/herdr-test"), ) }); } } } } #[test] fn api_pane_close_closes_linked_worktree_workspace_only() { let mut app = app_with_linked_worktree(); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_pane_close( "req".into(), PaneTarget { pane_id: public_pane_id, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); assert!(app.state.workspaces.is_empty()); } #[test] fn api_pane_current_prefers_caller_pane_id() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.ensure_test_terminals(); app.state.workspaces[0].tabs[0].layout.focus_pane(root); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_current( "req".into(), crate::api::schema::PaneCurrentParams { caller_pane_id: Some(right_public.clone()), }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneCurrent { pane } = success.result else { panic!("expected pane current response"); }; assert_eq!(pane.pane_id, right_public); assert!(!pane.focused); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); assert_ne!(pane.pane_id, root_public); } #[test] fn api_pane_current_falls_back_to_focused_pane() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_current( "req".into(), crate::api::schema::PaneCurrentParams::default(), ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneCurrent { pane } = success.result else { panic!("expected pane current response"); }; assert_eq!(pane.pane_id, root_public); assert!(pane.focused); } #[test] fn api_pane_current_dispatches_through_socket_request() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_api_request(crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneCurrent( crate::api::schema::PaneCurrentParams::default(), ), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneCurrent { pane } = success.result else { panic!("expected pane current response"); }; assert_eq!(pane.pane_id, root_public); } #[test] fn api_pane_current_reports_invalid_caller_pane_id() { let mut app = app_with_linked_worktree(); let response = app.handle_pane_current( "req".into(), crate::api::schema::PaneCurrentParams { caller_pane_id: Some("missing".into()), }, ); assert_eq!(metadata_error_code(&response), "pane_not_found"); } #[test] fn api_pane_current_reports_no_active_pane() { let mut app = app_with_linked_worktree(); app.state.active = None; let response = app.handle_pane_current( "req".into(), crate::api::schema::PaneCurrentParams::default(), ); assert_eq!(metadata_error_code(&response), "pane_not_found"); } #[test] fn api_pane_swap_explicit_source_and_target_preserves_focus_and_returns_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(source); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); let response = app.handle_pane_swap( "req".into(), PaneSwapParams { source_pane_id: Some(source_public.clone()), target_pane_id: Some(target_public.clone()), ..PaneSwapParams::default() }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneSwap { swap } = success.result else { panic!("expected pane swap response"); }; assert!(swap.changed); assert_eq!(swap.reason, None); assert_eq!(swap.source_pane_id, source_public); assert_eq!(swap.target_pane_id, Some(target_public)); assert_eq!(swap.focused_pane_id, swap.source_pane_id); assert_eq!(swap.layout.focused_pane_id, swap.source_pane_id); assert_eq!(swap.layout.panes.len(), 2); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source)); } #[test] fn api_pane_swap_direction_no_neighbor_returns_unchanged_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(source); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( "req".into(), PaneSwapParams { pane_id: Some(source_public.clone()), direction: Some(PaneDirection::Left), ..PaneSwapParams::default() }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneSwap { swap } = success.result else { panic!("expected pane swap response"); }; assert!(!swap.changed); assert_eq!(swap.reason, Some(PaneSwapReason::NoNeighbor)); assert_eq!(swap.source_pane_id, source_public); assert_eq!(swap.target_pane_id, None); assert_eq!(swap.layout.panes.len(), 1); assert!(app.event_hub.events_after(0).is_empty()); } #[test] fn api_pane_swap_explicit_missing_target_returns_not_found_noop() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_swap( "req".into(), PaneSwapParams { source_pane_id: Some(source_public.clone()), target_pane_id: Some("missing-pane".into()), ..PaneSwapParams::default() }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneSwap { swap } = success.result else { panic!("expected pane swap response"); }; assert!(!swap.changed); assert_eq!(swap.reason, Some(PaneSwapReason::NotFound)); assert_eq!(swap.source_pane_id, source_public); assert_eq!(swap.target_pane_id, Some("missing-pane".into())); assert_eq!(swap.layout.panes.len(), 1); } #[test] fn api_pane_swap_explicit_missing_source_returns_not_found_noop() { let mut app = app_with_linked_worktree(); let target = app.state.workspaces[0].tabs[0].root_pane; let target_public = app.public_pane_id(0, target).unwrap(); let response = app.handle_pane_swap( "req".into(), PaneSwapParams { source_pane_id: Some("missing-pane".into()), target_pane_id: Some(target_public.clone()), ..PaneSwapParams::default() }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneSwap { swap } = success.result else { panic!("expected pane swap response"); }; assert!(!swap.changed); assert_eq!(swap.reason, Some(PaneSwapReason::NotFound)); assert_eq!(swap.source_pane_id, "missing-pane"); assert_eq!(swap.target_pane_id, Some(target_public)); assert_eq!(swap.layout.panes.len(), 1); } #[test] fn api_pane_swap_explicit_cross_workspace_preserves_target_id() { let mut app = app_with_linked_worktree(); app.state.workspaces.push(Workspace::test_new("other")); let source = app.state.workspaces[0].tabs[0].root_pane; let target = app.state.workspaces[1].tabs[0].root_pane; let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(1, target).unwrap(); let response = app.handle_pane_swap( "req".into(), PaneSwapParams { source_pane_id: Some(source_public.clone()), target_pane_id: Some(target_public.clone()), ..PaneSwapParams::default() }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneSwap { swap } = success.result else { panic!("expected pane swap response"); }; assert!(!swap.changed); assert_eq!(swap.reason, Some(PaneSwapReason::CrossTab)); assert_eq!(swap.source_pane_id, source_public); assert_eq!(swap.target_pane_id, Some(target_public)); assert_eq!(swap.layout.workspace_id, app.public_workspace_id(0)); } #[test] fn api_pane_move_to_existing_tab_preserves_internal_pane_and_terminal() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); let target = app.state.workspaces[0].tabs[target_tab].root_pane; seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let source_tab_public = app.public_tab_id(0, 0).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public.clone(), destination: PaneMoveDestination::Tab { tab_id: target_tab_public.clone(), target_pane_id: Some(target_public), split: SplitDirection::Right, ratio: Some(0.25), }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!(move_result.reason, None); assert_eq!(move_result.previous_pane_id, source_public); assert_eq!(move_result.previous_tab_id, source_tab_public); assert_eq!(move_result.pane.pane_id, move_result.previous_pane_id); assert_eq!(move_result.pane.tab_id, target_tab_public); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!(move_result.closed_tab_id, Some(source_tab_public)); assert_eq!(move_result.closed_workspace_id, None); assert_eq!(move_result.target_layout.panes.len(), 2); assert_eq!(app.state.workspaces[0].tabs.len(), 1); assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); } #[test] fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() { let mut app = app_with_linked_worktree(); app.state.workspaces.push(Workspace::test_new("other")); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); app.state .terminals .get_mut(&source_terminal) .unwrap() .set_detected_state(Some(Agent::Pi), AgentState::Idle); let previous_pane_id = app.public_pane_id(0, source).unwrap(); let previous_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); let target_tab_id = app.public_tab_id(1, 0).unwrap(); let target_pane_id = app.public_pane_id(1, target).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: previous_pane_id.clone(), destination: PaneMoveDestination::Tab { tab_id: target_tab_id.clone(), target_pane_id: Some(target_pane_id), split: SplitDirection::Down, ratio: None, }, focus: false, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!(move_result.previous_pane_id, previous_pane_id); assert_eq!(move_result.previous_workspace_id, previous_workspace_id); assert_eq!(move_result.closed_workspace_id, Some(previous_workspace_id)); assert_ne!(move_result.pane.pane_id, move_result.previous_pane_id); assert!(move_result .pane .pane_id .starts_with(&format!("{target_workspace_id}:p"))); assert_eq!(move_result.pane.workspace_id, target_workspace_id); assert_eq!(move_result.pane.tab_id, target_tab_id); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!(app.state.workspaces.len(), 1); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); assert_eq!(app.parse_pane_id(&previous_pane_id), Some((0, source))); assert!(matches!( app.resolve_agent_target(&previous_pane_id), Err(crate::app::terminal_targets::TerminalTargetError::NotFound { .. }) )); assert!(app.resolve_agent_target(&move_result.pane.pane_id).is_ok()); } #[test] fn api_pane_move_legacy_target_tab_id_survives_source_workspace_removal() { let mut app = app_with_linked_worktree(); app.state.workspaces.push(Workspace::test_new("other")); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); let target = app.state.workspaces[1].tabs[0].root_pane; seed_terminal_states(&mut app); let source_workspace_id = app.public_workspace_id(0); let target_workspace_id = app.public_workspace_id(1); let target_tab_id = app.public_tab_id(1, 0).unwrap(); let source_public = app.public_pane_id(0, source).unwrap(); let target_public = app.public_pane_id(1, target).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::Tab { tab_id: "t_2_1".into(), target_pane_id: Some(target_public), split: SplitDirection::Right, ratio: None, }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(move_result.closed_workspace_id, Some(source_workspace_id)); assert_eq!(move_result.pane.workspace_id, target_workspace_id); assert_eq!(move_result.pane.tab_id, target_tab_id); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); } #[test] fn api_pane_move_to_new_tab_creates_tab_without_spawning_terminal() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public.clone(), destination: PaneMoveDestination::NewTab { workspace_id: None, label: Some("moved".into()), }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!( move_result .created_tab .as_ref() .map(|tab| tab.label.as_str()), Some("moved") ); assert_eq!( move_result.created_tab.as_ref().map(|tab| tab.focused), Some(true) ); assert_eq!(move_result.closed_tab_id, None); assert_eq!(move_result.pane.pane_id, source_public); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!(app.state.workspaces[0].tabs.len(), 2); assert!(app.state.workspaces[0].tabs[0].terminal_id(right).is_some()); assert_eq!( app.state.workspaces[0].tabs[1].terminal_id(source), Some(&source_terminal) ); let envelopes = app.event_hub.events_after(0); let events: Vec<_> = envelopes .iter() .map(|(_, envelope)| envelope.event) .collect(); assert_eq!( events, vec![ EventKind::TabCreated, EventKind::PaneMoved, EventKind::LayoutUpdated, EventKind::LayoutUpdated, ] ); match &envelopes[0].1.data { EventData::TabCreated { tab } => assert!(tab.focused), other => panic!("expected tab created event, got {other:?}"), } assert!(matches!( &envelopes[2].1.data, EventData::LayoutUpdated { layout } if layout.tab_id == app.public_tab_id(0, 0).unwrap() )); assert!(matches!( &envelopes[3].1.data, EventData::LayoutUpdated { layout } if layout.tab_id == app.public_tab_id(0, 1).unwrap() )); } #[test] fn api_pane_move_only_pane_to_new_tab_uses_app_render_handles() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::NewTab { workspace_id: None, label: Some("moved".into()), }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert!(std::sync::Arc::ptr_eq( &app.state.workspaces[0].tabs[0].render_notify, &app.render_notify )); assert!(std::sync::Arc::ptr_eq( &app.state.workspaces[0].tabs[0].render_dirty, &app.render_dirty )); } #[test] fn api_pane_move_to_new_workspace_closes_empty_source_workspace() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let source_workspace = app.public_workspace_id(0); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public.clone(), destination: PaneMoveDestination::NewWorkspace { label: Some("promoted".into()), tab_label: Some("main".into()), }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!(move_result.closed_workspace_id, Some(source_workspace)); assert_eq!( move_result .created_workspace .as_ref() .map(|ws| ws.label.as_str()), Some("promoted") ); assert_eq!( move_result.created_workspace.as_ref().map(|ws| ws.focused), Some(true) ); assert_eq!( move_result .created_tab .as_ref() .map(|tab| tab.label.as_str()), Some("main") ); assert_eq!( move_result.created_tab.as_ref().map(|tab| tab.focused), Some(true) ); assert_ne!(move_result.pane.pane_id, source_public); assert_eq!(move_result.pane.terminal_id, source_terminal.to_string()); assert_eq!(app.state.workspaces.len(), 1); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); assert!(std::sync::Arc::ptr_eq( &app.state.workspaces[0].tabs[0].render_notify, &app.render_notify )); assert!(std::sync::Arc::ptr_eq( &app.state.workspaces[0].tabs[0].render_dirty, &app.render_dirty )); let envelopes = app.event_hub.events_after(0); let events: Vec<_> = envelopes .iter() .map(|(_, envelope)| envelope.event) .collect(); assert_eq!( events, vec![ EventKind::TabClosed, EventKind::WorkspaceClosed, EventKind::WorkspaceCreated, EventKind::TabCreated, EventKind::PaneMoved, EventKind::LayoutUpdated, ] ); match &envelopes[2].1.data { EventData::WorkspaceCreated { workspace } => assert!(workspace.focused), other => panic!("expected workspace created event, got {other:?}"), } match &envelopes[3].1.data { EventData::TabCreated { tab } => assert!(tab.focused), other => panic!("expected tab created event, got {other:?}"), } match &envelopes[5].1.data { EventData::LayoutUpdated { layout } => assert_eq!( layout.tab_id, app.public_tab_id(0, 0) .expect("created workspace should have a first tab") ), other => panic!("expected layout updated event, got {other:?}"), } } #[test] fn api_pane_move_same_tab_returns_same_tab_noop() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let source_tab = app.public_tab_id(0, 0).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::Tab { tab_id: source_tab, target_pane_id: None, split: SplitDirection::Right, ratio: None, }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(!move_result.changed); assert_eq!(move_result.reason, Some(PaneMoveReason::SameTab)); assert_eq!(app.state.workspaces[0].tabs.len(), 1); } #[test] fn api_pane_move_rejects_target_pane_outside_target_tab() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); let other_tab = app.state.workspaces[0].test_add_tab(Some("other")); seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); let wrong_target = app .public_pane_id(0, app.state.workspaces[0].tabs[other_tab].root_pane) .unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::Tab { tab_id: target_tab_public, target_pane_id: Some(wrong_target), split: SplitDirection::Right, ratio: None, }, focus: true, }, ); let error: crate::api::schema::ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "target_pane_not_found"); assert_eq!(app.state.workspaces[0].tabs.len(), 3); } #[test] fn api_pane_move_existing_tab_no_focus_preserves_previous_target_focus() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); let previously_focused = app.state.workspaces[0].tabs[target_tab].root_pane; app.state.workspaces[0].active_tab = target_tab; let explicit_target = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[target_tab] .layout .focus_pane(previously_focused); seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); let explicit_target_public = app.public_pane_id(0, explicit_target).unwrap(); let previously_focused_public = app.public_pane_id(0, previously_focused).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::Tab { tab_id: target_tab_public, target_pane_id: Some(explicit_target_public), split: SplitDirection::Right, ratio: None, }, focus: false, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(move_result.changed); assert_eq!(move_result.focused_pane_id, previously_focused_public); assert_eq!( app.state.workspaces[0].tabs[0].layout.focused(), previously_focused ); } #[test] fn api_pane_move_recovery_restores_removed_source_workspace() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let source_terminal = app.state.workspaces[0].tabs[0] .terminal_id(source) .unwrap() .clone(); let previous_workspace_id = app.public_workspace_id(0); let context = PaneMoveRecoveryContext { source_ws_idx: 0, previous_workspace_id: previous_workspace_id.clone(), previous_workspace_label: app.state.workspaces[0].custom_name.clone(), previous_tab_label: app.state.workspaces[0].tabs[0].custom_name.clone(), previous_worktree_space: app.state.workspaces[0].worktree_space.clone(), identity_cwd: app.state.workspaces[0].identity_cwd.clone(), }; let taken = app.state.workspaces[0] .take_pane_for_move(source) .expect("source pane should be movable"); app.state.workspaces.remove(0); app.state.active = None; app.state.selected = 0; app.recover_failed_pane_move(context, taken.moved); assert_eq!(app.state.workspaces.len(), 1); assert_eq!(app.state.workspaces[0].id, previous_workspace_id); assert_eq!( app.state.workspaces[0].tabs[0].terminal_id(source), Some(&source_terminal) ); assert_eq!( app.parse_pane_id(&format!("{previous_workspace_id}:p1")), Some((0, source)) ); } #[test] fn api_pane_move_to_zoomed_target_returns_target_layout() { let mut app = app_with_linked_worktree(); let source = app.state.workspaces[0].tabs[0].root_pane; let target_tab = app.state.workspaces[0].test_add_tab(Some("target")); let target = app.state.workspaces[0].tabs[target_tab].root_pane; app.state.workspaces[0].tabs[target_tab].zoomed = true; seed_terminal_states(&mut app); let source_public = app.public_pane_id(0, source).unwrap(); let target_tab_public = app.public_tab_id(0, target_tab).unwrap(); let target_public = app.public_pane_id(0, target).unwrap(); let response = app.handle_pane_move( "req".into(), PaneMoveParams { pane_id: source_public, destination: PaneMoveDestination::Tab { tab_id: target_tab_public.clone(), target_pane_id: Some(target_public), split: SplitDirection::Right, ratio: None, }, focus: true, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneMove { move_result } = success.result else { panic!("expected pane move response"); }; assert!(!move_result.changed); assert_eq!(move_result.reason, Some(PaneMoveReason::ZoomedTab)); assert_eq!(move_result.target_layout.tab_id, target_tab_public); assert_eq!( move_result .source_layout .as_ref() .map(|layout| layout.tab_id.as_str()), app.public_tab_id(0, 0).as_deref() ); } #[test] fn api_pane_zoom_current_toggles_zoom() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default()); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(zoom.changed); assert!(zoom.zoom_changed); assert!(!zoom.focus_changed); assert_eq!(zoom.reason, None); assert_eq!(zoom.pane_id, root_public); assert_eq!(zoom.focused_pane_id, zoom.pane_id); assert!(zoom.zoomed); assert!(zoom.layout.zoomed); assert!(matches!( &app.event_hub.events_after(0).last().expect("layout event").1.data, EventData::LayoutUpdated { layout } if layout.tab_id == app.public_tab_id(0, 0).unwrap() && layout.zoomed )); let response = app.handle_pane_zoom("req".into(), PaneZoomParams::default()); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(zoom.changed); assert!(zoom.zoom_changed); assert!(!zoom.focus_changed); assert!(!zoom.zoomed); assert!(!zoom.layout.zoomed); assert!(matches!( &app.event_hub.events_after(0).last().expect("layout event").1.data, EventData::LayoutUpdated { layout } if layout.tab_id == app.public_tab_id(0, 0).unwrap() && !layout.zoomed )); } #[test] fn api_pane_zoom_single_pane_returns_noop() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: Some(root_public.clone()), mode: PaneZoomMode::Toggle, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(!zoom.changed); assert!(!zoom.zoom_changed); assert!(!zoom.focus_changed); assert_eq!(zoom.reason, Some(PaneZoomReason::SinglePane)); assert_eq!(zoom.pane_id, root_public); assert!(!zoom.zoomed); assert!(!app.state.workspaces[0].tabs[0].zoomed); } #[test] fn api_pane_zoom_on_and_off_are_idempotent() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let _right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: Some(root_public.clone()), mode: PaneZoomMode::On, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(zoom.changed); assert!(zoom.zoom_changed); assert!(!zoom.focus_changed); assert!(zoom.zoomed); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: Some(root_public.clone()), mode: PaneZoomMode::On, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(!zoom.changed); assert!(!zoom.zoom_changed); assert!(!zoom.focus_changed); assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed)); assert!(zoom.zoomed); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: Some(root_public), mode: PaneZoomMode::Off, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(zoom.changed); assert!(zoom.zoom_changed); assert!(!zoom.focus_changed); assert!(!zoom.zoomed); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: None, mode: PaneZoomMode::Off, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(!zoom.changed); assert!(!zoom.zoom_changed); assert!(!zoom.focus_changed); assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyUnzoomed)); assert!(!zoom.zoomed); } #[test] fn api_pane_zoom_idempotent_mode_reports_focus_change() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); app.state.workspaces[0].tabs[0].zoomed = true; let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_zoom( "req".into(), PaneZoomParams { pane_id: Some(right_public), mode: PaneZoomMode::On, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneZoom { zoom } = success.result else { panic!("expected pane zoom response"); }; assert!(zoom.changed); assert!(!zoom.zoom_changed); assert!(zoom.focus_changed); assert_eq!(zoom.reason, Some(PaneZoomReason::AlreadyZoomed)); assert!(zoom.zoomed); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); assert!(matches!( &app.event_hub.events_after(0).last().expect("layout event").1.data, EventData::LayoutUpdated { layout } if layout.focused_pane_id == app.public_pane_id(0, right).unwrap() )); } #[test] fn api_pane_zoom_params_serialize_modes() { let request = crate::api::schema::Request { id: "req".into(), method: crate::api::schema::Method::PaneZoom(PaneZoomParams { pane_id: Some("issue-1".into()), mode: PaneZoomMode::On, }), }; let encoded = serde_json::to_string(&request).unwrap(); assert!(encoded.contains("\"method\":\"pane.zoom\"")); assert!(encoded.contains("\"mode\":\"on\"")); let decoded: crate::api::schema::Request = serde_json::from_str(&encoded).unwrap(); let crate::api::schema::Method::PaneZoom(params) = decoded.method else { panic!("expected pane zoom request"); }; assert_eq!(params.pane_id, Some("issue-1".into())); assert_eq!(params.mode, PaneZoomMode::On); } #[test] fn api_pane_layout_returns_public_ids_rects_and_splits() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_layout( "req".into(), crate::api::schema::PaneLayoutParams { pane_id: Some(root_public.clone()), }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneLayout { layout } = success.result else { panic!("expected pane layout response"); }; assert_eq!(layout.focused_pane_id, root_public); assert!(layout.panes.iter().any(|pane| pane.pane_id == root_public)); assert!(layout.panes.iter().any(|pane| pane.pane_id == right_public)); assert_eq!(layout.splits.len(), 1); assert_eq!( layout.splits[0].direction, crate::api::schema::SplitDirection::Right ); } #[test] fn api_pane_neighbor_returns_directional_neighbor_public_id() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_neighbor( "req".into(), crate::api::schema::PaneNeighborParams { pane_id: Some(root_public.clone()), direction: PaneDirection::Right, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneNeighbor { neighbor } = success.result else { panic!("expected pane neighbor response"); }; assert_eq!(neighbor.pane_id, root_public); assert_eq!(neighbor.direction, PaneDirection::Right); assert_eq!(neighbor.neighbor_pane_id, Some(right_public)); } #[test] fn api_pane_edges_reports_physical_layout_edges() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_edges( "req".into(), crate::api::schema::PaneEdgesParams { pane_id: Some(right_public.clone()), }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneEdges { edges } = success.result else { panic!("expected pane edges response"); }; assert_eq!(edges.pane_id, right_public); assert!(!edges.left); assert!(edges.right); assert!(edges.up); assert!(edges.down); } #[test] fn api_pane_resize_changes_target_ratio_without_changing_focus() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(right); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_resize( "req".into(), crate::api::schema::PaneResizeParams { pane_id: Some(root_public.clone()), direction: PaneDirection::Right, amount: Some(0.1), }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneResize { resize } = success.result else { panic!("expected pane resize response"); }; assert!(resize.changed); assert_eq!(resize.reason, None); assert_eq!(resize.pane_id, root_public); assert_eq!(resize.focused_pane_id, right_public); assert_eq!(resize.layout.focused_pane_id, right_public); assert!((resize.layout.splits[0].ratio - 0.6).abs() < f32::EPSILON); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); assert!(matches!( &app.event_hub.events_after(0).last().expect("layout event").1.data, EventData::LayoutUpdated { layout } if layout.tab_id == app.public_tab_id(0, 0).unwrap() && (layout.splits[0].ratio - 0.6).abs() < f32::EPSILON )); } #[test] fn api_pane_focus_direction_focuses_neighbor() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; let right = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal); app.state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let root_public = app.public_pane_id(0, root).unwrap(); let right_public = app.public_pane_id(0, right).unwrap(); let response = app.handle_pane_focus_direction( "req".into(), crate::api::schema::PaneFocusDirectionParams { pane_id: Some(root_public.clone()), direction: PaneDirection::Right, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneFocusDirection { focus } = success.result else { panic!("expected pane focus direction response"); }; assert!(focus.changed); assert_eq!(focus.reason, None); assert_eq!(focus.source_pane_id, root_public); assert_eq!(focus.focused_pane_id, Some(right_public.clone())); assert_eq!(focus.layout.focused_pane_id, right_public); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(right)); } #[test] fn api_pane_focus_focuses_direct_target_across_tabs_and_workspaces() { let mut app = app_with_linked_worktree(); app.state.workspaces.push(Workspace::test_new("other")); let target_tab_idx = app.state.workspaces[1].test_add_tab(Some("target")); app.state.workspaces[1].switch_tab(target_tab_idx); let target_pane = app.state.workspaces[1].tabs[target_tab_idx].root_pane; app.state.ensure_test_terminals(); let target_public = app.public_pane_id(1, target_pane).unwrap(); app.state.switch_workspace(0); assert_eq!(app.state.active, Some(0)); let response = app.handle_pane_focus( "req".into(), crate::api::schema::PaneTarget { pane_id: target_public.clone(), }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneInfo { pane } = success.result else { panic!("expected pane info response"); }; assert_eq!(pane.pane_id, target_public); assert_eq!(app.state.active, Some(1)); assert_eq!(app.state.workspaces[1].active_tab, target_tab_idx); assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); assert_eq!(app.state.mode, Mode::Terminal); } #[test] fn api_pane_focus_marks_already_focused_done_pane_seen() { let mut app = app_with_linked_worktree(); app.state.active = Some(0); app.state.selected = 0; app.state.outer_terminal_focus = Some(false); let pane_id = app.state.workspaces[0].tabs[0].root_pane; let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] .attached_terminal_id .clone(); app.state.terminals.get_mut(&terminal_id).unwrap().state = crate::detect::AgentState::Idle; app.state.workspaces[0].tabs[0] .panes .get_mut(&pane_id) .unwrap() .seen = false; app.state.workspaces[0].tabs[0].layout.focus_pane(pane_id); let public_pane_id = app.public_pane_id(0, pane_id).unwrap(); let response = app.handle_pane_focus( "req".into(), PaneTarget { pane_id: public_pane_id, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneInfo { pane } = success.result else { panic!("expected pane info response"); }; assert_eq!(pane.agent_status, crate::api::schema::AgentStatus::Idle); } #[test] fn api_pane_focus_rejects_invalid_pane_id() { let mut app = app_with_linked_worktree(); let response = app.handle_pane_focus( "req".into(), crate::api::schema::PaneTarget { pane_id: "pane_missing".into(), }, ); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "pane_not_found"); } #[test] fn api_pane_focus_direction_no_neighbor_is_noop() { let mut app = app_with_linked_worktree(); let root = app.state.workspaces[0].tabs[0].root_pane; app.state.workspaces[0].tabs[0].layout.focus_pane(root); crate::ui::compute_view_with_runtime_registry( &mut app.state, &crate::terminal::TerminalRuntimeRegistry::new(), ratatui::layout::Rect::new(0, 0, 100, 20), ); let root_public = app.public_pane_id(0, root).unwrap(); let response = app.handle_pane_focus_direction( "req".into(), crate::api::schema::PaneFocusDirectionParams { pane_id: Some(root_public.clone()), direction: PaneDirection::Left, }, ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneFocusDirection { focus } = success.result else { panic!("expected pane focus direction response"); }; assert!(!focus.changed); assert_eq!(focus.reason, Some(PaneFocusDirectionReason::NoNeighbor)); assert_eq!(focus.source_pane_id, root_public.clone()); assert_eq!(focus.focused_pane_id, Some(root_public)); assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root)); } #[test] fn pane_metadata_tokens_patch_and_clear_through_dispatcher() { let (mut app, pane_id) = app_with_test_workspace(); for (tokens, expected) in [ ( std::collections::HashMap::from([ ("summary".into(), Some("reviewing auth".into())), ("model".into(), Some("opus".into())), ]), std::collections::HashMap::from([ ("summary".into(), "reviewing auth".into()), ("model".into(), "opus".into()), ]), ), ( std::collections::HashMap::from([ ("summary".into(), Some("done".into())), ("model".into(), None), ]), std::collections::HashMap::from([("summary".into(), "done".into())]), ), ] { let mut params = metadata_params(pane_id.clone()); params.title = None; params.tokens = tokens; let response = app.handle_api_request(crate::api::schema::Request { id: "set".into(), method: crate::api::schema::Method::PaneReportMetadata(params), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.result, ResponseResult::Ok {}); let response = app.handle_api_request(crate::api::schema::Request { id: "get".into(), method: crate::api::schema::Method::PaneGet(PaneTarget { pane_id: pane_id.clone(), }), }); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::PaneInfo { pane } = success.result else { panic!("expected pane info"); }; assert_eq!(pane.tokens, expected); } let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); let terminal_id = app.state.workspaces[0] .pane_state(internal_pane_id) .unwrap() .attached_terminal_id .clone(); assert!(app.state.terminals[&terminal_id].agent_metadata.is_empty()); } #[test] fn pane_tokens_are_independent_from_presentation_guards() { let (mut app, pane_id) = app_with_test_workspace(); let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); let terminal_id = app.state.workspaces[0] .pane_state(internal_pane_id) .unwrap() .attached_terminal_id .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state(Some(Agent::Claude), AgentState::Working); let mut params = metadata_params(pane_id); params.title = None; params.agent = Some("codex".into()); params.tokens = std::collections::HashMap::from([("summary".into(), Some("global".into()))]); let response = app.handle_pane_report_metadata("guarded".into(), params); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!( app.state.terminals[&terminal_id].metadata_tokens.values(), std::collections::HashMap::from([("summary".into(), "global".into())]) ); } #[test] fn pane_metadata_uses_one_sequence_for_presentation_and_tokens() { let (mut app, pane_id) = app_with_test_workspace(); let mut presentation = metadata_params(pane_id.clone()); presentation.seq = Some(10); let response = app.handle_pane_report_metadata("presentation".into(), presentation); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let mut stale_token = metadata_params(pane_id.clone()); stale_token.title = None; stale_token.tokens = std::collections::HashMap::from([("summary".into(), Some("stale".into()))]); stale_token.seq = Some(9); let response = app.handle_pane_report_metadata("stale".into(), stale_token); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); let terminal_id = app.state.workspaces[0] .pane_state(internal_pane_id) .unwrap() .attached_terminal_id .clone(); assert!(app.state.terminals[&terminal_id] .metadata_tokens .values() .is_empty()); } #[test] fn pane_metadata_ignored_after_process_exit_does_not_poison_sequence() { let (mut app, pane_id) = app_with_test_workspace(); let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap(); let terminal_id = app.state.workspaces[0] .pane_state(internal_pane_id) .unwrap() .attached_terminal_id .clone(); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state(Some(Agent::Pi), AgentState::Idle); let mut initial = metadata_params(pane_id.clone()); initial.source = "custom:pi-metadata".into(); initial.agent = Some("pi".into()); initial.seq = Some(100); let response = app.handle_pane_report_metadata("initial".into(), initial); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let mut initial_tokens = metadata_params(pane_id.clone()); initial_tokens.source = "custom:pi-tokens".into(); initial_tokens.agent = Some("pi".into()); initial_tokens.title = None; initial_tokens.tokens = std::collections::HashMap::from([("generation".into(), Some("old".into()))]); initial_tokens.seq = Some(100); let response = app.handle_pane_report_metadata("initial-tokens".into(), initial_tokens); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let exit_at = std::time::Instant::now() + std::time::Duration::from_millis(1); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, false, false, true, exit_at, ); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state_with_screen_signals_at( None, AgentState::Unknown, false, false, false, false, exit_at + std::time::Duration::from_millis(1), ); let mut stale = metadata_params(pane_id.clone()); stale.source = "custom:pi-metadata".into(); stale.agent = Some("pi".into()); stale.title = Some("stale".into()); stale.seq = Some(200); let response = app.handle_pane_report_metadata("stale".into(), stale); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let mut official = metadata_params(pane_id.clone()); official.source = "herdr:pi".into(); official.seq = Some(200); let response = app.handle_pane_report_metadata("official".into(), official); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let terminal = &app.state.terminals[&terminal_id]; assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-metadata", Some(1))); assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-tokens", Some(1))); assert!(terminal.metadata_report_sequence_is_fresh("herdr:pi", Some(1))); app.state .terminals .get_mut(&terminal_id) .unwrap() .set_detected_state_with_screen_signals_at( Some(Agent::Pi), AgentState::Idle, false, false, false, false, exit_at + std::time::Duration::from_millis(2), ); let mut fresh = metadata_params(pane_id.clone()); fresh.source = "custom:pi-metadata".into(); fresh.agent = Some("pi".into()); fresh.title = Some("fresh".into()); fresh.seq = Some(1); let response = app.handle_pane_report_metadata("fresh".into(), fresh); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let mut fresh_tokens = metadata_params(pane_id); fresh_tokens.source = "custom:pi-tokens".into(); fresh_tokens.agent = Some("pi".into()); fresh_tokens.title = None; fresh_tokens.tokens = std::collections::HashMap::from([("generation".into(), Some("new".into()))]); fresh_tokens.seq = Some(1); let response = app.handle_pane_report_metadata("fresh-tokens".into(), fresh_tokens); let _: SuccessResponse = serde_json::from_str(&response).unwrap(); let terminal = &app.state.terminals[&terminal_id]; assert_eq!( terminal.agent_metadata["custom:pi-metadata"] .title .as_deref(), Some("fresh") ); assert_eq!( terminal .metadata_tokens .values() .get("generation") .map(String::as_str), Some("new") ); } #[test] fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); params.ttl_ms = Some(METADATA_TTL_MAX_MS); let response = app.handle_pane_report_metadata("req".into(), params); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); assert_eq!(success.id, "req"); } #[test] fn pane_report_metadata_rejects_invalid_source_shape() { let (mut app, pane_id) = app_with_test_workspace(); for source in ["", "user metadata", "user/metadata", "user:\u{7f}metadata"] { let mut params = metadata_params(pane_id.clone()); params.source = source.into(); let response = app.handle_pane_report_metadata("req".into(), params); assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); } } #[test] fn pane_report_metadata_rejects_long_source() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); params.source = "a".repeat(METADATA_SOURCE_MAX_CHARS + 1); let response = app.handle_pane_report_metadata("req".into(), params); assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); } #[test] fn pane_report_metadata_rejects_invalid_applies_to_source() { let (mut app, pane_id) = app_with_test_workspace(); let mut params = metadata_params(pane_id); params.applies_to_source = Some("herdr source".into()); let response = app.handle_pane_report_metadata("req".into(), params); assert_eq!(metadata_error_code(&response), "invalid_metadata_source"); } #[test] fn pane_report_metadata_rejects_ttl_outside_supported_range() { let (mut app, pane_id) = app_with_test_workspace(); for ttl_ms in [0, METADATA_TTL_MAX_MS + 1] { let mut params = metadata_params(pane_id.clone()); params.ttl_ms = Some(ttl_ms); let response = app.handle_pane_report_metadata("req".into(), params); assert_eq!(metadata_error_code(&response), "invalid_metadata_ttl"); } } }