aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:19:54 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-21 20:22:57 +0300
commit866aaf740e10fcfce169aaf81380ea0257fbe272 (patch)
treef815392b7c6b24a7c0de14e655bd635656383813 /src/app
parentca579c402d5668560ca13059f82c03178be18b2a (diff)
refactor: separate server client boundaries
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs72
-rw-r--r--src/app/agents.rs7
-rw-r--r--src/app/api.rs1339
-rw-r--r--src/app/api/agents.rs127
-rw-r--r--src/app/api/integrations.rs46
-rw-r--r--src/app/api/panes.rs353
-rw-r--r--src/app/api/responses.rs19
-rw-r--r--src/app/api/tabs.rs233
-rw-r--r--src/app/api/workspaces.rs168
-rw-r--r--src/app/creation.rs25
-rw-r--r--src/app/input/mod.rs33
-rw-r--r--src/app/input/modal.rs19
-rw-r--r--src/app/input/mouse.rs194
-rw-r--r--src/app/input/navigate.rs68
-rw-r--r--src/app/input/selection.rs72
-rw-r--r--src/app/input/terminal.rs84
-rw-r--r--src/app/mod.rs61
-rw-r--r--src/app/runtime.rs33
-rw-r--r--src/app/session.rs2
-rw-r--r--src/app/state.rs61
-rw-r--r--src/app/terminal_targets.rs6
-rw-r--r--src/app/theme_sync.rs2
22 files changed, 1511 insertions, 1513 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 139f7cd4..0c722125 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -461,11 +461,11 @@ impl AppState {
.any(|pane| pane.attached_terminal_id == terminal_id)
})
});
- if !still_attached {
- self.terminals.remove(&terminal_id);
- if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
- runtime.shutdown();
- }
+ if !still_attached
+ && self.terminals.remove(&terminal_id).is_some()
+ && !self.terminal_runtime_shutdowns.contains(&terminal_id)
+ {
+ self.terminal_runtime_shutdowns.push(terminal_id);
}
}
}
@@ -682,7 +682,7 @@ impl AppState {
self.selection_autoscroll = None;
}
- pub fn copy_selection(&mut self) {
+ pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
let mut sel = match self.selection.take() {
Some(sel) => sel,
None => return,
@@ -697,7 +697,7 @@ impl AppState {
};
let text = self
- .runtime_for_pane_in_workspace(ws_idx, sel.pane_id)
+ .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
.and_then(|rt| rt.extract_selection(&sel));
if let Some(text) = text {
@@ -717,7 +717,11 @@ impl AppState {
// ---------------------------------------------------------------------------
impl AppState {
- pub fn apply_workspace_git_statuses(&mut self, results: Vec<WorkspaceGitStatus>) -> bool {
+ pub fn apply_workspace_git_statuses(
+ &mut self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ results: Vec<WorkspaceGitStatus>,
+ ) -> bool {
let mut changed = false;
for result in results {
let Some(ws_idx) = self
@@ -729,7 +733,7 @@ impl AppState {
};
if self.workspaces[ws_idx]
- .resolved_identity_cwd_from(&self.terminals, &self.terminal_runtimes)
+ .resolved_identity_cwd_from(&self.terminals, terminal_runtimes)
.as_ref()
!= Some(&result.resolved_identity_cwd)
{
@@ -833,7 +837,7 @@ impl AppState {
// dispatch; never touches AppState.
AppEvent::ClipboardWrite { .. } => Vec::new(),
AppEvent::GitStatusRefreshed { results } => {
- self.apply_workspace_git_statuses(results);
+ let _ = results;
Vec::new()
}
}
@@ -1019,12 +1023,16 @@ mod tests {
let first_cwd = state.workspaces[0].resolved_identity_cwd().unwrap();
let second_id = state.workspaces[1].id.clone();
- let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
- workspace_id: first_id,
- resolved_identity_cwd: first_cwd,
- branch: Some("main".into()),
- ahead_behind: Some((2, 1)),
- }]);
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let changed = state.apply_workspace_git_statuses(
+ &terminal_runtimes,
+ vec![WorkspaceGitStatus {
+ workspace_id: first_id,
+ resolved_identity_cwd: first_cwd,
+ branch: Some("main".into()),
+ ahead_behind: Some((2, 1)),
+ }],
+ );
assert!(changed);
assert_eq!(state.workspaces[0].branch().as_deref(), Some("main"));
@@ -1040,12 +1048,16 @@ mod tests {
state.workspaces[0].cached_git_branch = Some("old".into());
state.workspaces[0].cached_git_ahead_behind = Some((1, 0));
- let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
- workspace_id,
- resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
- branch: Some("main".into()),
- ahead_behind: Some((0, 1)),
- }]);
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let changed = state.apply_workspace_git_statuses(
+ &terminal_runtimes,
+ vec![WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: std::path::PathBuf::from("/definitely/not/current"),
+ branch: Some("main".into()),
+ ahead_behind: Some((0, 1)),
+ }],
+ );
assert!(!changed);
assert_eq!(state.workspaces[0].branch().as_deref(), Some("old"));
@@ -1060,12 +1072,16 @@ mod tests {
state.workspaces[0].cached_git_branch = Some("main".into());
state.workspaces[0].cached_git_ahead_behind = Some((1, 2));
- let changed = state.apply_workspace_git_statuses(vec![WorkspaceGitStatus {
- workspace_id,
- resolved_identity_cwd: cwd,
- branch: None,
- ahead_behind: None,
- }]);
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let changed = state.apply_workspace_git_statuses(
+ &terminal_runtimes,
+ vec![WorkspaceGitStatus {
+ workspace_id,
+ resolved_identity_cwd: cwd,
+ branch: None,
+ ahead_behind: None,
+ }],
+ );
assert!(changed);
assert_eq!(state.workspaces[0].branch(), None);
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 15b93674..f36ba1ca 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -336,9 +336,7 @@ impl App {
self.render_dirty.clone(),
)
.map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
- self.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
self.state.workspaces.push(ws);
let ws_idx = self.state.workspaces.len() - 1;
@@ -386,8 +384,7 @@ impl App {
target: target_pane.raw().to_string(),
})?
.map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
- self.state
- .terminal_runtimes
+ self.terminal_runtimes
.insert(result.1.terminal.id.clone(), result.1.runtime);
self.state
.terminals
diff --git a/src/app/api.rs b/src/app/api.rs
index 6eb95f03..c8ae530a 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1,13 +1,14 @@
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
-use super::{
- api_helpers::{
- detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status,
- normalize_reported_agent_label, pane_agent_status,
- },
- App, Mode, OverlayPaneState, ToastKind,
-};
+mod agents;
+mod integrations;
+mod panes;
+mod responses;
+mod tabs;
+mod workspaces;
+
+use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind};
use crate::events::AppEvent;
impl App {
@@ -20,7 +21,10 @@ impl App {
if let AppEvent::GitStatusRefreshed { results } = ev {
self.git_refresh_in_flight = false;
self.last_git_remote_status_refresh = Instant::now();
- if self.state.apply_workspace_git_statuses(results) {
+ if self
+ .state
+ .apply_workspace_git_statuses(&self.terminal_runtimes, results)
+ {
self.render_dirty.store(true, Ordering::Release);
self.render_notify.notify_one();
}
@@ -75,8 +79,11 @@ impl App {
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
- if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)
- {
+ if let Some(runtime) = self.state.runtime_for_pane_in_workspace(
+ &self.terminal_runtimes,
+ ws_idx,
+ pane_id,
+ ) {
runtime.begin_graceful_release(agent);
}
}
@@ -156,6 +163,7 @@ impl App {
}
self.sync_toast_deadline(previous_toast);
+ self.shutdown_detached_terminal_runtimes();
}
fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
@@ -293,12 +301,10 @@ impl App {
pane_id: crate::layout::PaneId,
event: crate::ghostty::FocusEvent,
) {
- let Some(runtime) = self
- .state
- .workspaces
- .get(ws_idx)
- .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id))
- else {
+ let Some(runtime) = self.state.workspaces.get(ws_idx).and_then(|_| {
+ self.state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
+ }) else {
return;
};
runtime.try_send_focus_event(event);
@@ -306,13 +312,7 @@ impl App {
pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
self.drain_internal_events();
- use bytes::Bytes;
-
- use crate::api::schema::{
- ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationUninstallResult, Method,
- PaneListParams, PaneReadResult, ReadFormat, ReadSource, ResponseResult,
- SuccessResponse, TabListParams,
- };
+ use crate::api::schema::{Method, ResponseResult, SuccessResponse};
let response = match request.method {
Method::ServerStop(_) => {
@@ -332,1280 +332,65 @@ impl App {
},
}
}
- Method::WorkspaceList(_) => SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceList {
- workspaces: self
- .state
- .workspaces
- .iter()
- .enumerate()
- .map(|(idx, _)| self.workspace_info(idx))
- .collect(),
- },
- },
- Method::WorkspaceGet(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- let Some(_) = self.state.workspaces.get(index) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceInfo {
- workspace: self.workspace_info(index),
- },
- }
- }
+ Method::WorkspaceList(_) => return self.handle_workspace_list(request.id),
+ Method::WorkspaceGet(target) => return self.handle_workspace_get(request.id, target),
Method::WorkspaceCreate(params) => {
- let cwd = params
- .cwd
- .map(std::path::PathBuf::from)
- .or_else(|| std::env::current_dir().ok())
- .unwrap_or_else(|| std::path::PathBuf::from("/"));
- match self.create_workspace_with_options(cwd, params.focus) {
- Ok(index) => {
- if let Some(label) = params.label {
- if let Some(workspace) = self.state.workspaces.get_mut(index) {
- workspace.set_custom_name(label);
- crate::logging::workspace_renamed(&workspace.id);
- }
- }
- let workspace = self.workspace_info(index);
- let tab = self
- .tab_info(index, 0)
- .expect("new workspace should have an initial tab");
- let root_pane = self
- .root_pane_info(index, 0)
- .expect("new workspace should have an initial root pane");
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceCreated,
- data: crate::api::schema::EventData::WorkspaceCreated {
- workspace: workspace.clone(),
- },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabCreated,
- data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated {
- pane: root_pane.clone(),
- },
- });
- SuccessResponse {
- id: request.id,
- result: self
- .workspace_created_result(index)
- .expect("new workspace should produce a complete create response"),
- }
- }
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_create_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
+ return self.handle_workspace_create(request.id, params);
}
Method::WorkspaceFocus(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- if self.state.workspaces.get(index).is_none() {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- }
- self.state.switch_workspace(index);
- SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceInfo {
- workspace: self.workspace_info(index),
- },
- }
+ return self.handle_workspace_focus(request.id, target)
}
Method::WorkspaceRename(params) => {
- let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", params.workspace_id),
- },
- })
- .unwrap();
- };
- let Some(ws) = self.state.workspaces.get_mut(index) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", params.workspace_id),
- },
- })
- .unwrap();
- };
- ws.set_custom_name(params.label.clone());
- crate::logging::workspace_renamed(&ws.id);
- self.schedule_session_save();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceRenamed,
- data: crate::api::schema::EventData::WorkspaceRenamed {
- workspace_id: self.public_workspace_id(index),
- label: params.label,
- },
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::WorkspaceInfo {
- workspace: self.workspace_info(index),
- },
- }
+ return self.handle_workspace_rename(request.id, params);
}
Method::WorkspaceClose(target) => {
- let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- };
- if self.state.workspaces.get(index).is_none() {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", target.workspace_id),
- },
- })
- .unwrap();
- }
- self.state.selected = index;
- self.state.close_selected_workspace();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceClosed,
- data: crate::api::schema::EventData::WorkspaceClosed {
- workspace_id: target.workspace_id,
- },
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::TabList(TabListParams { workspace_id }) => {
- let tabs = if let Some(workspace_id) = workspace_id {
- let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", workspace_id),
- },
- })
- .unwrap();
- };
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", workspace_id),
- },
- })
- .unwrap();
- };
- (0..ws.tabs.len())
- .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx))
- .collect()
- } else {
- let mut tabs = Vec::new();
- for (ws_idx, ws) in self.state.workspaces.iter().enumerate() {
- for tab_idx in 0..ws.tabs.len() {
- if let Some(tab) = self.tab_info(ws_idx, tab_idx) {
- tabs.push(tab);
- }
- }
- }
- tabs
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::TabList { tabs },
- }
- }
- Method::TabGet(target) => {
- let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", target.tab_id),
- },
- })
- .unwrap();
- };
- let Some(tab) = self.tab_info(ws_idx, tab_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", target.tab_id),
- },
- })
- .unwrap();
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::TabInfo { tab },
- }
- }
- Method::TabCreate(params) => {
- let crate::api::schema::TabCreateParams {
- workspace_id,
- cwd,
- focus,
- label,
- } = params;
- let ws_idx = if let Some(workspace_id) = workspace_id {
- let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: format!("workspace {} not found", workspace_id),
- },
- })
- .unwrap();
- };
- ws_idx
- } else if let Some(active) = self.state.active {
- active
- } else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "workspace_not_found".into(),
- message: "no active workspace".into(),
- },
- })
- .unwrap();
- };
- let cwd = cwd
- .map(std::path::PathBuf::from)
- .or_else(|| {
- self.state
- .focused_runtime_in_workspace(ws_idx)
- .and_then(|rt| rt.cwd())
- })
- .or_else(|| std::env::current_dir().ok())
- .unwrap_or_else(|| std::path::PathBuf::from("/"));
- let (rows, cols) = self.state.estimate_pane_size();
- 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 result = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .ok_or_else(|| std::io::Error::other("workspace disappeared"))
- .and_then(|ws| {
- ws.create_tab(
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- &default_shell,
- )
- });
- match result {
- Ok((tab_idx, terminal, runtime)) => {
- self.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
- self.state.terminals.insert(terminal.id.clone(), terminal);
- if let Some(label) = label {
- let workspace_id = self.state.workspaces[ws_idx].id.clone();
- let tab_id = self
- .public_tab_id(ws_idx, tab_idx)
- .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1));
- if let Some(tab) = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- {
- tab.set_custom_name(label);
- crate::logging::tab_renamed(&workspace_id, &tab_id);
- }
- }
- if focus {
- self.state.switch_workspace(ws_idx);
- self.state.switch_tab(tab_idx);
- self.state.mode = Mode::Terminal;
- }
- self.schedule_session_save();
- let tab = self.tab_info(ws_idx, tab_idx).unwrap();
- let root_pane = self
- .root_pane_info(ws_idx, tab_idx)
- .expect("new tab should have a root pane");
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabCreated,
- data: crate::api::schema::EventData::TabCreated { tab: tab.clone() },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated {
- pane: root_pane.clone(),
- },
- });
- SuccessResponse {
- id: request.id,
- result: self
- .tab_created_result(ws_idx, tab_idx)
- .expect("new tab should produce a complete create response"),
- }
- }
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_create_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- }
- Method::TabFocus(target) => {
- let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", target.tab_id),
- },
- })
- .unwrap();
- };
- self.state.switch_workspace(ws_idx);
- self.state.switch_tab(tab_idx);
- let tab = self.tab_info(ws_idx, tab_idx).unwrap();
- SuccessResponse {
- id: request.id,
- result: ResponseResult::TabInfo { tab },
- }
- }
- Method::TabRename(params) => {
- let Some((ws_idx, tab_idx)) = self.parse_tab_id(&params.tab_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", params.tab_id),
- },
- })
- .unwrap();
- };
- let workspace_id = self.state.workspaces[ws_idx].id.clone();
- let tab_id = self
- .public_tab_id(ws_idx, tab_idx)
- .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1));
- let Some(tab) = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", params.tab_id),
- },
- })
- .unwrap();
- };
- tab.set_custom_name(params.label.clone());
- crate::logging::tab_renamed(&workspace_id, &tab_id);
- self.schedule_session_save();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabRenamed,
- data: crate::api::schema::EventData::TabRenamed {
- tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(),
- workspace_id: self.public_workspace_id(ws_idx),
- label: params.label,
- },
- });
- let tab = self.tab_info(ws_idx, tab_idx).unwrap();
- SuccessResponse {
- id: request.id,
- result: ResponseResult::TabInfo { tab },
- }
- }
- Method::TabClose(target) => {
- let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", target.tab_id),
- },
- })
- .unwrap();
- };
- let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
- let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_not_found".into(),
- message: format!("tab {} not found", target.tab_id),
- },
- })
- .unwrap();
- };
- if ws.tabs.len() <= 1 {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_close_failed".into(),
- message: "cannot close the last tab in a workspace".into(),
- },
- })
- .unwrap();
- }
- if !ws.close_tab(tab_idx) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "tab_close_failed".into(),
- message: format!("tab {} could not be closed", target.tab_id),
- },
- })
- .unwrap();
- }
- self.state.remove_unattached_terminal_ids(terminal_ids);
- self.schedule_session_save();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::TabClosed,
- data: crate::api::schema::EventData::TabClosed {
- tab_id: target.tab_id,
- workspace_id: self.public_workspace_id(ws_idx),
- },
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::AgentList(_) => SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentList {
- agents: self.collect_agent_infos(),
- },
- },
- Method::AgentGet(target) => {
- let agent = match self.agent_info_for_target(&target.target) {
- Ok(agent) => agent,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentInfo { agent },
- }
- }
- Method::AgentFocus(target) => {
- let agent = match self.focus_agent_target(&target.target) {
- Ok(agent) => agent,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentInfo { agent },
- }
- }
- Method::AgentRename(params) => {
- let agent = match self.rename_agent_target(&params.target, params.name) {
- Ok(agent) => agent,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_rename_error_body(err),
- })
- .unwrap();
- }
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentInfo { agent },
- }
- }
- Method::AgentStart(params) => {
- let (agent, argv) = match self.start_agent(params) {
- Ok(started) => started,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_start_error_body(err),
- })
- .unwrap();
- }
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::AgentStarted { agent, argv },
- }
- }
- Method::AgentRead(params) => {
- let resolved = match self.resolve_terminal_target(&params.target) {
- Ok(resolved) => resolved,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- let Some((pane, workspace_id)) =
- self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_not_found".into(),
- message: format!("agent target {} not found", params.target),
- },
- })
- .unwrap();
- };
- let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
- let text = match params.format {
- ReadFormat::Text => match params.source {
- ReadSource::Visible => pane.visible_text(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- },
- ReadFormat::Ansi => match params.source {
- ReadSource::Visible => pane.visible_ansi(),
- ReadSource::Recent => pane.recent_ansi(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
- },
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneRead {
- read: PaneReadResult {
- pane_id: self
- .public_pane_id(resolved.ws_idx, resolved.pane_id)
- .unwrap_or_else(|| params.target.clone()),
- workspace_id,
- tab_id: self
- .public_tab_id(resolved.ws_idx, resolved.tab_idx)
- .unwrap(),
- source: params.source,
- format: params.format,
- text,
- revision: 0,
- truncated: false,
- },
- },
- }
- }
- Method::AgentSend(params) => {
- let resolved = match self.resolve_terminal_target(&params.target) {
- Ok(resolved) => resolved,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: self.agent_target_error_body(err),
- })
- .unwrap();
- }
- };
- let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id)
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_not_found".into(),
- message: format!("agent target {} not found", params.target),
- },
- })
- .unwrap();
- };
- if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "agent_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSplit(params) => {
- let Some((ws_idx, target_pane_id)) = self.parse_pane_id(&params.target_pane_id)
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.target_pane_id),
- },
- })
- .unwrap();
- };
- let (rows, cols) = self.state.estimate_pane_size();
- let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
- self.state.workspaces.get(ws_idx).and_then(|ws| {
- let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?;
- ws.tabs.get(tab_idx)?.cwd_for_pane(
- target_pane_id,
- &self.state.terminals,
- &self.state.terminal_runtimes,
- )
- })
- });
- 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 Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.target_pane_id),
- },
- })
- .unwrap();
- };
- let direction = match params.direction {
- crate::api::schema::SplitDirection::Right => {
- ratatui::layout::Direction::Horizontal
- }
- crate::api::schema::SplitDirection::Down => {
- ratatui::layout::Direction::Vertical
- }
- };
- let (target_tab_idx, new_pane) = match ws.split_pane(
- target_pane_id,
- direction,
- rows,
- cols,
- split_cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- &default_shell,
- params.focus,
- ) {
- Some(Ok(result)) => result,
- Some(Err(err)) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_split_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- None => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.target_pane_id),
- },
- })
- .unwrap();
- }
- };
- if params.focus {
- self.state.switch_workspace(ws_idx);
- self.state.switch_tab(target_tab_idx);
- self.state.mode = Mode::Terminal;
- }
- self.state
- .terminal_runtimes
- .insert(new_pane.terminal.id.clone(), new_pane.runtime);
- 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(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneCreated,
- data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() },
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneInfo { pane },
- }
- }
- Method::PaneList(PaneListParams { workspace_id }) => {
- match self.collect_panes_for_workspace(workspace_id.as_deref()) {
- Ok(panes) => SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneList { panes },
- },
- Err((code, message)) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody { code, message },
- })
- .unwrap();
- }
- }
- }
- Method::PaneGet(target) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- let Some(pane) = self.pane_info(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneInfo { pane },
- }
- }
- Method::PaneRename(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(terminal_id) = self
- .state
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.terminal_id(pane_id))
- .cloned()
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- 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();
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneInfo { pane },
- }
- }
- Method::PaneRead(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(tab_idx) = self
- .state
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.find_tab_index_for_pane(pane_id))
- else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
- let text = match params.format {
- ReadFormat::Text => match params.source {
- ReadSource::Visible => pane.visible_text(),
- ReadSource::Recent => pane.recent_text(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
- },
- ReadFormat::Ansi => match params.source {
- ReadSource::Visible => pane.visible_ansi(),
- ReadSource::Recent => pane.recent_ansi(requested_lines),
- ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
- },
- };
- SuccessResponse {
- id: request.id,
- result: ResponseResult::PaneRead {
- read: PaneReadResult {
- pane_id: params.pane_id,
- workspace_id,
- tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(),
- source: params.source,
- format: params.format,
- text,
- revision: 0,
- truncated: false,
- },
- },
- }
- }
+ return self.handle_workspace_close(request.id, target)
+ }
+ Method::TabList(params) => return self.handle_tab_list(request.id, params),
+ Method::TabGet(target) => return self.handle_tab_get(request.id, target),
+ Method::TabCreate(params) => return self.handle_tab_create(request.id, params),
+ Method::TabFocus(target) => return self.handle_tab_focus(request.id, target),
+ Method::TabRename(params) => return self.handle_tab_rename(request.id, params),
+ Method::TabClose(target) => return self.handle_tab_close(request.id, target),
+ Method::AgentList(_) => return self.handle_agent_list(request.id),
+ Method::AgentGet(target) => return self.handle_agent_get(request.id, target),
+ Method::AgentFocus(target) => return self.handle_agent_focus(request.id, target),
+ Method::AgentRename(params) => return self.handle_agent_rename(request.id, params),
+ Method::AgentStart(params) => return self.handle_agent_start(request.id, params),
+ Method::AgentRead(params) => return self.handle_agent_read(request.id, params),
+ Method::AgentSend(params) => return self.handle_agent_send(request.id, params),
+ Method::PaneSplit(params) => return self.handle_pane_split(request.id, params),
+ Method::PaneList(params) => return self.handle_pane_list(request.id, params),
+ Method::PaneGet(target) => return self.handle_pane_get(request.id, target),
+ Method::PaneRename(params) => return self.handle_pane_rename(request.id, params),
+ Method::PaneRead(params) => return self.handle_pane_read(request.id, params),
Method::PaneReportAgent(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_agent".into(),
- message: "agent label must not be empty".into(),
- },
- })
- .unwrap();
- };
- self.handle_internal_event(crate::events::AppEvent::HookStateReported {
- pane_id,
- source: params.source,
- agent_label,
- state: detect_state_from_api(params.state),
- message: params.message,
- custom_status: normalize_custom_status(params.custom_status),
- seq: params.seq,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
+ return self.handle_pane_report_agent(request.id, params);
}
Method::PaneClearAgentAuthority(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared {
- pane_id,
- source: params.source,
- seq: params.seq,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
+ return self.handle_pane_clear_agent_authority(request.id, params);
}
Method::PaneReleaseAgent(params) => {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_agent".into(),
- message: "agent label must not be empty".into(),
- },
- })
- .unwrap();
- };
- 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,
- });
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSendText(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
+ return self.handle_pane_release_agent(request.id, params);
}
+ Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params),
Method::PaneSendInput(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let encoded_keys = match encode_api_keys(runtime, &params.keys) {
- Ok(encoded_keys) => encoded_keys,
- Err(key) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_key".into(),
- message: format!("unsupported key {key}"),
- },
- })
- .unwrap();
- }
- };
- if !params.text.is_empty() {
- let text_bytes = encode_api_text(runtime, &params.text);
- if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- for bytes in encoded_keys {
- if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneClose(target) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- let workspace_id = self.state.workspaces[ws_idx].id.clone();
- 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 serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", target.pane_id),
- },
- })
- .unwrap();
- };
- ws.close_pane(pane_id)
- };
- if should_close_workspace {
- self.state.selected = ws_idx;
- self.state.close_selected_workspace();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneClosed,
- data: crate::api::schema::EventData::PaneClosed {
- pane_id: target.pane_id.clone(),
- workspace_id: workspace_id.clone(),
- },
- });
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::WorkspaceClosed,
- data: crate::api::schema::EventData::WorkspaceClosed { workspace_id },
- });
- } else {
- self.state.remove_unattached_terminal_ids(terminal_id);
- self.schedule_session_save();
- self.emit_event(crate::api::schema::EventEnvelope {
- event: crate::api::schema::EventKind::PaneClosed,
- data: crate::api::schema::EventData::PaneClosed {
- pane_id: target.pane_id,
- workspace_id,
- },
- });
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
- }
- Method::PaneSendKeys(params) => {
- let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_not_found".into(),
- message: format!("pane {} not found", params.pane_id),
- },
- })
- .unwrap();
- };
- let encoded_keys = match encode_api_keys(runtime, &params.keys) {
- Ok(encoded_keys) => encoded_keys,
- Err(key) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "invalid_key".into(),
- message: format!("unsupported key {key}"),
- },
- })
- .unwrap();
- }
- };
- for bytes in encoded_keys {
- if let Err(err) = runtime.try_send_bytes(Bytes::from(bytes)) {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "pane_send_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- }
- SuccessResponse {
- id: request.id,
- result: ResponseResult::Ok {},
- }
+ return self.handle_pane_send_input(request.id, params)
}
+ Method::PaneClose(target) => return self.handle_pane_close(request.id, target),
+ Method::PaneSendKeys(params) => return self.handle_pane_send_keys(request.id, params),
Method::IntegrationInstall(params) => {
- let target = params.target;
- let messages = match crate::integration::install_target(target) {
- Ok(messages) => messages,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "integration_install_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- };
-
- SuccessResponse {
- id: request.id,
- result: ResponseResult::IntegrationInstall {
- target,
- details: IntegrationInstallResult { messages },
- },
- }
+ return self.handle_integration_install(request.id, params);
}
Method::IntegrationUninstall(params) => {
- let target = params.target;
- let messages = match crate::integration::uninstall_target(target) {
- Ok(messages) => messages,
- Err(err) => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "integration_uninstall_failed".into(),
- message: err.to_string(),
- },
- })
- .unwrap();
- }
- };
-
- SuccessResponse {
- id: request.id,
- result: ResponseResult::IntegrationUninstall {
- target,
- details: IntegrationUninstallResult { messages },
- },
- }
+ return self.handle_integration_uninstall(request.id, params);
}
_ => {
- return serde_json::to_string(&ErrorResponse {
- id: request.id,
- error: ErrorBody {
- code: "not_implemented".into(),
- message: "method not implemented yet".into(),
- },
- })
- .unwrap();
+ return responses::encode_error(
+ request.id,
+ "not_implemented",
+ "method not implemented yet",
+ );
}
};
diff --git a/src/app/api/agents.rs b/src/app/api/agents.rs
new file mode 100644
index 00000000..4077b38c
--- /dev/null
+++ b/src/app/api/agents.rs
@@ -0,0 +1,127 @@
+use bytes::Bytes;
+
+use crate::api::schema::{
+ AgentRenameParams, AgentSendParams, AgentStartParams, AgentTarget, PaneReadResult, ReadFormat,
+ ReadSource, ResponseResult,
+};
+use crate::app::App;
+
+use super::responses::{encode_error, encode_error_body, encode_success};
+
+impl App {
+ pub(super) fn handle_agent_list(&mut self, id: String) -> String {
+ encode_success(
+ id,
+ ResponseResult::AgentList {
+ agents: self.collect_agent_infos(),
+ },
+ )
+ }
+
+ pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String {
+ let agent = match self.agent_info_for_target(&target.target) {
+ Ok(agent) => agent,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+
+ encode_success(id, ResponseResult::AgentInfo { agent })
+ }
+
+ pub(super) fn handle_agent_focus(&mut self, id: String, target: AgentTarget) -> String {
+ let agent = match self.focus_agent_target(&target.target) {
+ Ok(agent) => agent,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+
+ encode_success(id, ResponseResult::AgentInfo { agent })
+ }
+
+ pub(super) fn handle_agent_rename(&mut self, id: String, params: AgentRenameParams) -> String {
+ let agent = match self.rename_agent_target(&params.target, params.name) {
+ Ok(agent) => agent,
+ Err(err) => return encode_error_body(id, self.agent_rename_error_body(err)),
+ };
+
+ encode_success(id, ResponseResult::AgentInfo { agent })
+ }
+
+ pub(super) fn handle_agent_start(&mut self, id: String, params: AgentStartParams) -> String {
+ let (agent, argv) = match self.start_agent(params) {
+ Ok(started) => started,
+ Err(err) => return encode_error_body(id, self.agent_start_error_body(err)),
+ };
+
+ encode_success(id, ResponseResult::AgentStarted { agent, argv })
+ }
+
+ pub(super) fn handle_agent_read(
+ &mut self,
+ id: String,
+ params: crate::api::schema::AgentReadParams,
+ ) -> String {
+ let resolved = match self.resolve_terminal_target(&params.target) {
+ Ok(resolved) => resolved,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+ let Some((pane, workspace_id)) = self.lookup_runtime(resolved.ws_idx, resolved.pane_id)
+ else {
+ return agent_not_found(id, &params.target);
+ };
+ let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
+ let text = match params.format {
+ ReadFormat::Text => match params.source {
+ ReadSource::Visible => pane.visible_text(),
+ ReadSource::Recent => pane.recent_text(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ },
+ ReadFormat::Ansi => match params.source {
+ ReadSource::Visible => pane.visible_ansi(),
+ ReadSource::Recent => pane.recent_ansi(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ },
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneRead {
+ read: PaneReadResult {
+ pane_id: self
+ .public_pane_id(resolved.ws_idx, resolved.pane_id)
+ .unwrap_or_else(|| params.target.clone()),
+ workspace_id,
+ tab_id: self
+ .public_tab_id(resolved.ws_idx, resolved.tab_idx)
+ .unwrap(),
+ source: params.source,
+ format: params.format,
+ text,
+ revision: 0,
+ truncated: false,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_agent_send(&mut self, id: String, params: AgentSendParams) -> String {
+ let resolved = match self.resolve_terminal_target(&params.target) {
+ Ok(resolved) => resolved,
+ Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
+ };
+ let Some(runtime) = self.lookup_runtime_sender(resolved.ws_idx, resolved.pane_id) else {
+ return agent_not_found(id, &params.target);
+ };
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(params.text)) {
+ return encode_error(id, "agent_send_failed", err.to_string());
+ }
+
+ encode_success(id, ResponseResult::Ok {})
+ }
+}
+
+fn agent_not_found(id: String, target: &str) -> String {
+ encode_error(
+ id,
+ "agent_not_found",
+ format!("agent target {target} not found"),
+ )
+}
diff --git a/src/app/api/integrations.rs b/src/app/api/integrations.rs
new file mode 100644
index 00000000..0cca8cba
--- /dev/null
+++ b/src/app/api/integrations.rs
@@ -0,0 +1,46 @@
+use crate::api::schema::{IntegrationInstallResult, IntegrationUninstallResult, ResponseResult};
+use crate::app::App;
+
+use super::responses::{encode_error, encode_success};
+
+impl App {
+ pub(super) fn handle_integration_install(
+ &mut self,
+ id: String,
+ params: crate::api::schema::IntegrationInstallParams,
+ ) -> String {
+ let target = params.target;
+ let messages = match crate::integration::install_target(target) {
+ Ok(messages) => messages,
+ Err(err) => return encode_error(id, "integration_install_failed", err.to_string()),
+ };
+
+ encode_success(
+ id,
+ ResponseResult::IntegrationInstall {
+ target,
+ details: IntegrationInstallResult { messages },
+ },
+ )
+ }
+
+ pub(super) fn handle_integration_uninstall(
+ &mut self,
+ id: String,
+ params: crate::api::schema::IntegrationUninstallParams,
+ ) -> String {
+ let target = params.target;
+ let messages = match crate::integration::uninstall_target(target) {
+ Ok(messages) => messages,
+ Err(err) => return encode_error(id, "integration_uninstall_failed", err.to_string()),
+ };
+
+ encode_success(
+ id,
+ ResponseResult::IntegrationUninstall {
+ target,
+ details: IntegrationUninstallResult { messages },
+ },
+ )
+ }
+}
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
new file mode 100644
index 00000000..b09ecb2a
--- /dev/null
+++ b/src/app/api/panes.rs
@@ -0,0 +1,353 @@
+use bytes::Bytes;
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, PaneClearAgentAuthorityParams, PaneListParams,
+ PaneReadParams, PaneReadResult, PaneReleaseAgentParams, PaneRenameParams,
+ PaneReportAgentParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams,
+ PaneSplitParams, PaneTarget, ReadFormat, ReadSource, ResponseResult,
+};
+use crate::app::{App, Mode};
+
+use super::super::api_helpers::{
+ detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status,
+ normalize_reported_agent_label,
+};
+use super::responses::{encode_error, encode_success};
+
+impl App {
+ pub(super) fn handle_pane_split(&mut self, id: String, params: PaneSplitParams) -> String {
+ let Some((ws_idx, target_pane_id)) = self.parse_pane_id(&params.target_pane_id) else {
+ return pane_not_found(id, &params.target_pane_id);
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let split_cwd = params.cwd.map(std::path::PathBuf::from).or_else(|| {
+ self.state.workspaces.get(ws_idx).and_then(|ws| {
+ let tab_idx = ws.find_tab_index_for_pane(target_pane_id)?;
+ ws.tabs.get(tab_idx)?.cwd_for_pane(
+ target_pane_id,
+ &self.state.terminals,
+ &self.terminal_runtimes,
+ )
+ })
+ });
+ 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 Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return pane_not_found(id, &params.target_pane_id);
+ };
+ let direction = match params.direction {
+ crate::api::schema::SplitDirection::Right => ratatui::layout::Direction::Horizontal,
+ crate::api::schema::SplitDirection::Down => ratatui::layout::Direction::Vertical,
+ };
+ let (target_tab_idx, new_pane) = match ws.split_pane(
+ target_pane_id,
+ direction,
+ rows,
+ cols,
+ split_cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ &default_shell,
+ params.focus,
+ ) {
+ Some(Ok(result)) => result,
+ Some(Err(err)) => return encode_error(id, "pane_split_failed", err.to_string()),
+ None => return pane_not_found(id, &params.target_pane_id),
+ };
+ if params.focus {
+ self.state.switch_workspace(ws_idx);
+ self.state.switch_tab(target_tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.terminal_runtimes
+ .insert(new_pane.terminal.id.clone(), new_pane.runtime);
+ 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() },
+ });
+
+ 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_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_rename(&mut self, id: String, params: PaneRenameParams) -> String {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ 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, &params.pane_id);
+ };
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some((pane, workspace_id)) = self.lookup_runtime(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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, &params.pane_id);
+ };
+ let requested_lines = params.lines.unwrap_or(80).min(1000) as usize;
+ let text = match params.format {
+ ReadFormat::Text => match params.source {
+ ReadSource::Visible => pane.visible_text(),
+ ReadSource::Recent => pane.recent_text(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_text(requested_lines),
+ },
+ ReadFormat::Ansi => match params.source {
+ ReadSource::Visible => pane.visible_ansi(),
+ ReadSource::Recent => pane.recent_ansi(requested_lines),
+ ReadSource::RecentUnwrapped => pane.recent_unwrapped_ansi(requested_lines),
+ },
+ };
+
+ encode_success(
+ id,
+ ResponseResult::PaneRead {
+ read: PaneReadResult {
+ pane_id: params.pane_id,
+ workspace_id,
+ tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(),
+ source: params.source,
+ format: params.format,
+ text,
+ revision: 0,
+ truncated: false,
+ },
+ },
+ )
+ }
+
+ pub(super) fn handle_pane_report_agent(
+ &mut self,
+ id: String,
+ params: PaneReportAgentParams,
+ ) -> String {
+ let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
+ return invalid_agent(id);
+ };
+ self.handle_internal_event(crate::events::AppEvent::HookStateReported {
+ pane_id,
+ source: params.source,
+ agent_label,
+ state: detect_state_from_api(params.state),
+ message: params.message,
+ custom_status: normalize_custom_status(params.custom_status),
+ seq: params.seq,
+ });
+
+ 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(&params.pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(agent_label) = normalize_reported_agent_label(&params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.keys) {
+ Ok(encoded_keys) => encoded_keys,
+ Err(key) => return encode_error(id, "invalid_key", format!("unsupported key {key}")),
+ };
+ if !params.text.is_empty() {
+ let text_bytes = encode_api_text(runtime, &params.text);
+ if let Err(err) = runtime.try_send_bytes(Bytes::from(text_bytes)) {
+ return encode_error(id, "pane_send_failed", err.to_string());
+ }
+ }
+ 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 {})
+ }
+
+ pub(super) fn handle_pane_close(&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 workspace_id = self.state.workspaces[ws_idx].id.clone();
+ 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 pane_not_found(id, &target.pane_id);
+ };
+ ws.close_pane(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: target.pane_id.clone(),
+ workspace_id: workspace_id.clone(),
+ },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed { workspace_id },
+ });
+ } 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: target.pane_id,
+ workspace_id,
+ },
+ });
+ }
+
+ encode_success(id, ResponseResult::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(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let Some(runtime) = self.lookup_runtime_sender(ws_idx, pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let encoded_keys = match encode_api_keys(runtime, &params.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 pane_not_found(id: String, pane_id: &str) -> String {
+ encode_error(id, "pane_not_found", format!("pane {pane_id} not found"))
+}
+
+fn invalid_agent(id: String) -> String {
+ encode_error(id, "invalid_agent", "agent label must not be empty")
+}
diff --git a/src/app/api/responses.rs b/src/app/api/responses.rs
new file mode 100644
index 00000000..7c871c37
--- /dev/null
+++ b/src/app/api/responses.rs
@@ -0,0 +1,19 @@
+use crate::api::schema::{ErrorBody, ErrorResponse, ResponseResult, SuccessResponse};
+
+pub(super) fn encode_success(id: String, result: ResponseResult) -> String {
+ serde_json::to_string(&SuccessResponse { id, result }).unwrap()
+}
+
+pub(super) fn encode_error(id: String, code: &str, message: impl Into<String>) -> String {
+ encode_error_body(
+ id,
+ ErrorBody {
+ code: code.into(),
+ message: message.into(),
+ },
+ )
+}
+
+pub(super) fn encode_error_body(id: String, error: ErrorBody) -> String {
+ serde_json::to_string(&ErrorResponse { id, error }).unwrap()
+}
diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs
new file mode 100644
index 00000000..7978a17f
--- /dev/null
+++ b/src/app/api/tabs.rs
@@ -0,0 +1,233 @@
+use std::path::PathBuf;
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, ResponseResult, TabCreateParams, TabListParams,
+ TabRenameParams, TabTarget,
+};
+use crate::app::{App, Mode};
+
+use super::responses::{encode_error, encode_success};
+
+impl App {
+ pub(super) fn handle_tab_list(&mut self, id: String, params: TabListParams) -> String {
+ let tabs = if let Some(workspace_id) = params.workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ return workspace_not_found(id, &workspace_id);
+ };
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return workspace_not_found(id, &workspace_id);
+ };
+ (0..ws.tabs.len())
+ .filter_map(|tab_idx| self.tab_info(ws_idx, tab_idx))
+ .collect()
+ } else {
+ let mut tabs = Vec::new();
+ for (ws_idx, ws) in self.state.workspaces.iter().enumerate() {
+ for tab_idx in 0..ws.tabs.len() {
+ if let Some(tab) = self.tab_info(ws_idx, tab_idx) {
+ tabs.push(tab);
+ }
+ }
+ }
+ tabs
+ };
+
+ encode_success(id, ResponseResult::TabList { tabs })
+ }
+
+ pub(super) fn handle_tab_get(&mut self, id: String, target: TabTarget) -> String {
+ let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
+ return tab_not_found(id, &target.tab_id);
+ };
+ let Some(tab) = self.tab_info(ws_idx, tab_idx) else {
+ return tab_not_found(id, &target.tab_id);
+ };
+
+ encode_success(id, ResponseResult::TabInfo { tab })
+ }
+
+ pub(super) fn handle_tab_create(&mut self, id: String, params: TabCreateParams) -> String {
+ let TabCreateParams {
+ workspace_id,
+ cwd,
+ focus,
+ label,
+ } = params;
+ let ws_idx = if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else {
+ return workspace_not_found(id, &workspace_id);
+ };
+ ws_idx
+ } else if let Some(active) = self.state.active {
+ active
+ } else {
+ return encode_error(id, "workspace_not_found", "no active workspace");
+ };
+ let cwd = cwd
+ .map(PathBuf::from)
+ .or_else(|| {
+ self.state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ .and_then(|rt| rt.cwd())
+ })
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| PathBuf::from("/"));
+ let (rows, cols) = self.state.estimate_pane_size();
+ 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 result = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("workspace disappeared"))
+ .and_then(|ws| {
+ ws.create_tab(
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ &default_shell,
+ )
+ });
+ match result {
+ Ok((tab_idx, terminal, runtime)) => {
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
+ self.state.terminals.insert(terminal.id.clone(), terminal);
+ if let Some(label) = label {
+ let workspace_id = self.state.workspaces[ws_idx].id.clone();
+ let tab_id = self
+ .public_tab_id(ws_idx, tab_idx)
+ .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1));
+ if let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ tab.set_custom_name(label);
+ crate::logging::tab_renamed(&workspace_id, &tab_id);
+ }
+ }
+ if focus {
+ self.state.switch_workspace(ws_idx);
+ self.state.switch_tab(tab_idx);
+ self.state.mode = Mode::Terminal;
+ }
+ self.schedule_session_save();
+ let tab = self.tab_info(ws_idx, tab_idx).unwrap();
+ let root_pane = self
+ .root_pane_info(ws_idx, tab_idx)
+ .expect("new tab should have a root pane");
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab: tab.clone() },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneCreated,
+ data: EventData::PaneCreated {
+ pane: root_pane.clone(),
+ },
+ });
+ encode_success(
+ id,
+ self.tab_created_result(ws_idx, tab_idx)
+ .expect("new tab should produce a complete create response"),
+ )
+ }
+ Err(err) => encode_error(id, "tab_create_failed", err.to_string()),
+ }
+ }
+
+ pub(super) fn handle_tab_focus(&mut self, id: String, target: TabTarget) -> String {
+ let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
+ return tab_not_found(id, &target.tab_id);
+ };
+ self.state.switch_workspace(ws_idx);
+ self.state.switch_tab(tab_idx);
+ let tab = self.tab_info(ws_idx, tab_idx).unwrap();
+
+ encode_success(id, ResponseResult::TabInfo { tab })
+ }
+
+ pub(super) fn handle_tab_rename(&mut self, id: String, params: TabRenameParams) -> String {
+ let Some((ws_idx, tab_idx)) = self.parse_tab_id(&params.tab_id) else {
+ return tab_not_found(id, &params.tab_id);
+ };
+ let workspace_id = self.state.workspaces[ws_idx].id.clone();
+ let tab_id = self
+ .public_tab_id(ws_idx, tab_idx)
+ .unwrap_or_else(|| format!("{}:{}", workspace_id, tab_idx + 1));
+ let Some(tab) = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ else {
+ return tab_not_found(id, &params.tab_id);
+ };
+ tab.set_custom_name(params.label.clone());
+ crate::logging::tab_renamed(&workspace_id, &tab_id);
+ self.schedule_session_save();
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabRenamed,
+ data: EventData::TabRenamed {
+ tab_id: self.public_tab_id(ws_idx, tab_idx).unwrap(),
+ workspace_id: self.public_workspace_id(ws_idx),
+ label: params.label,
+ },
+ });
+ let tab = self.tab_info(ws_idx, tab_idx).unwrap();
+
+ encode_success(id, ResponseResult::TabInfo { tab })
+ }
+
+ pub(super) fn handle_tab_close(&mut self, id: String, target: TabTarget) -> String {
+ let Some((ws_idx, tab_idx)) = self.parse_tab_id(&target.tab_id) else {
+ return tab_not_found(id, &target.tab_id);
+ };
+ let terminal_ids = self.state.terminal_ids_for_tab(ws_idx, tab_idx);
+ let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ return tab_not_found(id, &target.tab_id);
+ };
+ if ws.tabs.len() <= 1 {
+ return encode_error(
+ id,
+ "tab_close_failed",
+ "cannot close the last tab in a workspace",
+ );
+ }
+ if !ws.close_tab(tab_idx) {
+ return encode_error(
+ id,
+ "tab_close_failed",
+ format!("tab {} could not be closed", target.tab_id),
+ );
+ }
+ self.state.remove_unattached_terminal_ids(terminal_ids);
+ self.shutdown_detached_terminal_runtimes();
+ self.schedule_session_save();
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabClosed,
+ data: EventData::TabClosed {
+ tab_id: target.tab_id,
+ workspace_id: self.public_workspace_id(ws_idx),
+ },
+ });
+
+ encode_success(id, ResponseResult::Ok {})
+ }
+}
+
+fn workspace_not_found(id: String, workspace_id: &str) -> String {
+ encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ )
+}
+
+fn tab_not_found(id: String, tab_id: &str) -> String {
+ encode_error(id, "tab_not_found", format!("tab {tab_id} not found"))
+}
diff --git a/src/app/api/workspaces.rs b/src/app/api/workspaces.rs
new file mode 100644
index 00000000..2f8c1a28
--- /dev/null
+++ b/src/app/api/workspaces.rs
@@ -0,0 +1,168 @@
+use std::path::PathBuf;
+
+use crate::api::schema::{
+ EventData, EventEnvelope, EventKind, ResponseResult, WorkspaceCreateParams,
+ WorkspaceRenameParams, WorkspaceTarget,
+};
+use crate::app::App;
+
+use super::responses::{encode_error, encode_success};
+
+impl App {
+ pub(super) fn handle_workspace_list(&mut self, id: String) -> String {
+ encode_success(
+ id,
+ ResponseResult::WorkspaceList {
+ workspaces: self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .map(|(idx, _)| self.workspace_info(idx))
+ .collect(),
+ },
+ )
+ }
+
+ pub(super) fn handle_workspace_get(&mut self, id: String, target: WorkspaceTarget) -> String {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return workspace_not_found(id, &target.workspace_id);
+ };
+ let Some(_) = self.state.workspaces.get(index) else {
+ return workspace_not_found(id, &target.workspace_id);
+ };
+
+ encode_success(
+ id,
+ ResponseResult::WorkspaceInfo {
+ workspace: self.workspace_info(index),
+ },
+ )
+ }
+
+ pub(super) fn handle_workspace_create(
+ &mut self,
+ id: String,
+ params: WorkspaceCreateParams,
+ ) -> String {
+ let cwd = params
+ .cwd
+ .map(PathBuf::from)
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| PathBuf::from("/"));
+ match self.create_workspace_with_options(cwd, params.focus) {
+ Ok(index) => {
+ if let Some(label) = params.label {
+ if let Some(workspace) = self.state.workspaces.get_mut(index) {
+ workspace.set_custom_name(label);
+ crate::logging::workspace_renamed(&workspace.id);
+ }
+ }
+ let workspace = self.workspace_info(index);
+ let tab = self
+ .tab_info(index, 0)
+ .expect("new workspace should have an initial tab");
+ let root_pane = self
+ .root_pane_info(index, 0)
+ .expect("new workspace should have an initial root pane");
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceCreated,
+ data: EventData::WorkspaceCreated {
+ workspace: workspace.clone(),
+ },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::TabCreated,
+ data: EventData::TabCreated { tab: tab.clone() },
+ });
+ self.emit_event(EventEnvelope {
+ event: EventKind::PaneCreated,
+ data: EventData::PaneCreated {
+ pane: root_pane.clone(),
+ },
+ });
+ encode_success(
+ id,
+ self.workspace_created_result(index)
+ .expect("new workspace should produce a complete create response"),
+ )
+ }
+ Err(err) => encode_error(id, "workspace_create_failed", err.to_string()),
+ }
+ }
+
+ pub(super) fn handle_workspace_focus(&mut self, id: String, target: WorkspaceTarget) -> String {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return workspace_not_found(id, &target.workspace_id);
+ };
+ if self.state.workspaces.get(index).is_none() {
+ return workspace_not_found(id, &target.workspace_id);
+ }
+ self.state.switch_workspace(index);
+
+ encode_success(
+ id,
+ ResponseResult::WorkspaceInfo {
+ workspace: self.workspace_info(index),
+ },
+ )
+ }
+
+ pub(super) fn handle_workspace_rename(
+ &mut self,
+ id: String,
+ params: WorkspaceRenameParams,
+ ) -> String {
+ let Some(index) = self.parse_workspace_id(&params.workspace_id) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ let Some(ws) = self.state.workspaces.get_mut(index) else {
+ return workspace_not_found(id, &params.workspace_id);
+ };
+ ws.set_custom_name(params.label.clone());
+ crate::logging::workspace_renamed(&ws.id);
+ self.schedule_session_save();
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceRenamed,
+ data: EventData::WorkspaceRenamed {
+ workspace_id: self.public_workspace_id(index),
+ label: params.label,
+ },
+ });
+
+ encode_success(
+ id,
+ ResponseResult::WorkspaceInfo {
+ workspace: self.workspace_info(index),
+ },
+ )
+ }
+
+ pub(super) fn handle_workspace_close(&mut self, id: String, target: WorkspaceTarget) -> String {
+ let Some(index) = self.parse_workspace_id(&target.workspace_id) else {
+ return workspace_not_found(id, &target.workspace_id);
+ };
+ if self.state.workspaces.get(index).is_none() {
+ return workspace_not_found(id, &target.workspace_id);
+ }
+ self.state.selected = index;
+ self.state.close_selected_workspace();
+ self.shutdown_detached_terminal_runtimes();
+ self.emit_event(EventEnvelope {
+ event: EventKind::WorkspaceClosed,
+ data: EventData::WorkspaceClosed {
+ workspace_id: target.workspace_id,
+ },
+ });
+
+ encode_success(id, ResponseResult::Ok {})
+ }
+}
+
+fn workspace_not_found(id: String, workspace_id: &str) -> String {
+ encode_error(
+ id,
+ "workspace_not_found",
+ format!("workspace {workspace_id} not found"),
+ )
+}
diff --git a/src/app/creation.rs b/src/app/creation.rs
index f3d8644e..de1ad128 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -11,7 +11,7 @@ impl App {
self.state
.workspaces
.get(ws_idx)?
- .resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes)
+ .resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
}
pub(super) fn workspace_creation_source(&self) -> Option<usize> {
@@ -89,9 +89,7 @@ impl App {
self.state.host_terminal_theme,
&self.state.default_shell,
)?;
- self.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
if focus {
ws.switch_tab(idx);
@@ -124,9 +122,7 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
)?;
- self.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
+ self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
self.state.workspaces.push(ws);
let idx = self.state.workspaces.len() - 1;
@@ -262,11 +258,7 @@ impl App {
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
focused,
cwd: ws.tabs[tab_idx]
- .cwd_for_pane(
- pane_id,
- &self.state.terminals,
- &self.state.terminal_runtimes,
- )
+ .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
.map(|cwd| cwd.display().to_string()),
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
@@ -281,7 +273,9 @@ impl App {
ws_idx: usize,
pane_id: crate::layout::PaneId,
) -> Option<(&crate::terminal::TerminalRuntime, String)> {
- let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?;
+ let runtime =
+ self.state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
Some((runtime, self.public_workspace_id(ws_idx)))
}
@@ -290,7 +284,8 @@ impl App {
ws_idx: usize,
pane_id: crate::layout::PaneId,
) -> Option<&crate::terminal::TerminalRuntime> {
- self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)
+ self.state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
}
pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
@@ -299,7 +294,7 @@ impl App {
crate::api::schema::WorkspaceInfo {
workspace_id: self.public_workspace_id(index),
number: index + 1,
- label: ws.display_name_from(&self.state.terminals, &self.state.terminal_runtimes),
+ label: ws.display_name_from(&self.state.terminals, &self.terminal_runtimes),
focused: self.state.active == Some(index),
pane_count: ws.public_pane_numbers.len(),
tab_count: ws.tabs.len(),
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 245f935b..7c2bbe03 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -70,7 +70,13 @@ impl App {
}
Mode::Resize => handle_resize_key(&mut self.state, key),
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event),
- Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event),
+ Mode::ContextMenu => {
+ handle_context_menu_key(
+ &mut self.state,
+ &mut self.terminal_runtimes,
+ key_event,
+ );
+ }
Mode::Settings => self.handle_settings_key(key_event),
Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event),
@@ -85,7 +91,10 @@ impl App {
return;
}
if let Some(ws_idx) = self.state.active {
- if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) {
+ if let Some(rt) = self
+ .state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ {
let _ = rt.send_paste(text).await;
}
}
@@ -183,7 +192,7 @@ impl App {
let previous_agent_panel_scope = self.state.agent_panel_scope;
let previous_settings_section = self.state.settings.section;
- if let Some(action) = self.state.handle_mouse(mouse) {
+ if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
match action {
SettingsAction::SaveTheme(name) => self.save_theme(&name),
SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
@@ -232,7 +241,11 @@ impl App {
// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
impl AppState {
- pub(crate) fn split_pane(&mut self, direction: Direction) {
+ pub(crate) fn split_pane(
+ &mut self,
+ terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
+ direction: Direction,
+ ) {
// Actual PTY spawning happens in Workspace::split_focused
// which needs events channel — this is called from navigate_key
// where we don't have async context, so the workspace handles it
@@ -245,11 +258,7 @@ impl AppState {
.and_then(|i| self.workspaces.get(i))
.and_then(|ws| {
let tab = ws.active_tab()?;
- tab.cwd_for_pane(
- tab.layout.focused(),
- &self.terminals,
- &self.terminal_runtimes,
- )
+ tab.cwd_for_pane(tab.layout.focused(), &self.terminals, terminal_runtimes)
});
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
@@ -263,8 +272,7 @@ impl AppState {
&self.default_shell,
) {
let new_id = new_pane.pane_id;
- self.terminal_runtimes
- .insert(new_pane.terminal.id.clone(), new_pane.runtime);
+ terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime);
self.terminals
.insert(new_pane.terminal.id.clone(), new_pane.terminal);
ws.layout.focus_pane(new_id);
@@ -332,10 +340,11 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> {
#[cfg(test)]
fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
crate::persist::capture(
&state.workspaces,
&state.terminals,
- &state.terminal_runtimes,
+ &terminal_runtimes,
state.active,
state.selected,
state.agent_panel_scope,
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index 084f44a9..868ee3d3 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -498,7 +498,12 @@ pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
}
}
-pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) {
+pub(super) fn apply_context_menu_action(
+ state: &mut AppState,
+ terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
+ menu: ContextMenuState,
+ idx: usize,
+) {
let item = menu.items().get(idx).copied();
match (menu.kind, item) {
(ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
@@ -554,11 +559,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS
state.mode = Mode::Terminal;
}
(ContextMenuKind::Pane { .. }, Some("Split vertical")) => {
- state.split_pane(Direction::Horizontal);
+ state.split_pane(terminal_runtimes, Direction::Horizontal);
state.mode = Mode::Terminal;
}
(ContextMenuKind::Pane { .. }, Some("Split horizontal")) => {
- state.split_pane(Direction::Vertical);
+ state.split_pane(terminal_runtimes, Direction::Vertical);
state.mode = Mode::Terminal;
}
(ContextMenuKind::Pane { .. }, Some("Zoom")) => {
@@ -577,7 +582,11 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS
}
}
-pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
+pub(crate) fn handle_context_menu_key(
+ state: &mut AppState,
+ terminal_runtimes: &mut crate::terminal::TerminalRuntimeRegistry,
+ key: KeyEvent,
+) {
match key.code {
KeyCode::Esc => {
state.context_menu = None;
@@ -596,7 +605,7 @@ pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
KeyCode::Enter => {
if let Some(menu) = state.context_menu.take() {
let idx = menu.list.highlighted;
- apply_context_menu_action(state, menu, idx);
+ apply_context_menu_action(state, terminal_runtimes, menu, idx);
}
}
_ => {}
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index e3f13d77..4d49a917 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -10,6 +10,7 @@ use crate::{
},
layout::{PaneInfo, SplitBorder},
selection::Selection,
+ terminal::TerminalRuntimeRegistry,
};
#[cfg(test)]
@@ -25,7 +26,11 @@ use super::{
};
impl AppState {
- pub(crate) fn handle_pane_mouse_only(&mut self, mouse: MouseEvent) {
+ pub(crate) fn handle_pane_mouse_only(
+ &mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ mouse: MouseEvent,
+ ) {
if self.mode != Mode::Terminal {
return;
}
@@ -38,16 +43,20 @@ impl AppState {
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => {
- self.forward_pane_reported_wheel(&info, mouse);
+ self.forward_pane_reported_wheel(terminal_runtimes, &info, mouse);
}
MouseEventKind::Down(_) | MouseEventKind::Up(_) | MouseEventKind::Drag(_) => {
- self.forward_pane_mouse_button(&info, mouse);
+ self.forward_pane_mouse_button(terminal_runtimes, &info, mouse);
}
MouseEventKind::Moved => {}
}
}
- pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ pub(super) fn handle_mouse(
+ &mut self,
+ terminal_runtimes: &mut TerminalRuntimeRegistry,
+ mouse: MouseEvent,
+ ) -> Option<SettingsAction> {
if self.mode == Mode::Onboarding {
self.handle_onboarding_mouse(mouse);
return None;
@@ -187,7 +196,7 @@ impl AppState {
let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
if let Some(menu) = self.context_menu.take() {
if let Some(idx) = item_idx {
- apply_context_menu_action(self, menu, idx);
+ apply_context_menu_action(self, terminal_runtimes, menu, idx);
} else {
leave_modal(self);
}
@@ -224,7 +233,7 @@ impl AppState {
}
if let Some((pane_id, target)) =
- self.scrollbar_target_at(mouse.column, mouse.row)
+ self.scrollbar_target_at(terminal_runtimes, mouse.column, mouse.row)
{
self.focus_pane(pane_id);
match target {
@@ -237,7 +246,11 @@ impl AppState {
});
}
ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.set_pane_scroll_offset(pane_id, offset_from_bottom);
+ self.set_pane_scroll_offset(
+ terminal_runtimes,
+ pane_id,
+ offset_from_bottom,
+ );
}
}
if self.mode != Mode::Terminal {
@@ -370,7 +383,7 @@ impl AppState {
self.mode = Mode::Terminal;
}
- if self.forward_pane_mouse_button(&info, mouse) {
+ if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
self.selection = None;
self.selection_autoscroll = None;
return None;
@@ -384,7 +397,7 @@ impl AppState {
info.id,
row,
col,
- self.pane_scroll_metrics(info.id),
+ self.pane_scroll_metrics(terminal_runtimes, info.id),
));
} else if let Some(info) = self.view.pane_infos.iter().find(|p| {
mouse.column >= p.rect.x
@@ -402,13 +415,13 @@ impl AppState {
MouseEventKind::Drag(MouseButton::Left) => {
if self.selection.is_some() {
- self.update_selection_drag(mouse.column, mouse.row);
+ self.update_selection_drag(terminal_runtimes, mouse.column, mouse.row);
return None;
}
if self.drag.is_none() {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- if self.forward_pane_mouse_button(&info, mouse) {
+ if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
self.selection = None;
self.selection_autoscroll = None;
return None;
@@ -504,11 +517,16 @@ impl AppState {
grab_row_offset,
} => {
if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
+ terminal_runtimes,
*pane_id,
mouse.row,
*grab_row_offset,
) {
- self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
+ self.set_pane_scroll_offset(
+ terminal_runtimes,
+ *pane_id,
+ offset_from_bottom,
+ );
}
}
DragTarget::SidebarDivider => {
@@ -535,14 +553,14 @@ impl AppState {
self.selection = None;
self.selection_autoscroll = None;
} else {
- self.copy_selection();
+ self.copy_selection(terminal_runtimes);
}
return None;
}
if self.drag.is_none() {
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- if self.forward_pane_mouse_button(&info, mouse) {
+ if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
self.selection = None;
self.selection_autoscroll = None;
self.workspace_press = None;
@@ -597,7 +615,7 @@ impl AppState {
self.selection = None;
self.selection_autoscroll = None;
} else {
- self.copy_selection();
+ self.copy_selection(terminal_runtimes);
}
}
}
@@ -607,7 +625,7 @@ impl AppState {
if !in_sidebar =>
{
if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- let _ = self.forward_pane_mouse_button(&info, mouse);
+ let _ = self.forward_pane_mouse_button(terminal_runtimes, &info, mouse);
}
}
@@ -622,12 +640,12 @@ impl AppState {
}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
- if !in_sidebar && self.scroll_selection_with_wheel(mouse) => {}
+ if !in_sidebar && self.scroll_selection_with_wheel(terminal_runtimes, mouse) => {}
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
self.selection = None;
self.selection_autoscroll = None;
- self.handle_terminal_wheel(mouse);
+ self.handle_terminal_wheel(terminal_runtimes, mouse);
}
MouseEventKind::ScrollUp if in_sidebar => {
@@ -1070,17 +1088,29 @@ impl AppState {
self.mode = Mode::Terminal;
}
- pub(crate) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ pub(crate) fn scroll_pane_up(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ pane_id: crate::layout::PaneId,
+ lines: usize,
+ ) {
if let Some(ws_idx) = self.active {
- if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ {
rt.scroll_up(lines);
}
}
}
- pub(crate) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ pub(crate) fn scroll_pane_down(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ pane_id: crate::layout::PaneId,
+ lines: usize,
+ ) {
if let Some(ws_idx) = self.active {
- if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ {
rt.scroll_down(lines);
}
}
@@ -1088,24 +1118,33 @@ impl AppState {
pub(crate) fn pane_scroll_metrics(
&self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
) -> Option<crate::pane::ScrollMetrics> {
self.active
- .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id))
+ .and_then(|i| self.runtime_for_pane_in_workspace(terminal_runtimes, i, pane_id))
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
}
- pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
+ pub(super) fn handle_terminal_wheel(
+ &mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ mouse: MouseEvent,
+ ) {
const LINES_PER_NOTCH: usize = 3;
if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
- if self.forward_pane_wheel(&info, mouse) {
+ if self.forward_pane_wheel(terminal_runtimes, &info, mouse) {
return;
}
match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollUp => {
+ self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH)
+ }
+ MouseEventKind::ScrollDown => {
+ self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH)
+ }
_ => {}
}
return;
@@ -1114,15 +1153,19 @@ impl AppState {
if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
self.focus_pane(info.id);
match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollUp => {
+ self.scroll_pane_up(terminal_runtimes, info.id, LINES_PER_NOTCH)
+ }
+ MouseEventKind::ScrollDown => {
+ self.scroll_pane_down(terminal_runtimes, info.id, LINES_PER_NOTCH)
+ }
_ => {}
}
return;
}
if let Some(ws_idx) = self.active {
- if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) {
+ if let Some(rt) = self.focused_runtime_in_workspace(terminal_runtimes, ws_idx) {
match mouse.kind {
MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
@@ -1132,11 +1175,17 @@ impl AppState {
}
}
- pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ pub(super) fn forward_pane_mouse_button(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ info: &PaneInfo,
+ mouse: MouseEvent,
+ ) -> bool {
let Some(ws_idx) = self.active else {
return false;
};
- let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ else {
return false;
};
let column = mouse.column.saturating_sub(info.inner_rect.x);
@@ -1151,11 +1200,17 @@ impl AppState {
true
}
- fn forward_pane_reported_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ fn forward_pane_reported_wheel(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ info: &PaneInfo,
+ mouse: MouseEvent,
+ ) -> bool {
let Some(ws_idx) = self.active else {
return false;
};
- let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ else {
return false;
};
if !rt
@@ -1177,11 +1232,17 @@ impl AppState {
true
}
- pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ pub(super) fn forward_pane_wheel(
+ &self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ info: &PaneInfo,
+ mouse: MouseEvent,
+ ) -> bool {
let Some(ws_idx) = self.active else {
return false;
};
- let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
+ else {
return false;
};
match rt.wheel_routing() {
@@ -1215,11 +1276,13 @@ impl AppState {
pub(super) fn set_pane_scroll_offset(
&self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
offset_from_bottom: usize,
) {
if let Some(ws_idx) = self.active {
- if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ {
rt.set_scroll_offset_from_bottom(offset_from_bottom);
}
}
@@ -1227,6 +1290,7 @@ impl AppState {
pub(super) fn scrollbar_target_at(
&self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
col: u16,
row: u16,
) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
@@ -1239,7 +1303,7 @@ impl AppState {
&& row < track.y + track.height
})
})?;
- let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?;
+ let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
@@ -1259,6 +1323,7 @@ impl AppState {
pub(super) fn scrollbar_offset_for_pane_row(
&self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
row: u16,
grab_row_offset: u16,
@@ -1270,7 +1335,7 @@ impl AppState {
.iter()
.find(|info| info.id == pane_id)?;
let track = crate::ui::pane_scrollbar_rect(info)?;
- let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?;
+ let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
@@ -1435,6 +1500,7 @@ mod tests {
app.state.mode = Mode::ContextMenu;
handle_context_menu_key(
&mut app.state,
+ &mut app.terminal_runtimes,
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
);
assert_eq!(app.state.mode, Mode::ConfirmClose);
@@ -1458,6 +1524,56 @@ mod tests {
assert_eq!(app.state.workspaces[0].display_name(), "a");
}
+ #[tokio::test]
+ async fn keyboard_context_menu_split_keeps_new_runtime() {
+ let mut app = app_for_mouse_test();
+ app.state.default_shell = "/usr/bin/true".into();
+ let (workspace, terminal, runtime) = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ &app.state.default_shell,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.terminal_runtimes.insert(terminal.id.clone(), runtime);
+ app.state.terminals.insert(terminal.id.clone(), terminal);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let runtime_count = app.terminal_runtimes.len();
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Pane {
+ pane_id,
+ has_manual_label: false,
+ },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(1),
+ });
+ app.state.mode = Mode::ContextMenu;
+
+ handle_context_menu_key(
+ &mut app.state,
+ &mut app.terminal_runtimes,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert_eq!(app.terminal_runtimes.len(), runtime_count + 1);
+
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
+ }
+ }
+
#[test]
fn dragging_pane_split_updates_captured_layout_ratio() {
let mut app = app_for_mouse_test();
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 17180fed..9256bbbb 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -16,6 +16,7 @@ use crate::{
},
input::TerminalKey,
layout::NavDirection,
+ terminal::TerminalRuntimeRegistry,
};
pub(crate) fn terminal_direct_navigation_action(
@@ -55,7 +56,12 @@ impl App {
self.launch_focused_scrollback_editor();
finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode);
} else {
- execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix);
+ execute_navigate_action_in_context(
+ &mut self.state,
+ &mut self.terminal_runtimes,
+ action,
+ ActionContext::Prefix,
+ );
}
self.selection_autoscroll_deadline = None;
return;
@@ -88,6 +94,7 @@ impl App {
} else {
execute_navigate_action_in_context(
&mut self.state,
+ &mut self.terminal_runtimes,
action,
ActionContext::Navigate,
);
@@ -105,7 +112,10 @@ impl App {
let Some(ws_idx) = self.state.active else {
return false;
};
- let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else {
+ let Some(rt) = self
+ .state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ else {
return false;
};
@@ -174,11 +184,7 @@ impl App {
env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
}
if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| {
- tab.cwd_for_pane(
- pane_id,
- &self.state.terminals,
- &self.state.terminal_runtimes,
- )
+ tab.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
}) {
env.push((
"HERDR_ACTIVE_PANE_CWD".to_string(),
@@ -245,7 +251,7 @@ impl App {
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
let scrollback = self
.state
- .runtime_for_pane_in_workspace(ws_idx, pane_id)
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)
.ok_or_else(|| std::io::Error::other("focused pane has no scrollback runtime"))?
.recent_text(usize::MAX);
@@ -298,7 +304,7 @@ impl App {
tab.cwd_for_pane(
previous_focus,
&self.state.terminals,
- &self.state.terminal_runtimes,
+ &self.terminal_runtimes,
)
});
let new_pane = ws.split_focused_command(
@@ -312,8 +318,7 @@ impl App {
self.state.host_terminal_theme,
)?;
let new_pane_id = new_pane.pane_id;
- self.state
- .terminal_runtimes
+ self.terminal_runtimes
.insert(new_pane.terminal.id.clone(), new_pane.runtime);
self.state
.terminals
@@ -428,6 +433,7 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_dismissed = true;
let terminal_key = TerminalKey::from(key);
@@ -441,7 +447,12 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
}
if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) {
- execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+ execute_navigate_action_in_context(
+ state,
+ &mut terminal_runtimes,
+ action,
+ ActionContext::Navigate,
+ );
}
}
@@ -587,11 +598,18 @@ fn action_for_key(
#[cfg(test)]
pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
- execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
+ execute_navigate_action_in_context(
+ state,
+ &mut terminal_runtimes,
+ action,
+ ActionContext::Navigate,
+ );
}
pub(super) fn execute_navigate_action_in_context(
state: &mut AppState,
+ terminal_runtimes: &mut TerminalRuntimeRegistry,
action: NavigateAction,
context: ActionContext,
) {
@@ -694,11 +712,11 @@ pub(super) fn execute_navigate_action_in_context(
NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up),
NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right),
NavigateAction::SplitVertical => {
- state.split_pane(Direction::Horizontal);
+ state.split_pane(terminal_runtimes, Direction::Horizontal);
leave_navigate_mode(state);
}
NavigateAction::SplitHorizontal => {
- state.split_pane(Direction::Vertical);
+ state.split_pane(terminal_runtimes, Direction::Vertical);
leave_navigate_mode(state);
}
NavigateAction::ClosePane => {
@@ -1192,10 +1210,12 @@ mod tests {
#[test]
fn empty_state_new_tab_is_no_op() {
let mut state = crate::app::state::AppState::test_new();
+ let mut terminal_runtimes = TerminalRuntimeRegistry::new();
state.mode = Mode::Prefix;
execute_navigate_action_in_context(
&mut state,
+ &mut terminal_runtimes,
NavigateAction::NewTab,
ActionContext::Prefix,
);
@@ -1277,9 +1297,7 @@ mod tests {
)
.expect("workspace should spawn");
app.state.workspaces = vec![workspace];
- app.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
+ app.terminal_runtimes.insert(terminal.id.clone(), runtime);
app.state.terminals.insert(terminal.id.clone(), terminal);
app.state.active = Some(0);
app.state.selected = 0;
@@ -1303,7 +1321,7 @@ mod tests {
.await;
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
- assert_eq!(app.state.terminal_runtimes.len(), 2);
+ assert_eq!(app.terminal_runtimes.len(), 2);
assert!(app.state.workspaces[0].tabs[0].zoomed);
let _ = wait_for_file(&output_path);
@@ -1321,6 +1339,11 @@ mod tests {
assert!(!app.state.workspaces[0].tabs[0].zoomed);
assert_eq!(app.state.mode, Mode::Terminal);
let _ = std::fs::remove_file(output_path);
+
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
+ }
}
#[tokio::test]
@@ -1337,7 +1360,12 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"),
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
+ 20,
+ 5,
+ 4096,
+ b"alpha\nbeta\n",
+ ),
);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs
index ad1dd33f..06de30b5 100644
--- a/src/app/input/selection.rs
+++ b/src/app/input/selection.rs
@@ -1,10 +1,14 @@
use crossterm::event::{MouseEvent, MouseEventKind};
-use crate::app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection};
+use crate::{
+ app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection},
+ terminal::TerminalRuntimeRegistry,
+};
impl AppState {
pub(crate) fn update_selection_cursor(
&mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
screen_col: u16,
screen_row: u16,
@@ -12,7 +16,7 @@ impl AppState {
let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
return;
};
- let metrics = self.pane_scroll_metrics(pane_id);
+ let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
if let Some(selection) = self.selection.as_mut() {
selection.drag(screen_col, screen_row, info.inner_rect, metrics);
}
@@ -22,7 +26,12 @@ impl AppState {
usize::from(distance).saturating_mul(3).clamp(3, 15)
}
- pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) {
+ pub(super) fn update_selection_drag(
+ &mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ screen_col: u16,
+ screen_row: u16,
+ ) {
let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
return;
};
@@ -44,14 +53,16 @@ impl AppState {
// Anchor is stored in absolute row; for a simple screen
// comparison, check whether the mouse is on a different
// cell than the anchor's screen position.
- let (ar, ac) =
- s.anchor_screen_pos(info.inner_rect, self.pane_scroll_metrics(s.pane_id));
+ let (ar, ac) = s.anchor_screen_pos(
+ info.inner_rect,
+ self.pane_scroll_metrics(terminal_runtimes, s.pane_id),
+ );
ar != screen_row || ac != screen_col
});
let is_dragging = was_dragging || anchor_differs_from_mouse;
// Advance the selection cursor.
- self.update_selection_cursor(pane_id, screen_col, screen_row);
+ self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
// If the mouse is on a different cell than the anchor but drag()
// didn't transition (cursor clamped to edge == anchor), force
@@ -67,9 +78,13 @@ impl AppState {
if screen_row < top {
// Cursor above pane — immediate scroll + set autoscroll state
if is_dragging {
- self.scroll_pane_up(pane_id, Self::selection_edge_scroll_lines(top - screen_row));
+ self.scroll_pane_up(
+ terminal_runtimes,
+ pane_id,
+ Self::selection_edge_scroll_lines(top - screen_row),
+ );
// Re-advance cursor after scroll so it reflects the new viewport position
- self.update_selection_cursor(pane_id, screen_col, screen_row);
+ self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Up,
last_mouse_screen_col: screen_col,
@@ -81,11 +96,12 @@ impl AppState {
// Cursor below pane — immediate scroll + set autoscroll state
if is_dragging {
self.scroll_pane_down(
+ terminal_runtimes,
pane_id,
Self::selection_edge_scroll_lines(screen_row - bottom),
);
// Re-advance cursor after scroll so it reflects the new viewport position
- self.update_selection_cursor(pane_id, screen_col, screen_row);
+ self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Down,
last_mouse_screen_col: screen_col,
@@ -123,7 +139,11 @@ impl AppState {
}
}
- pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool {
+ pub(super) fn scroll_selection_with_wheel(
+ &mut self,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ mouse: MouseEvent,
+ ) -> bool {
const LINES_PER_NOTCH: usize = 3;
let Some(selection) = self.selection.as_ref() else {
@@ -135,11 +155,15 @@ impl AppState {
let pane_id = selection.pane_id;
self.focus_pane(pane_id);
match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollUp => {
+ self.scroll_pane_up(terminal_runtimes, pane_id, LINES_PER_NOTCH)
+ }
+ MouseEventKind::ScrollDown => {
+ self.scroll_pane_down(terminal_runtimes, pane_id, LINES_PER_NOTCH)
+ }
_ => return false,
}
- self.update_selection_cursor(pane_id, mouse.column, mouse.row);
+ self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row);
true
}
}
@@ -148,6 +172,7 @@ impl AppState {
mod autoscroll_tests {
use super::*;
use crate::layout::PaneInfo;
+ use crate::terminal::TerminalRuntimeRegistry;
use crate::workspace::Workspace;
use ratatui::layout::Rect;
@@ -188,7 +213,8 @@ mod autoscroll_tests {
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
sel.drag(4, 5, Rect::new(0, 5, 80, 24), None);
state.selection = Some(sel);
- state.update_selection_drag(5, 4);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 5, 4);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
}
@@ -200,7 +226,8 @@ mod autoscroll_tests {
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
sel.drag(0, 0, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
- state.update_selection_drag(0, 0);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 0, 0);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
}
@@ -211,7 +238,8 @@ mod autoscroll_tests {
let (mut state, pane_id) = make_state_with_pane();
state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
// Same-cell drag on top edge row — still anchored
- state.update_selection_drag(0, 0);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 0, 0);
assert!(state.selection_autoscroll.is_none());
}
@@ -222,7 +250,8 @@ mod autoscroll_tests {
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
sel.drag(23, 0, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
- state.update_selection_drag(0, 23);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 0, 23);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
}
@@ -234,7 +263,8 @@ mod autoscroll_tests {
// Anchor at bottom edge row
state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None));
// Same-cell drag — still anchored
- state.update_selection_drag(0, 23);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 0, 23);
assert!(state.selection_autoscroll.is_none());
}
@@ -246,7 +276,8 @@ mod autoscroll_tests {
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
// Drag cursor one row below the pane bottom
- state.update_selection_drag(0, 24);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 0, 24);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
}
@@ -266,7 +297,8 @@ mod autoscroll_tests {
inner_rect: Rect::new(0, 0, 80, 24),
});
// Move cursor into safe zone (middle of pane, not on edge rows)
- state.update_selection_drag(5, 12);
+ let terminal_runtimes = TerminalRuntimeRegistry::new();
+ state.update_selection_drag(&terminal_runtimes, 5, 12);
assert!(state.selection_autoscroll.is_none());
}
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 41a62e82..eee5b863 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -47,6 +47,7 @@ impl App {
} else {
super::navigate::execute_navigate_action_in_context(
&mut self.state,
+ &mut self.terminal_runtimes,
action,
super::navigate::ActionContext::Direct,
);
@@ -88,7 +89,9 @@ impl App {
let ws_idx = self.state.active?;
let ws = self.state.workspaces.get(ws_idx)?;
let pane_id = ws.focused_pane_id()?;
- let rt = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?;
+ let rt =
+ self.state
+ .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
// Intercept plain PageUp/PageDown presses for pane scrollback when the
// focused pane doesn't handle its own scrolling (e.g., a plain shell
@@ -116,9 +119,11 @@ impl App {
.unwrap_or(10)
.max(1);
if key_event.code == KeyCode::PageUp {
- self.state.scroll_pane_up(pane_id, lines);
+ self.state
+ .scroll_pane_up(&self.terminal_runtimes, pane_id, lines);
} else {
- self.state.scroll_pane_down(pane_id, lines);
+ self.state
+ .scroll_pane_down(&self.terminal_runtimes, pane_id, lines);
}
debug!(
code = ?key_event.code,
@@ -223,7 +228,7 @@ mod tests {
let start_metrics = app
.state
- .runtime_for_pane(pane_id)
+ .runtime_for_pane(&app.terminal_runtimes, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
let start_row = info.inner_rect.y;
@@ -242,7 +247,7 @@ mod tests {
let end_metrics = app
.state
- .runtime_for_pane(pane_id)
+ .runtime_for_pane(&app.terminal_runtimes, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after drag");
assert_eq!(
@@ -329,7 +334,7 @@ mod tests {
let start_metrics = app
.state
- .runtime_for_pane(pane_id)
+ .runtime_for_pane(&app.terminal_runtimes, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
let top_row = info.inner_rect.y;
@@ -340,7 +345,7 @@ mod tests {
let end_metrics = app
.state
- .runtime_for_pane(pane_id)
+ .runtime_for_pane(&app.terminal_runtimes, pane_id)
.and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after wheel");
assert_eq!(
@@ -523,7 +528,12 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"),
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
+ 20,
+ 5,
+ 4096,
+ b"alpha\nbeta\n",
+ ),
);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
@@ -602,6 +612,7 @@ mod tests {
api_rx,
crate::api::EventHub::default(),
);
+ app.state.default_shell = "/usr/bin/true".into();
let (workspace, terminal, runtime) = Workspace::new(
std::env::current_dir().unwrap_or_else(|_| "/".into()),
24,
@@ -615,9 +626,7 @@ mod tests {
)
.expect("workspace should spawn");
app.state.workspaces = vec![workspace];
- app.state
- .terminal_runtimes
- .insert(terminal.id.clone(), runtime);
+ app.terminal_runtimes.insert(terminal.id.clone(), runtime);
app.state.terminals.insert(terminal.id.clone(), terminal);
app.state.active = Some(0);
app.state.selected = 0;
@@ -639,6 +648,11 @@ mod tests {
assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
assert!(app.state.workspaces[0].tabs[0].zoomed);
assert_eq!(app.state.mode, Mode::Terminal);
+
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
+ }
}
#[tokio::test]
@@ -648,7 +662,7 @@ mod tests {
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(0, 0, 80, 24));
let info = pane_infos[0].clone();
- let (runtime, mut rx) = crate::pane::PaneRuntime::test_with_channel(
+ let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(
info.inner_rect.width,
info.inner_rect.height,
);
@@ -677,7 +691,7 @@ mod tests {
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
@@ -693,8 +707,8 @@ mod tests {
let start_metrics = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
assert_eq!(start_metrics.offset_from_bottom, 0);
@@ -702,8 +716,8 @@ mod tests {
let end_metrics = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert_eq!(
end_metrics.offset_from_bottom,
@@ -720,7 +734,7 @@ mod tests {
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
@@ -737,8 +751,8 @@ mod tests {
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let after_up = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert!(after_up.offset_from_bottom > 0);
@@ -748,8 +762,8 @@ mod tests {
));
let after_down = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageDown");
assert_eq!(after_down.offset_from_bottom, 0);
}
@@ -763,7 +777,7 @@ mod tests {
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
@@ -780,8 +794,8 @@ mod tests {
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let after_press = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp press");
assert_eq!(
after_press.offset_from_bottom,
@@ -795,8 +809,8 @@ mod tests {
let after_release = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp release");
assert_eq!(
after_release.offset_from_bottom,
@@ -813,7 +827,7 @@ mod tests {
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
@@ -831,8 +845,8 @@ mod tests {
let metrics = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after modified PageUp");
assert_eq!(metrics.offset_from_bottom, 0);
}
@@ -848,7 +862,7 @@ mod tests {
bytes.extend_from_slice(&numbered_lines_bytes(64));
ws.tabs[0].runtimes.insert(
pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
@@ -864,8 +878,8 @@ mod tests {
let start_metrics = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
assert_eq!(start_metrics.offset_from_bottom, 0);
@@ -873,8 +887,8 @@ mod tests {
let end_metrics = app
.state
- .runtime_for_pane_in_workspace(0, pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
// Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom.
assert_eq!(end_metrics.offset_from_bottom, 0);
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 4c4845a1..6a94f116 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -62,6 +62,7 @@ pub(crate) struct OverlayPaneState {
pub struct App {
pub state: AppState,
+ pub(crate) terminal_runtimes: crate::terminal::TerminalRuntimeRegistry,
pub event_tx: mpsc::Sender<AppEvent>,
pub(crate) event_rx: mpsc::Receiver<AppEvent>,
pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
@@ -224,7 +225,7 @@ impl App {
// Try to restore previous session
let mut restored_terminals = std::collections::HashMap::new();
- let mut restored_terminal_runtimes = std::collections::HashMap::new();
+ let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
let (
workspaces,
active,
@@ -255,7 +256,7 @@ impl App {
render_dirty.clone(),
);
restored_terminals = terminals;
- restored_terminal_runtimes = terminal_runtimes;
+ restored_terminal_runtimes = terminal_runtimes.into();
if ws.is_empty() {
crate::logging::session_restored(0, "empty");
(
@@ -346,7 +347,6 @@ impl App {
let mut state = AppState {
terminals: std::collections::HashMap::new(),
- terminal_runtimes: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
workspaces,
active,
@@ -457,14 +457,14 @@ impl App {
global_menu: state::MenuListState::new(0),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
session_dirty: false,
+ terminal_runtime_shutdowns: Vec::new(),
};
state.terminals = restored_terminals;
- state.terminal_runtimes = restored_terminal_runtimes;
for ws_idx in 0..state.workspaces.len() {
let cwd = state.workspaces[ws_idx]
- .resolved_identity_cwd_from(&state.terminals, &state.terminal_runtimes);
+ .resolved_identity_cwd_from(&state.terminals, &restored_terminal_runtimes);
state.workspaces[ws_idx].cached_git_branch =
cwd.as_deref().and_then(crate::workspace::git_branch);
}
@@ -488,6 +488,7 @@ impl App {
config_diagnostic_deadline: None,
toast_deadline: None,
state,
+ terminal_runtimes: restored_terminal_runtimes,
event_tx,
event_rx,
last_git_remote_status_refresh: Instant::now() - GIT_REMOTE_STATUS_REFRESH_INTERVAL,
@@ -593,14 +594,31 @@ impl App {
let area = frame.area();
if kitty_graphics_enabled {
cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area);
- crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size);
+ crate::ui::compute_view_with_cell_size(
+ &mut self.state,
+ &self.terminal_runtimes,
+ area,
+ cell_size,
+ );
} else {
- crate::ui::compute_view(&mut self.state, area);
+ crate::ui::compute_view_with_runtime_registry(
+ &mut self.state,
+ &self.terminal_runtimes,
+ area,
+ );
}
- crate::ui::render(&self.state, frame);
+ crate::ui::render_with_runtime_registry(
+ &self.state,
+ &self.terminal_runtimes,
+ frame,
+ );
})?;
if kitty_graphics_enabled {
- crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?;
+ crate::kitty_graphics::paint_local_pane_graphics(
+ &self.state,
+ &self.terminal_runtimes,
+ cell_size,
+ )?;
}
self.last_render_at = Some(now);
needs_render = false;
@@ -664,7 +682,9 @@ impl App {
}
fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> {
- let desired = self.state.should_capture_host_mouse();
+ let desired = self
+ .state
+ .should_capture_host_mouse_from(&self.terminal_runtimes);
if desired == *active {
return Ok(());
}
@@ -1024,7 +1044,8 @@ impl App {
if self.state.mouse_capture {
self.handle_mouse_event_headless(mouse);
} else {
- self.state.handle_pane_mouse_only(mouse);
+ self.state
+ .handle_pane_mouse_only(&self.terminal_runtimes, mouse);
}
}
crate::raw_input::RawInputEvent::Paste(text) => {
@@ -1032,9 +1053,11 @@ impl App {
if let Some(ws_idx) = self.state.active {
if let Some(ws) = self.state.workspaces.get(ws_idx) {
if let Some(focused) = ws.focused_pane_id() {
- if let Some(runtime) =
- self.state.runtime_for_pane_in_workspace(ws_idx, focused)
- {
+ if let Some(runtime) = self.state.runtime_for_pane_in_workspace(
+ &self.terminal_runtimes,
+ ws_idx,
+ focused,
+ ) {
let _ = runtime.try_send_bytes(bytes::Bytes::from(
if runtime
.input_state()
@@ -1087,7 +1110,11 @@ impl App {
input::handle_confirm_close_key(&mut self.state, key_event);
}
Mode::ContextMenu => {
- input::handle_context_menu_key(&mut self.state, key_event);
+ input::handle_context_menu_key(
+ &mut self.state,
+ &mut self.terminal_runtimes,
+ key_event,
+ );
}
Mode::KeybindHelp => {
input::handle_keybind_help_key(&mut self.state, key_event);
@@ -2221,7 +2248,7 @@ mod tests {
3
);
- let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect();
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
for (_terminal_id, runtime) in runtimes {
runtime.shutdown();
}
@@ -2268,7 +2295,7 @@ mod tests {
assert_eq!(app.state.active, Some(0));
assert_eq!(app.state.workspaces[0].active_tab, background_tab);
- let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect();
+ let runtimes: Vec<_> = app.terminal_runtimes.drain().collect();
for (_terminal_id, runtime) in runtimes {
runtime.shutdown();
}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 5671bf38..7f7306cc 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -11,10 +11,20 @@ use crate::events::AppEvent;
use crate::workspace::{Workspace, WorkspaceGitStatus};
impl App {
+ pub(crate) fn shutdown_detached_terminal_runtimes(&mut self) {
+ let terminal_ids = std::mem::take(&mut self.state.terminal_runtime_shutdowns);
+ for terminal_id in terminal_ids {
+ if let Some(runtime) = self.terminal_runtimes.remove(&terminal_id) {
+ runtime.shutdown();
+ }
+ }
+ }
+
pub(crate) fn drain_api_requests(&mut self) -> bool {
let mut changed = false;
while let Ok(msg) = self.api_rx.try_recv() {
changed |= self.handle_api_request_message(msg);
+ self.shutdown_detached_terminal_runtimes();
}
changed
}
@@ -53,7 +63,7 @@ impl App {
&mut self,
event: crate::raw_input::RawInputEvent,
) -> bool {
- match event {
+ let changed = match event {
crate::raw_input::RawInputEvent::Key(key) => {
let key_id = repeat_key_identity(&key);
match key.kind {
@@ -90,7 +100,8 @@ impl App {
if self.state.mouse_capture {
self.handle_mouse(mouse);
} else {
- self.state.handle_pane_mouse_only(mouse);
+ self.state
+ .handle_pane_mouse_only(&self.terminal_runtimes, mouse);
}
true
}
@@ -108,7 +119,9 @@ impl App {
self.update_host_terminal_theme(kind, color)
}
crate::raw_input::RawInputEvent::Unsupported => false,
- }
+ };
+ self.shutdown_detached_terminal_runtimes();
+ changed
}
fn handle_resize_poll(&mut self) -> bool {
@@ -248,7 +261,10 @@ impl App {
}
// Scrollback boundary detection via ScrollMetrics — fail-closed if unavailable
- let Some(metrics) = self.state.pane_scroll_metrics(pane_id) else {
+ let Some(metrics) = self
+ .state
+ .pane_scroll_metrics(&self.terminal_runtimes, pane_id)
+ else {
self.stop_selection_autoscroll();
return;
};
@@ -259,7 +275,8 @@ impl App {
self.stop_selection_autoscroll();
return;
}
- self.state.scroll_pane_up(pane_id, 1);
+ self.state
+ .scroll_pane_up(&self.terminal_runtimes, pane_id, 1);
}
crate::app::state::SelectionAutoscrollDirection::Down => {
let at_bottom = metrics.offset_from_bottom == 0;
@@ -267,12 +284,14 @@ impl App {
self.stop_selection_autoscroll();
return;
}
- self.state.scroll_pane_down(pane_id, 1);
+ self.state
+ .scroll_pane_down(&self.terminal_runtimes, pane_id, 1);
}
}
// Extend selection cursor to last known mouse position
self.state.update_selection_cursor(
+ &self.terminal_runtimes,
pane_id,
autoscroll.last_mouse_screen_col,
autoscroll.last_mouse_screen_row,
@@ -328,7 +347,7 @@ impl App {
.workspaces
.iter()
.filter_map(|ws| {
- ws.resolved_identity_cwd_from(&self.state.terminals, &self.state.terminal_runtimes)
+ ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes)
.map(|cwd| (ws.id.clone(), cwd))
})
.collect();
diff --git a/src/app/session.rs b/src/app/session.rs
index 85bdd1bb..976e320e 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -28,7 +28,7 @@ impl App {
let snap = crate::persist::capture(
&self.state.workspaces,
&self.state.terminals,
- &self.state.terminal_runtimes,
+ &self.terminal_runtimes,
self.state.active,
self.state.selected,
self.state.agent_panel_scope,
diff --git a/src/app/state.rs b/src/app/state.rs
index 6924dc65..13958d03 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -891,8 +891,6 @@ pub enum SidebarWidthSource {
pub struct AppState {
pub terminals:
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>,
- pub terminal_runtimes:
- std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalRuntime>,
/// Terminal ids whose size is currently owned by a direct attach client.
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
pub workspaces: Vec<Workspace>,
@@ -1001,6 +999,9 @@ pub struct AppState {
pub host_terminal_theme: TerminalTheme,
/// Set when a persisted session snapshot would change.
pub session_dirty: bool,
+ /// Terminal runtimes that should be shut down by the app/runtime layer
+ /// after state has detached their terminal metadata.
+ pub(crate) terminal_runtime_shutdowns: Vec<crate::terminal::TerminalId>,
}
impl AppState {
@@ -1039,17 +1040,23 @@ impl AppState {
section == SettingsSection::Integrations && self.integration_updates_available()
}
- pub fn focused_pane_requests_mouse_capture(&self) -> bool {
+ pub(crate) fn focused_pane_requests_mouse_capture_from(
+ &self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ ) -> bool {
self.mode == Mode::Terminal
&& self
.active
- .and_then(|idx| self.focused_runtime_in_workspace(idx))
+ .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx))
.and_then(crate::terminal::TerminalRuntime::input_state)
.is_some_and(crate::pane::InputState::mouse_reporting_enabled)
}
- pub fn should_capture_host_mouse(&self) -> bool {
- self.mouse_capture || self.focused_pane_requests_mouse_capture()
+ pub(crate) fn should_capture_host_mouse_from(
+ &self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ ) -> bool {
+ self.mouse_capture || self.focused_pane_requests_mouse_capture_from(terminal_runtimes)
}
pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool {
@@ -1066,11 +1073,12 @@ impl AppState {
/// Returns true when the given (workspace, tab, pane) refers to the
/// currently focused pane in the active workspace's active tab.
- pub(crate) fn runtime_for_pane_in_workspace(
- &self,
+ pub(crate) fn runtime_for_pane_in_workspace<'a>(
+ &'a self,
+ terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
ws_idx: usize,
pane_id: crate::layout::PaneId,
- ) -> Option<&crate::terminal::TerminalRuntime> {
+ ) -> Option<&'a crate::terminal::TerminalRuntime> {
#[cfg(test)]
if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) {
return Some(runtime);
@@ -1086,14 +1094,15 @@ impl AppState {
return Some(runtime);
}
let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?;
- self.terminal_runtimes.get(terminal_id)
+ terminal_runtimes.get(terminal_id)
}
#[cfg(test)]
- pub(crate) fn runtime_for_pane(
- &self,
+ pub(crate) fn runtime_for_pane<'a>(
+ &'a self,
+ terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
- ) -> Option<&crate::terminal::TerminalRuntime> {
+ ) -> Option<&'a crate::terminal::TerminalRuntime> {
self.workspaces.iter().find_map(|ws| {
#[cfg(test)]
if let Some(runtime) = ws.test_runtimes.get(&pane_id) {
@@ -1104,17 +1113,18 @@ impl AppState {
return Some(runtime);
}
let terminal_id = ws.terminal_id(pane_id)?;
- self.terminal_runtimes.get(terminal_id)
+ terminal_runtimes.get(terminal_id)
})
}
- pub(crate) fn focused_runtime_in_workspace(
- &self,
+ pub(crate) fn focused_runtime_in_workspace<'a>(
+ &'a self,
+ terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry,
ws_idx: usize,
- ) -> Option<&crate::terminal::TerminalRuntime> {
+ ) -> Option<&'a crate::terminal::TerminalRuntime> {
let ws = self.workspaces.get(ws_idx)?;
let pane_id = ws.focused_pane_id()?;
- self.runtime_for_pane_in_workspace(ws_idx, pane_id)
+ self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
}
pub fn is_active_pane(
@@ -1161,7 +1171,6 @@ impl AppState {
pub fn test_new() -> Self {
Self {
terminals: std::collections::HashMap::new(),
- terminal_runtimes: std::collections::HashMap::new(),
direct_attach_resize_locks: std::collections::HashSet::new(),
workspaces: Vec::new(),
active: None,
@@ -1262,6 +1271,7 @@ impl AppState {
global_menu: MenuListState::new(0),
host_terminal_theme: TerminalTheme::default(),
session_dirty: false,
+ terminal_runtime_shutdowns: Vec::new(),
}
}
@@ -1289,14 +1299,13 @@ impl AppState {
pane_id: crate::layout::PaneId,
runtime: crate::terminal::TerminalRuntime,
) {
- let Some(terminal_id) = self
+ if let Some(ws) = self
.workspaces
- .iter()
- .find_map(|ws| ws.terminal_id(pane_id).cloned())
- else {
- return;
- };
- self.terminal_runtimes.insert(terminal_id, runtime);
+ .iter_mut()
+ .find(|ws| ws.terminal_id(pane_id).is_some())
+ {
+ ws.insert_test_runtime(pane_id, runtime);
+ }
}
}
diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs
index 71ca83bb..d38d115c 100644
--- a/src/app/terminal_targets.rs
+++ b/src/app/terminal_targets.rs
@@ -162,11 +162,7 @@ impl App {
workspace_id: self.public_workspace_id(ws_idx),
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
cwd: ws.tabs[tab_idx]
- .cwd_for_pane(
- pane_id,
- &self.state.terminals,
- &self.state.terminal_runtimes,
- )
+ .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
.map(|cwd| cwd.display().to_string()),
agent_status: pane_agent_status(terminal.state, pane.seen),
})
diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs
index 802725f0..a45818df 100644
--- a/src/app/theme_sync.rs
+++ b/src/app/theme_sync.rs
@@ -37,7 +37,7 @@ impl App {
return;
}
- for runtime in self.state.terminal_runtimes.values() {
+ for runtime in self.terminal_runtimes.values() {
runtime.apply_host_terminal_theme(self.state.host_terminal_theme);
}