aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-17 01:41:16 +0300
commita36da363f14639085bec91d7a378d6c2eeaa6c58 (patch)
tree2c86155b115f8c79db2b8826fd8b2199d153f7bf /src/app
parente4de58b37f58d59d146158239ed218a8accd5c3c (diff)
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs298
-rw-r--r--src/app/agents.rs66
-rw-r--r--src/app/api.rs75
-rw-r--r--src/app/api_helpers.rs4
-rw-r--r--src/app/creation.rs50
-rw-r--r--src/app/input/mod.rs21
-rw-r--r--src/app/input/modal.rs29
-rw-r--r--src/app/input/mouse.rs67
-rw-r--r--src/app/input/navigate.rs37
-rw-r--r--src/app/input/sidebar.rs183
-rw-r--r--src/app/input/terminal.rs58
-rw-r--r--src/app/mod.rs180
-rw-r--r--src/app/runtime.rs4
-rw-r--r--src/app/session.rs2
-rw-r--r--src/app/state.rs91
-rw-r--r--src/app/terminal_targets.rs19
-rw-r--r--src/app/theme_sync.rs8
17 files changed, 846 insertions, 346 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index be8e1018..27ca7474 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -6,7 +6,7 @@ use tracing::{info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
-use crate::pane::EffectiveStateChange;
+use crate::terminal::EffectiveStateChange;
use crate::workspace::WorkspaceGitStatus;
use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout};
@@ -91,6 +91,7 @@ pub struct PaneStateUpdate {
pub agent_label: Option<String>,
pub known_agent: Option<Agent>,
pub state: AgentState,
+ pub custom_status: Option<String>,
}
// ---------------------------------------------------------------------------
@@ -406,14 +407,75 @@ impl AppState {
self.agent_panel_scroll = self.agent_panel_scroll.min(max_scroll);
}
+ pub(crate) fn terminal_ids_for_workspace(
+ &self,
+ ws_idx: usize,
+ ) -> Vec<crate::terminal::TerminalId> {
+ self.workspaces
+ .get(ws_idx)
+ .into_iter()
+ .flat_map(|ws| &ws.tabs)
+ .flat_map(|tab| tab.panes.values())
+ .map(|pane| pane.attached_terminal_id.clone())
+ .collect()
+ }
+
+ pub(crate) fn terminal_ids_for_tab(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Vec<crate::terminal::TerminalId> {
+ self.workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .into_iter()
+ .flat_map(|tab| tab.panes.values())
+ .map(|pane| pane.attached_terminal_id.clone())
+ .collect()
+ }
+
+ pub(crate) fn terminal_id_for_pane(
+ &self,
+ ws_idx: usize,
+ pane_id: PaneId,
+ ) -> Option<crate::terminal::TerminalId> {
+ self.workspaces
+ .get(ws_idx)?
+ .pane_state(pane_id)
+ .map(|pane| pane.attached_terminal_id.clone())
+ }
+
+ pub(crate) fn remove_unattached_terminal_ids(
+ &mut self,
+ terminal_ids: impl IntoIterator<Item = crate::terminal::TerminalId>,
+ ) {
+ for terminal_id in terminal_ids {
+ let still_attached = self.workspaces.iter().any(|ws| {
+ ws.tabs.iter().any(|tab| {
+ tab.panes
+ .values()
+ .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();
+ }
+ }
+ }
+ }
+
pub fn close_selected_workspace(&mut self) {
if self.workspaces.is_empty() {
return;
}
self.mark_session_dirty();
+ let terminal_ids = self.terminal_ids_for_workspace(self.selected);
let workspace_id = self.workspaces[self.selected].id.clone();
crate::logging::workspace_closed(&workspace_id);
self.workspaces.remove(self.selected);
+ self.remove_unattached_terminal_ids(terminal_ids);
if self.workspaces.is_empty() {
self.active = None;
self.selected = 0;
@@ -529,12 +591,26 @@ impl AppState {
pub fn close_pane(&mut self) {
self.mark_session_dirty();
- let should_close_workspace = self
- .active
+ let active = self.active;
+ let terminal_ids = active
+ .and_then(|i| {
+ self.workspaces
+ .get(i)
+ .and_then(|ws| ws.focused_pane_id().map(|pane_id| (i, pane_id)))
+ })
+ .and_then(|(i, pane_id)| self.terminal_id_for_pane(i, pane_id))
+ .into_iter()
+ .collect::<Vec<_>>();
+ let should_close_workspace = active
.and_then(|i| self.workspaces.get_mut(i))
.is_some_and(|ws| ws.close_focused());
if should_close_workspace {
+ if let Some(active) = active {
+ self.selected = active;
+ }
self.close_selected_workspace();
+ } else {
+ self.remove_unattached_terminal_ids(terminal_ids);
}
}
@@ -545,16 +621,25 @@ impl AppState {
.and_then(|i| self.workspaces.get(i))
.is_some_and(|ws| ws.tabs.len() <= 1);
if should_close_workspace {
+ if let Some(active) = self.active {
+ self.selected = active;
+ }
self.close_selected_workspace();
return;
}
if let Some(ws_idx) = self.active {
+ let terminal_ids = self
+ .workspaces
+ .get(ws_idx)
+ .map(|ws| self.terminal_ids_for_tab(ws_idx, ws.active_tab))
+ .unwrap_or_default();
let Some(ws) = self.workspaces.get_mut(ws_idx) else {
return;
};
let workspace_id = ws.id.clone();
let closing_tab_id = format!("{}:{}", workspace_id, ws.active_tab + 1);
ws.close_active_tab();
+ self.remove_unattached_terminal_ids(terminal_ids);
crate::logging::tab_closed(&workspace_id, &closing_tab_id);
self.tab_scroll_follow_active = true;
self.refresh_tab_bar_view();
@@ -572,37 +657,23 @@ impl AppState {
}
pub fn copy_selection(&mut self) {
- let text = {
- let sel = match self.selection.as_mut() {
- Some(s) => {
- if !s.finish() {
- self.selection = None;
- return;
- }
- s
- }
- None => return,
- };
-
- let ws = match self.active.and_then(|i| self.workspaces.get(i)) {
- Some(ws) => ws,
- None => {
- self.selection = None;
- return;
- }
- };
-
- let rt = match ws.runtime(sel.pane_id) {
- Some(r) => r,
- None => {
- self.selection = None;
- return;
- }
- };
+ let mut sel = match self.selection.take() {
+ Some(sel) => sel,
+ None => return,
+ };
+ if !sel.finish() {
+ return;
+ }
- rt.extract_selection(sel)
+ let ws_idx = match self.active {
+ Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx,
+ _ => return,
};
+ let text = self
+ .runtime_for_pane_in_workspace(ws_idx, sel.pane_id)
+ .and_then(|rt| rt.extract_selection(&sel));
+
if let Some(text) = text {
if !text.is_empty() {
self.request_clipboard_write = Some(text.into_bytes());
@@ -678,7 +749,9 @@ impl AppState {
agent,
state,
} => self
- .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state))
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.set_detected_state(agent, state)
+ })
.into_iter()
.collect(),
AppEvent::HookStateReported {
@@ -690,8 +763,8 @@ impl AppState {
custom_status,
seq,
} => self
- .update_pane_state(pane_id, |pane| {
- pane.set_hook_authority_with_custom_status(
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.set_hook_authority_with_custom_status(
source,
agent_label,
state,
@@ -707,8 +780,8 @@ impl AppState {
source,
seq,
} => self
- .update_pane_state(pane_id, |pane| {
- pane.clear_hook_authority(source.as_deref(), seq)
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.clear_hook_authority(source.as_deref(), seq)
})
.into_iter()
.collect(),
@@ -719,8 +792,8 @@ impl AppState {
seq,
..
} => self
- .update_pane_state(pane_id, |pane| {
- pane.release_agent(&source, &agent_label, seq)
+ .update_terminal_state(pane_id, |terminal| {
+ terminal.release_agent(&source, &agent_label, seq)
})
.into_iter()
.collect(),
@@ -734,20 +807,21 @@ impl AppState {
}
}
- fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
+ fn update_terminal_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
where
- F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>,
+ F: FnOnce(&mut crate::terminal::TerminalState) -> Option<EffectiveStateChange>,
{
let ws_idx = self
.workspaces
.iter()
.position(|ws| ws.pane_state(pane_id).is_some())?;
+ let terminal_id = self.workspaces[ws_idx]
+ .pane_state(pane_id)?
+ .attached_terminal_id
+ .clone();
let change = {
- let pane = self.workspaces[ws_idx]
- .tabs
- .iter_mut()
- .find_map(|tab| tab.panes.get_mut(&pane_id))?;
- update(pane)?
+ let terminal = self.terminals.get_mut(&terminal_id)?;
+ update(terminal)?
};
let update = PaneStateUpdate {
pane_id,
@@ -758,6 +832,7 @@ impl AppState {
agent_label: change.agent_label.clone(),
known_agent: change.known_agent,
state: change.state,
+ custom_status: change.custom_status.clone(),
};
self.apply_pane_state_change(ws_idx, pane_id, &change);
Some(update)
@@ -838,6 +913,8 @@ impl AppState {
return;
};
+ let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
+ let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
let should_close_workspace = {
let ws = &mut self.workspaces[ws_idx];
ws.remove_pane(pane_id)
@@ -846,6 +923,7 @@ impl AppState {
if should_close_workspace {
self.workspaces.remove(ws_idx);
+ self.remove_unattached_terminal_ids(workspace_terminal_ids);
if self.workspaces.is_empty() {
self.active = None;
self.selected = 0;
@@ -862,6 +940,8 @@ impl AppState {
self.selected = self.workspaces.len() - 1;
}
}
+ } else {
+ self.remove_unattached_terminal_ids(pane_terminal_id);
}
}
}
@@ -883,6 +963,7 @@ mod tests {
let ws = Workspace::test_new(name);
state.workspaces.push(ws);
}
+ state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.mode = Mode::Terminal;
@@ -969,11 +1050,15 @@ mod tests {
}
fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) {
- state.workspaces[ws_idx].tabs[tab_idx]
+ let terminal_id = state.workspaces[ws_idx].tabs[tab_idx]
.panes
- .get_mut(&pane_id)
+ .get(&pane_id)
.unwrap()
- .set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ .attached_terminal_id
+ .clone();
+ if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ }
}
#[test]
@@ -987,6 +1072,7 @@ mod tests {
let mut state = AppState::test_new();
state.workspaces = vec![first, second];
+ state.ensure_test_terminals();
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
@@ -1044,6 +1130,7 @@ mod tests {
let mut state = AppState::test_new();
state.workspaces = vec![first, second];
+ state.ensure_test_terminals();
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
@@ -1068,6 +1155,7 @@ mod tests {
let mut state = AppState::test_new();
state.workspaces = vec![workspace];
+ state.ensure_test_terminals();
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Terminal;
@@ -1256,9 +1344,15 @@ mod tests {
state: AgentState::Working,
});
- let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
- assert_eq!(pane.state, AgentState::Working);
- assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ let terminal_id = state.workspaces[0]
+ .panes
+ .get(&pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
}
#[test]
@@ -1268,11 +1362,13 @@ mod tests {
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
// First set it to Working
- state.workspaces[1]
+ let bg_terminal_id = state.workspaces[1]
.panes
- .get_mut(&bg_pane_id)
+ .get(&bg_pane_id)
.unwrap()
- .state = AgentState::Working;
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working;
// Now transition to Idle while in background
state.handle_app_event(AppEvent::StateChanged {
@@ -1291,9 +1387,14 @@ mod tests {
state.active = Some(0);
state.outer_terminal_focus = Some(true);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
- let pane = state.workspaces[0].panes.get_mut(&pane_id).unwrap();
- pane.state = AgentState::Working;
- pane.seen = false;
+ let terminal_id = state.workspaces[0]
+ .panes
+ .get(&pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&terminal_id).unwrap().state = AgentState::Working;
+ state.workspaces[0].panes.get_mut(&pane_id).unwrap().seen = false;
state.handle_app_event(AppEvent::StateChanged {
pane_id,
@@ -1301,8 +1402,9 @@ mod tests {
state: AgentState::Idle,
});
+ let terminal = state.terminals.get(&terminal_id).unwrap();
+ assert_eq!(terminal.state, AgentState::Idle);
let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
- assert_eq!(pane.state, AgentState::Idle);
assert!(pane.seen);
}
@@ -1394,11 +1496,13 @@ mod tests {
state.active = Some(0);
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap();
- state.workspaces[1]
+ let bg_terminal_id = state.workspaces[1]
.panes
- .get_mut(&bg_pane_id)
+ .get(&bg_pane_id)
.unwrap()
- .state = AgentState::Working;
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Working;
state.handle_app_event(AppEvent::StateChanged {
pane_id: bg_pane_id,
@@ -1422,6 +1526,7 @@ mod tests {
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
state.workspaces[1].tabs[0].set_custom_name("main".into());
let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
+ state.ensure_test_terminals();
let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane;
state.handle_app_event(AppEvent::StateChanged {
@@ -1443,6 +1548,7 @@ mod tests {
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
state.workspaces[0].tabs[0].set_custom_name("main".into());
let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
+ state.ensure_test_terminals();
let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane;
state.handle_app_event(AppEvent::StateChanged {
@@ -1567,4 +1673,74 @@ mod tests {
state.close_pane();
assert_eq!(state.workspaces[0].panes.len(), 1);
}
+
+ #[test]
+ fn close_pane_removes_unattached_terminal_state() {
+ let mut state = app_with_workspaces(&["test"]);
+ let pane_id = state.workspaces[0].test_split(Direction::Horizontal);
+ state.ensure_test_terminals();
+ let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
+
+ state.close_pane();
+
+ assert!(!state.terminals.contains_key(&terminal_id));
+ }
+
+ #[test]
+ fn close_tab_removes_unattached_terminal_states() {
+ let mut state = app_with_workspaces(&["test"]);
+ let tab_idx = state.workspaces[0].test_add_tab(Some("logs"));
+ state.ensure_test_terminals();
+ state.workspaces[0].switch_tab(tab_idx);
+ let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
+ let terminal_id = state.terminal_id_for_pane(0, pane_id).unwrap();
+
+ state.close_tab();
+
+ assert!(!state.terminals.contains_key(&terminal_id));
+ }
+
+ #[test]
+ fn close_workspace_removes_unattached_terminal_states() {
+ let mut state = app_with_workspaces(&["one", "two"]);
+ let terminal_id = state
+ .terminal_id_for_pane(0, state.workspaces[0].tabs[0].root_pane)
+ .unwrap();
+
+ state.close_selected_workspace();
+
+ assert!(!state.terminals.contains_key(&terminal_id));
+ }
+
+ #[test]
+ fn close_tab_last_tab_closes_active_workspace_not_selected_workspace() {
+ let mut state = app_with_workspaces(&["selected", "active"]);
+ let active_terminal_id = state
+ .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane)
+ .unwrap();
+ state.active = Some(1);
+ state.selected = 0;
+
+ state.close_tab();
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "selected");
+ assert!(!state.terminals.contains_key(&active_terminal_id));
+ }
+
+ #[test]
+ fn close_pane_last_pane_closes_active_workspace_not_selected_workspace() {
+ let mut state = app_with_workspaces(&["selected", "active"]);
+ let active_terminal_id = state
+ .terminal_id_for_pane(1, state.workspaces[1].tabs[0].root_pane)
+ .unwrap();
+ state.active = Some(1);
+ state.selected = 0;
+
+ state.close_pane();
+
+ assert_eq!(state.workspaces.len(), 1);
+ assert_eq!(state.workspaces[0].display_name(), "selected");
+ assert!(!state.terminals.contains_key(&active_terminal_id));
+ }
}
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 35e26c42..f9e410f5 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -77,19 +77,19 @@ impl App {
}
}
- let Some(ws) = self.state.workspaces.get_mut(resolved.ws_idx) else {
- return Err(AgentRenameError::Target(TerminalTargetError::NotFound {
- target: target.to_string(),
- }));
- };
- let Some(pane_state) = ws.pane_state_mut(resolved.pane_id) else {
+ let Some(terminal) = self
+ .state
+ .terminals
+ .values_mut()
+ .find(|terminal| terminal.id.to_string() == resolved.terminal_id)
+ else {
return Err(AgentRenameError::Target(TerminalTargetError::NotFound {
target: target.to_string(),
}));
};
match normalized_name {
- Some(name) => pane_state.set_manual_label(name),
- None => pane_state.clear_manual_label(),
+ Some(name) => terminal.set_manual_label(name),
+ None => terminal.clear_manual_label(),
}
self.state.mark_session_dirty();
self.agent_info(resolved.ws_idx, resolved.pane_id)
@@ -185,13 +185,17 @@ impl App {
)?
};
- let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
- return Err(AgentStartError::SpawnFailed("workspace disappeared".into()));
- };
- let Some(pane_state) = ws.pane_state_mut(pane_id) else {
- return Err(AgentStartError::SpawnFailed("pane disappeared".into()));
+ let terminal_id = self
+ .state
+ .workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.terminal_id(pane_id))
+ .cloned()
+ .ok_or_else(|| AgentStartError::SpawnFailed("terminal disappeared".into()))?;
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
+ return Err(AgentStartError::SpawnFailed("terminal disappeared".into()));
};
- pane_state.set_manual_label(name);
+ terminal.set_manual_label(name);
self.state.mark_session_dirty();
let agent = self
@@ -317,7 +321,7 @@ impl App {
argv: &[String],
focus: bool,
) -> Result<(usize, usize, crate::layout::PaneId), AgentStartError> {
- let ws = crate::workspace::Workspace::new_argv_command(
+ let (ws, terminal, runtime) = crate::workspace::Workspace::new_argv_command(
cwd,
rows,
cols,
@@ -329,6 +333,10 @@ impl App {
self.render_dirty.clone(),
)
.map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
+ self.state
+ .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;
if focus || self.state.active.is_none() {
@@ -375,13 +383,19 @@ impl App {
target: target_pane.raw().to_string(),
})?
.map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?;
+ self.state
+ .terminal_runtimes
+ .insert(result.1.terminal.id.clone(), result.1.runtime);
+ self.state
+ .terminals
+ .insert(result.1.terminal.id.clone(), result.1.terminal);
if focus {
self.state.switch_workspace(ws_idx);
self.state.switch_tab(result.0);
self.state.mode = Mode::Terminal;
}
self.schedule_session_save();
- Ok((ws_idx, result.0, result.1))
+ Ok((ws_idx, result.0, result.1.pane_id))
}
fn agent_info(
@@ -409,23 +423,11 @@ impl App {
name: &str,
except_terminal_id: &str,
) -> Vec<crate::api::schema::AgentInfo> {
- self.state
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- ws.tabs.iter().flat_map(move |tab| {
- tab.layout
- .pane_ids()
- .into_iter()
- .filter_map(move |pane_id| {
- let pane_state = ws.pane_state(pane_id)?;
- (pane_state.manual_label.as_deref() == Some(name))
- .then(|| self.agent_info(ws_idx, pane_id))?
- })
- })
+ self.collect_agent_infos()
+ .into_iter()
+ .filter(|agent| {
+ agent.name.as_deref() == Some(name) && agent.terminal_id != except_terminal_id
})
- .filter(|agent| agent.terminal_id != except_terminal_id)
.collect()
}
}
diff --git a/src/app/api.rs b/src/app/api.rs
index c6fa88fa..ab26f82b 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -75,7 +75,8 @@ 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.workspaces[ws_idx].runtimes.get(&pane_id) {
+ if let Some(runtime) = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)
+ {
runtime.begin_graceful_release(agent);
}
}
@@ -129,7 +130,12 @@ impl App {
else {
continue;
};
- let Some(agent_label) = pane.effective_agent_label() else {
+ let Some(agent_label) = self
+ .state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .and_then(|terminal| terminal.effective_agent_label())
+ else {
continue;
};
let event_text = match kind {
@@ -199,14 +205,9 @@ impl App {
.workspaces
.get(update.ws_idx)
.and_then(|ws| ws.pane_state(update.pane_id))
- .map(|pane| pane_agent_status(pane.state, pane.seen))
+ .map(|pane| pane_agent_status(update.state, pane.seen))
.unwrap_or_else(|| pane_agent_status(update.state, true));
- let custom_status = self
- .state
- .workspaces
- .get(update.ws_idx)
- .and_then(|ws| ws.pane_state(update.pane_id))
- .and_then(|pane| pane.effective_custom_status().map(str::to_string));
+ let custom_status = update.custom_status.clone();
self.emit_event(crate::api::schema::EventEnvelope {
event: crate::api::schema::EventKind::PaneAgentStatusChanged,
data: crate::api::schema::EventData::PaneAgentStatusChanged {
@@ -296,7 +297,7 @@ impl App {
.state
.workspaces
.get(ws_idx)
- .and_then(|ws| ws.runtime(pane_id))
+ .and_then(|_| self.state.runtime_for_pane_in_workspace(ws_idx, pane_id))
else {
return;
};
@@ -629,11 +630,9 @@ impl App {
let cwd = cwd
.map(std::path::PathBuf::from)
.or_else(|| {
- self.state.workspaces.get(ws_idx).and_then(|ws| {
- ws.active_tab()
- .and_then(|tab| tab.focused_runtime())
- .and_then(|rt| rt.cwd())
- })
+ 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("/"));
@@ -653,7 +652,11 @@ impl App {
)
});
match result {
- Ok(tab_idx) => {
+ 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
@@ -785,6 +788,7 @@ impl App {
})
.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,
@@ -815,6 +819,7 @@ impl App {
})
.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,
@@ -1004,6 +1009,16 @@ impl App {
.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 Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return serde_json::to_string(&ErrorResponse {
id: request.id,
@@ -1022,12 +1037,12 @@ impl App {
ratatui::layout::Direction::Vertical
}
};
- let (target_tab_idx, new_pane_id) = match ws.split_pane(
+ let (target_tab_idx, new_pane) = match ws.split_pane(
target_pane_id,
direction,
rows,
cols,
- params.cwd.map(std::path::PathBuf::from),
+ split_cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
params.focus,
@@ -1059,8 +1074,14 @@ impl App {
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_id).unwrap();
+ 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() },
@@ -1122,7 +1143,13 @@ impl App {
})
.unwrap();
};
- let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
+ 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 {
@@ -1132,7 +1159,7 @@ impl App {
})
.unwrap();
};
- let Some(pane_state) = ws.pane_state_mut(pane_id) else {
+ let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
return serde_json::to_string(&ErrorResponse {
id: request.id,
error: ErrorBody {
@@ -1143,8 +1170,8 @@ impl App {
.unwrap();
};
match params.label.map(|label| label.trim().to_string()) {
- Some(label) if !label.is_empty() => pane_state.set_manual_label(label),
- _ => pane_state.clear_manual_label(),
+ 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();
@@ -1419,6 +1446,7 @@ impl App {
.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 {
@@ -1447,6 +1475,7 @@ impl App {
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,
diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs
index 56907ece..1ebb30ef 100644
--- a/src/app/api_helpers.rs
+++ b/src/app/api_helpers.rs
@@ -30,7 +30,7 @@ fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> {
}
}
-pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> {
+pub(super) fn encode_api_text(runtime: &crate::terminal::TerminalRuntime, text: &str) -> Vec<u8> {
let bracketed = runtime
.input_state()
.map(|state| state.bracketed_paste)
@@ -43,7 +43,7 @@ pub(super) fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) ->
}
pub(super) fn encode_api_keys(
- runtime: &crate::pane::PaneRuntime,
+ runtime: &crate::terminal::TerminalRuntime,
keys: &[String],
) -> Result<Vec<Vec<u8>>, String> {
let mut encoded_keys = Vec::with_capacity(keys.len());
diff --git a/src/app/creation.rs b/src/app/creation.rs
index 6e557066..1832e637 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -78,13 +78,17 @@ impl App {
};
let (rows, cols) = self.state.estimate_pane_size();
let ws = &mut self.state.workspaces[ws_idx];
- let idx = ws.create_tab(
+ let (idx, terminal, runtime) = ws.create_tab(
rows,
cols,
initial_cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
)?;
+ self.state
+ .terminal_runtimes
+ .insert(terminal.id.clone(), runtime);
+ self.state.terminals.insert(terminal.id.clone(), terminal);
if focus {
ws.switch_tab(idx);
self.state.mode = Mode::Terminal;
@@ -105,7 +109,7 @@ impl App {
focus: bool,
) -> std::io::Result<usize> {
let (rows, cols) = self.state.estimate_pane_size();
- let ws = Workspace::new(
+ let (ws, terminal, runtime) = Workspace::new(
initial_cwd,
rows,
cols,
@@ -115,6 +119,10 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
)?;
+ self.state
+ .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;
let workspace_id = self.state.workspaces[idx].id.clone();
@@ -177,7 +185,12 @@ impl App {
let (agg_state, seen) = tab
.panes
.values()
- .map(|pane| (pane.state, pane.seen))
+ .filter_map(|pane| {
+ self.state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .map(|terminal| (terminal.state, pane.seen))
+ })
.max_by_key(|(state, seen)| tab_attention_priority(*state, *seen))
.unwrap_or((crate::detect::AgentState::Unknown, true));
Some(crate::api::schema::TabInfo {
@@ -230,6 +243,7 @@ impl App {
) -> Option<crate::api::schema::PaneInfo> {
let ws = self.state.workspaces.get(ws_idx)?;
let pane = ws.pane_state(pane_id)?;
+ let terminal = self.state.terminals.get(&pane.attached_terminal_id)?;
let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
let focused = self.state.active == Some(ws_idx)
&& ws.active_tab == tab_idx
@@ -238,18 +252,22 @@ impl App {
.is_some_and(|focused| focused == pane_id);
Some(crate::api::schema::PaneInfo {
pane_id: self.public_pane_id(ws_idx, pane_id)?,
- terminal_id: ws.terminal_id(pane_id)?.to_string(),
+ terminal_id: terminal.id.to_string(),
workspace_id: self.public_workspace_id(ws_idx),
tab_id: self.public_tab_id(ws_idx, tab_idx)?,
focused,
cwd: ws.tabs[tab_idx]
- .cwd_for_pane(pane_id)
+ .cwd_for_pane(
+ pane_id,
+ &self.state.terminals,
+ &self.state.terminal_runtimes,
+ )
.map(|cwd| cwd.display().to_string()),
- label: pane.manual_label.clone(),
- agent: pane.effective_agent_label().map(str::to_string),
- agent_status: pane_agent_status(pane.state, pane.seen),
- custom_status: pane.effective_custom_status().map(str::to_string),
- revision: 0,
+ label: terminal.manual_label.clone(),
+ agent: terminal.effective_agent_label().map(str::to_string),
+ agent_status: pane_agent_status(terminal.state, pane.seen),
+ custom_status: terminal.effective_custom_status().map(str::to_string),
+ revision: terminal.revision,
})
}
@@ -257,9 +275,8 @@ impl App {
&self,
ws_idx: usize,
pane_id: crate::layout::PaneId,
- ) -> Option<(&crate::pane::PaneRuntime, String)> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let runtime = ws.runtime(pane_id)?;
+ ) -> Option<(&crate::terminal::TerminalRuntime, String)> {
+ let runtime = self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)?;
Some((runtime, self.public_workspace_id(ws_idx)))
}
@@ -267,14 +284,13 @@ impl App {
&self,
ws_idx: usize,
pane_id: crate::layout::PaneId,
- ) -> Option<&crate::pane::PaneRuntime> {
- let ws = self.state.workspaces.get(ws_idx)?;
- ws.runtime(pane_id)
+ ) -> Option<&crate::terminal::TerminalRuntime> {
+ self.state.runtime_for_pane_in_workspace(ws_idx, pane_id)
}
pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
let ws = &self.state.workspaces[index];
- let (agg_state, seen) = ws.aggregate_state();
+ let (agg_state, seen) = ws.aggregate_state(&self.state.terminals);
crate::api::schema::WorkspaceInfo {
workspace_id: self.public_workspace_id(index),
number: index + 1,
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 575b373d..fea88b55 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -82,8 +82,8 @@ impl App {
if self.state.mode != Mode::Terminal {
return;
}
- if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
+ if let Some(ws_idx) = self.state.active {
+ if let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) {
let _ = rt.send_paste(text).await;
}
}
@@ -197,11 +197,17 @@ impl AppState {
let cwd = self
.active
.and_then(|i| self.workspaces.get(i))
- .and_then(|ws| ws.focused_runtime())
- .and_then(|rt| rt.cwd());
+ .and_then(|ws| {
+ let tab = ws.active_tab()?;
+ tab.cwd_for_pane(
+ tab.layout.focused(),
+ &self.terminals,
+ &self.terminal_runtimes,
+ )
+ });
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- if let Ok(new_id) = ws.split_focused(
+ if let Ok(new_pane) = ws.split_focused(
direction,
new_rows,
new_cols,
@@ -209,6 +215,9 @@ impl AppState {
self.pane_scrollback_limit_bytes,
self.host_terminal_theme,
) {
+ let new_id = new_pane.pane_id;
+ self.terminals
+ .insert(new_pane.terminal.id.clone(), new_pane.terminal);
ws.layout.focus_pane(new_id);
self.mark_session_dirty();
self.mode = Mode::Terminal;
@@ -277,6 +286,8 @@ fn numbered_lines_bytes(count: usize) -> Vec<u8> {
fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
crate::persist::capture(
&state.workspaces,
+ &state.terminals,
+ &state.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 367e7e84..6de349cb 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -190,11 +190,14 @@ pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::Pan
let Some(pane) = ws.pane_state(pane_id) else {
return;
};
+ let terminal = state.terminals.get(&pane.attached_terminal_id);
state.creating_new_tab = false;
state.requested_new_tab_name = None;
state.rename_pane_target = Some(pane_id);
- state.name_input = pane.manual_label.clone().unwrap_or_default();
- state.name_input_replace_on_type = pane.manual_label.is_none();
+ state.name_input = terminal
+ .and_then(|t| t.manual_label.clone())
+ .unwrap_or_default();
+ state.name_input_replace_on_type = terminal.and_then(|t| t.manual_label.as_ref()).is_none();
state.mode = Mode::RenamePane;
}
@@ -316,10 +319,13 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) {
Mode::RenamePane => {
if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target)
{
- if let Some(ws) = state.workspaces.get_mut(ws_idx) {
- if let Some(pane) = ws.pane_state_mut(pane_id) {
- pane.set_manual_label(new_name);
- state.mark_session_dirty();
+ if let Some(ws) = state.workspaces.get(ws_idx) {
+ if let Some(pane) = ws.pane_state(pane_id) {
+ let terminal_id = pane.attached_terminal_id.clone();
+ if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
+ terminal.set_manual_label(new_name);
+ state.mark_session_dirty();
+ }
}
}
}
@@ -468,10 +474,13 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS
}
(ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => {
if let Some(ws_idx) = state.active {
- if let Some(ws) = state.workspaces.get_mut(ws_idx) {
- if let Some(pane) = ws.pane_state_mut(pane_id) {
- pane.clear_manual_label();
- state.mark_session_dirty();
+ if let Some(ws) = state.workspaces.get(ws_idx) {
+ if let Some(pane) = ws.pane_state(pane_id) {
+ let terminal_id = pane.attached_terminal_id.clone();
+ if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
+ terminal.clear_manual_label();
+ state.mark_session_dirty();
+ }
}
}
}
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
index a7a2159a..10ef9c18 100644
--- a/src/app/input/mouse.rs
+++ b/src/app/input/mouse.rs
@@ -712,7 +712,8 @@ impl AppState {
.active
.and_then(|ws_idx| self.workspaces.get(ws_idx))
.and_then(|ws| ws.pane_state(info.id))
- .and_then(|pane| pane.manual_label.as_ref())
+ .and_then(|pane| self.terminals.get(&pane.attached_terminal_id))
+ .and_then(|terminal| terminal.manual_label.as_ref())
.is_some();
self.context_menu = Some(ContextMenuState {
kind: ContextMenuKind::Pane {
@@ -1061,16 +1062,16 @@ impl AppState {
}
pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
+ if let Some(ws_idx) = self.active {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
rt.scroll_up(lines);
}
}
}
pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
+ if let Some(ws_idx) = self.active {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
rt.scroll_down(lines);
}
}
@@ -1081,9 +1082,8 @@ impl AppState {
pane_id: crate::layout::PaneId,
) -> Option<crate::pane::ScrollMetrics> {
self.active
- .and_then(|i| self.workspaces.get(i))
- .and_then(|ws| ws.runtime(pane_id))
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .and_then(|i| self.runtime_for_pane_in_workspace(i, pane_id))
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
}
pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
@@ -1112,8 +1112,8 @@ impl AppState {
return;
}
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
+ if let Some(ws_idx) = self.active {
+ if let Some(rt) = self.focused_runtime_in_workspace(ws_idx) {
match mouse.kind {
MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
@@ -1124,10 +1124,10 @@ impl AppState {
}
pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
- let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ let Some(ws_idx) = self.active else {
return false;
};
- let Some(rt) = ws.runtimes.get(&info.id) else {
+ let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
return false;
};
let column = mouse.column.saturating_sub(info.inner_rect.x);
@@ -1169,10 +1169,10 @@ impl AppState {
}
pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
- let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ let Some(ws_idx) = self.active else {
return false;
};
- let Some(rt) = ws.runtimes.get(&info.id) else {
+ let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, info.id) else {
return false;
};
match rt.wheel_routing() {
@@ -1209,8 +1209,8 @@ impl AppState {
pane_id: crate::layout::PaneId,
offset_from_bottom: usize,
) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
+ if let Some(ws_idx) = self.active {
+ if let Some(rt) = self.runtime_for_pane_in_workspace(ws_idx, pane_id) {
rt.set_scroll_offset_from_bottom(offset_from_bottom);
}
}
@@ -1221,7 +1221,7 @@ impl AppState {
col: u16,
row: u16,
) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
- let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let ws_idx = self.active?;
let info = self.view.pane_infos.iter().find(|info| {
crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
col >= track.x
@@ -1230,7 +1230,7 @@ impl AppState {
&& row < track.y + track.height
})
})?;
- let rt = ws.runtimes.get(&info.id)?;
+ let rt = self.runtime_for_pane_in_workspace(ws_idx, info.id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
@@ -1254,14 +1254,14 @@ impl AppState {
row: u16,
grab_row_offset: u16,
) -> Option<usize> {
- let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let ws_idx = self.active?;
let info = self
.view
.pane_infos
.iter()
.find(|info| info.id == pane_id)?;
let track = crate::ui::pane_scrollbar_rect(info)?;
- let rt = ws.runtimes.get(&pane_id)?;
+ let rt = self.runtime_for_pane_in_workspace(ws_idx, pane_id)?;
let metrics = rt.scroll_metrics()?;
if metrics.max_offset_from_bottom == 0 {
return None;
@@ -1345,12 +1345,19 @@ mod tests {
background.tabs[0].layout.focus_pane(first_pane);
app.state.workspaces = vec![active, background];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
- app.state.workspaces[1]
+ let target_terminal_id = app.state.workspaces[1]
.panes
- .get_mut(&target_pane)
+ .get(&target_pane)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&target_terminal_id)
.unwrap()
.state = AgentState::Working;
@@ -1606,17 +1613,17 @@ mod tests {
.expect("second pane info")
.clone();
- app.state.workspaces[0].tabs[0].runtimes.insert(
+ app.state.insert_test_runtime(
first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
- app.state.workspaces[0].tabs[0].runtimes.insert(
+ app.state.insert_test_runtime(
second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
@@ -1675,17 +1682,17 @@ mod tests {
.expect("second pane info")
.clone();
- app.state.workspaces[0].tabs[0].runtimes.insert(
+ app.state.insert_test_runtime(
first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"\x1b[?1002h",
),
);
- app.state.workspaces[0].tabs[0].runtimes.insert(
+ app.state.insert_test_runtime(
second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index f5a0bcaa..9fc11605 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -126,10 +126,10 @@ impl App {
}
fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool {
- let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) else {
+ let Some(ws_idx) = self.state.active else {
return false;
};
- let Some(rt) = ws.focused_runtime() else {
+ let Some(rt) = self.state.focused_runtime_in_workspace(ws_idx) else {
return false;
};
@@ -191,10 +191,13 @@ impl App {
if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
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))
- {
+ if let Some(pane_cwd) = workspace.active_tab().and_then(|tab| {
+ tab.cwd_for_pane(
+ pane_id,
+ &self.state.terminals,
+ &self.state.terminal_runtimes,
+ )
+ }) {
env.push((
"HERDR_ACTIVE_PANE_CWD".to_string(),
pane_cwd.display().to_string(),
@@ -308,10 +311,14 @@ impl App {
.focused_pane_id()
.ok_or_else(|| std::io::Error::other("no focused pane"))?;
let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
- let cwd = ws
- .active_tab()
- .and_then(|tab| tab.cwd_for_pane(previous_focus));
- let new_pane_id = ws.split_focused_command(
+ let cwd = ws.active_tab().and_then(|tab| {
+ tab.cwd_for_pane(
+ previous_focus,
+ &self.state.terminals,
+ &self.state.terminal_runtimes,
+ )
+ });
+ let new_pane = ws.split_focused_command(
Direction::Horizontal,
new_rows,
new_cols,
@@ -321,6 +328,10 @@ impl App {
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
)?;
+ let new_pane_id = new_pane.pane_id;
+ self.state
+ .terminals
+ .insert(new_pane.terminal.id.clone(), new_pane.terminal);
ws.active_tab_mut()
.expect("workspace must have an active tab")
.layout
@@ -1072,7 +1083,7 @@ mod tests {
api_rx,
crate::api::EventHub::default(),
);
- let workspace = Workspace::new(
+ let (workspace, terminal, runtime) = Workspace::new(
std::env::current_dir().unwrap_or_else(|_| "/".into()),
24,
80,
@@ -1084,6 +1095,10 @@ mod tests {
)
.expect("workspace should spawn");
app.state.workspaces = vec![workspace];
+ app.state
+ .terminal_runtimes
+ .insert(terminal.id.clone(), runtime);
+ app.state.terminals.insert(terminal.id.clone(), terminal);
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 8f6c7792..e8e1e094 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -351,7 +351,7 @@ impl AppState {
let ws_idx = self.collapsed_detail_workspace_idx()?;
let ws = self.workspaces.get(ws_idx)?;
let detail_idx = (row - detail_content_area.y) as usize;
- let details = ws.pane_details();
+ let details = ws.pane_details(&self.terminals);
let detail = details.get(detail_idx)?;
Some((ws_idx, detail.tab_idx, detail.pane_id))
}
@@ -633,19 +633,26 @@ mod tests {
let mut ws = Workspace::test_new("test");
ws.tabs[0].set_custom_name("main".into());
let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
+ let first_tab = ws.test_add_tab(Some("logs"));
+ let second_pane = ws.tabs[first_tab].root_pane;
+ app.state.workspaces = vec![ws];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
.unwrap()
.detected_agent = Some(Agent::Pi);
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
+ let second_terminal_id = app.state.workspaces[0].tabs[first_tab].panes[&second_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
.unwrap()
.detected_agent = Some(Agent::Claude);
- app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -659,9 +666,9 @@ mod tests {
);
assert_eq!(app.state.mode, Mode::Terminal);
let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.workspaces[0].active_tab, second_tab);
+ assert_eq!(snapshot.workspaces[0].active_tab, first_tab);
assert_eq!(
- snapshot.workspaces[0].tabs[second_tab].focused,
+ snapshot.workspaces[0].tabs[first_tab].focused,
Some(second_pane.raw())
);
}
@@ -701,23 +708,30 @@ mod tests {
#[test]
fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
let mut app = app_for_mouse_test();
- let mut first = Workspace::test_new("one");
+ let first = Workspace::test_new("one");
let first_pane = first.tabs[0].root_pane;
- first.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let mut second = Workspace::test_new("two");
+ let second = Workspace::test_new("two");
let second_pane = second.tabs[0].root_pane;
- second.tabs[0]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
app.state.workspaces = vec![first, second];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -747,12 +761,8 @@ mod tests {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
+ let mut tabs = Vec::new();
for (tab_name, agent) in [
("logs", Agent::Claude),
("review", Agent::Codex),
@@ -760,14 +770,29 @@ mod tests {
] {
let tab_idx = ws.test_add_tab(Some(tab_name));
let pane_id = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&pane_id)
- .unwrap()
- .detected_agent = Some(agent);
+ tabs.push((tab_idx, pane_id, agent));
}
app.state.workspaces = vec![ws];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ for (tab_idx, pane_id, agent) in tabs {
+ let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(agent);
+ }
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -792,31 +817,43 @@ mod tests {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
-
let second_tab = ws.test_add_tab(Some("logs"));
let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
+ let mut extra_tabs = Vec::new();
for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] {
let tab_idx = ws.test_add_tab(Some(tab_name));
let pane_id = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&pane_id)
- .unwrap()
- .detected_agent = Some(agent);
+ extra_tabs.push((tab_idx, pane_id, agent));
}
app.state.workspaces = vec![ws];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+ for (tab_idx, pane_id, agent) in extra_tabs {
+ let terminal_id = app.state.workspaces[0].tabs[tab_idx].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&terminal_id)
+ .unwrap()
+ .detected_agent = Some(agent);
+ }
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -843,19 +880,26 @@ mod tests {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
let second_tab = ws.test_add_tab(Some("logs"));
let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
+ app.state.workspaces = vec![ws];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_terminal_id = app.state.workspaces[0].tabs[second_tab].panes[&second_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
.unwrap()
.detected_agent = Some(Agent::Claude);
- app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -1128,20 +1172,27 @@ mod tests {
let mut first = Workspace::test_new("a");
let first_root = first.tabs[0].root_pane;
first.identity_cwd = first_repo.clone();
- first.tabs[0]
- .pane_cwds
- .insert(first_root, first_repo.clone());
first.refresh_git_ahead_behind();
let mut second = Workspace::test_new("b");
let second_root = second.tabs[0].root_pane;
second.identity_cwd = second_repo.clone();
- second.tabs[0]
- .pane_cwds
- .insert(second_root, second_repo.clone());
second.refresh_git_ahead_behind();
app.state.workspaces = vec![first, second];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0].tabs[0].panes[&first_root]
+ .attached_terminal_id
+ .clone();
+ app.state.terminals.get_mut(&first_terminal_id).unwrap().cwd = first_repo.clone();
+ let second_terminal_id = app.state.workspaces[1].tabs[0].panes[&second_root]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
+ .unwrap()
+ .cwd = second_repo.clone();
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0));
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 22cf3169..3f0bff2c 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -64,7 +64,7 @@ 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 = ws.runtimes.get(&pane_id)?;
+ let rt = self.state.runtime_for_pane_in_workspace(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
@@ -179,9 +179,9 @@ mod tests {
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
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,
@@ -195,9 +195,10 @@ mod tests {
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
- let start_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ let start_metrics = app
+ .state
+ .runtime_for_pane(pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
let start_row = info.inner_rect.y;
let start_col = info.inner_rect.x + 2;
@@ -213,9 +214,10 @@ mod tests {
info.inner_rect.y.saturating_sub(1),
));
- let end_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ let end_metrics = app
+ .state
+ .runtime_for_pane(pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after drag");
assert_eq!(
end_metrics.offset_from_bottom,
@@ -243,9 +245,9 @@ mod tests {
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
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,
@@ -283,9 +285,9 @@ mod tests {
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
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,
@@ -299,9 +301,10 @@ mod tests {
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
- let start_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ let start_metrics = app
+ .state
+ .runtime_for_pane(pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("initial scroll metrics");
let top_row = info.inner_rect.y;
let col = info.inner_rect.x + 2;
@@ -309,9 +312,10 @@ mod tests {
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));
- let end_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ let end_metrics = app
+ .state
+ .runtime_for_pane(pane_id)
+ .and_then(crate::terminal::TerminalRuntime::scroll_metrics)
.expect("scroll metrics after wheel");
assert_eq!(
end_metrics.offset_from_bottom,
@@ -352,17 +356,17 @@ mod tests {
.unwrap()
.clone();
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"",
),
);
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
@@ -410,17 +414,17 @@ mod tests {
.unwrap()
.clone();
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
first_info.inner_rect.width.max(1),
first_info.inner_rect.height.max(1),
b"",
),
);
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(
second_info.inner_rect.width.max(1),
second_info.inner_rect.height.max(1),
b"\x1b[?1002h",
diff --git a/src/app/mod.rs b/src/app/mod.rs
index d37bcb5d..b1fb9775 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -207,6 +207,8 @@ impl App {
let render_dirty = Arc::new(AtomicBool::new(false));
// Try to restore previous session
+ let mut restored_terminals = std::collections::HashMap::new();
+ let mut restored_terminal_runtimes = std::collections::HashMap::new();
let (
workspaces,
active,
@@ -226,7 +228,7 @@ impl App {
0.5_f32,
)
} else if let Some(snap) = crate::persist::load() {
- let ws = crate::persist::restore(
+ let (ws, terminals, terminal_runtimes) = crate::persist::restore(
&snap,
24,
80,
@@ -235,6 +237,8 @@ impl App {
render_notify.clone(),
render_dirty.clone(),
);
+ restored_terminals = terminals;
+ restored_terminal_runtimes = terminal_runtimes;
if ws.is_empty() {
crate::logging::session_restored(0, "empty");
(
@@ -306,6 +310,8 @@ impl App {
};
let mut state = AppState {
+ terminals: std::collections::HashMap::new(),
+ terminal_runtimes: std::collections::HashMap::new(),
workspaces,
active,
selected,
@@ -402,6 +408,9 @@ impl App {
session_dirty: false,
};
+ state.terminals = restored_terminals;
+ state.terminal_runtimes = restored_terminal_runtimes;
+
for ws in &mut state.workspaces {
ws.refresh_git_branch();
}
@@ -857,7 +866,9 @@ 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) = ws.runtimes.get(&focused) {
+ if let Some(runtime) =
+ self.state.runtime_for_pane_in_workspace(ws_idx, focused)
+ {
let _ = runtime.try_send_bytes(bytes::Bytes::from(
if runtime
.input_state()
@@ -945,7 +956,7 @@ mod tests {
use super::*;
use crate::config::Config;
use crate::detect::{Agent, AgentState};
- use crate::pane::PaneRuntime;
+ use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
use std::sync::{Mutex, OnceLock};
@@ -1369,22 +1380,44 @@ mod tests {
let background_tab = workspace.test_add_tab(Some("background"));
let background_pane = workspace.tabs[background_tab].root_pane;
- workspace.tabs[0].panes.get_mut(&root_pane).unwrap().state = AgentState::Idle;
- workspace.tabs[0].panes.get_mut(&root_pane).unwrap().seen = false;
- workspace.tabs[0].panes.get_mut(&split_pane).unwrap().state = AgentState::Idle;
- workspace.tabs[0].panes.get_mut(&split_pane).unwrap().seen = false;
- workspace.tabs[background_tab]
+ app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ let root_terminal_id = app.state.workspaces[0].tabs[0].panes[&root_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&root_terminal_id)
+ .unwrap()
+ .state = AgentState::Idle;
+ app.state.workspaces[0].tabs[0]
.panes
- .get_mut(&background_pane)
+ .get_mut(&root_pane)
+ .unwrap()
+ .seen = false;
+ let split_terminal_id = app.state.workspaces[0].tabs[0].panes[&split_pane]
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&split_terminal_id)
.unwrap()
.state = AgentState::Idle;
- workspace.tabs[background_tab]
+ app.state.workspaces[0].tabs[0]
+ .panes
+ .get_mut(&split_pane)
+ .unwrap()
+ .seen = false;
+ let bg_terminal_id = app.state.workspaces[0].tabs[background_tab].panes[&background_pane]
+ .attached_terminal_id
+ .clone();
+ app.state.terminals.get_mut(&bg_terminal_id).unwrap().state = AgentState::Idle;
+ app.state.workspaces[0].tabs[background_tab]
.panes
.get_mut(&background_pane)
.unwrap()
.seen = false;
- app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
@@ -1498,6 +1531,7 @@ mod tests {
fn workspace_create_response_includes_initial_tab_and_root_pane() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("api-root-pane")];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1524,6 +1558,7 @@ mod tests {
let mut workspace = Workspace::test_new("api-tab-root-pane");
workspace.test_add_tab(None);
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1542,15 +1577,9 @@ mod tests {
fn workspace_creation_in_navigate_mode_uses_selected_workspace_seed_cwd() {
let mut app = test_app();
let mut first = Workspace::test_new("herdr");
- let first_root = first.tabs[0].root_pane;
- first.tabs[0]
- .pane_cwds
- .insert(first_root, std::path::PathBuf::from("/tmp/herdr"));
+ first.identity_cwd = std::path::PathBuf::from("/tmp/herdr");
let mut second = Workspace::test_new("pion");
- let second_root = second.tabs[0].root_pane;
- second.tabs[0]
- .pane_cwds
- .insert(second_root, std::path::PathBuf::from("/tmp/pion"));
+ second.identity_cwd = std::path::PathBuf::from("/tmp/pion");
app.state.workspaces = vec![first, second];
app.state.active = Some(0);
@@ -1586,6 +1615,7 @@ mod tests {
let workspace = Workspace::test_new("api-pane-rename");
let pane = workspace.tabs[0].root_pane;
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1601,9 +1631,15 @@ mod tests {
assert_eq!(response["result"]["type"], "pane_info");
assert_eq!(response["result"]["pane"]["label"], "reviewer");
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(pane)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
assert_eq!(
- app.state.workspaces[0]
- .pane_state(pane)
+ app.state
+ .terminals
+ .get(&terminal_id)
.unwrap()
.manual_label
.as_deref(),
@@ -1621,8 +1657,10 @@ mod tests {
assert_eq!(response["result"]["type"], "pane_info");
assert!(response["result"]["pane"].get("label").is_none());
- assert!(app.state.workspaces[0]
- .pane_state(pane)
+ assert!(app
+ .state
+ .terminals
+ .get(&terminal_id)
.unwrap()
.manual_label
.is_none());
@@ -1665,11 +1703,21 @@ mod tests {
#[test]
fn terminal_target_resolves_unique_manual_name() {
let mut app = test_app();
- let mut workspace = Workspace::test_new("terminal-target-name");
+ let workspace = Workspace::test_new("terminal-target-name");
let pane = workspace.tabs[0].root_pane;
- workspace.pane_state_mut(pane).unwrap().manual_label = Some("reviewer".into());
let terminal_id = workspace.terminal_id(pane).unwrap().to_string();
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ let attached_terminal_id = app.state.workspaces[0]
+ .pane_state(pane)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&attached_terminal_id)
+ .unwrap()
+ .manual_label = Some("reviewer".into());
app.state.active = Some(0);
app.state.selected = 0;
@@ -1702,9 +1750,28 @@ mod tests {
let mut workspace = Workspace::test_new("terminal-target-ambiguous");
let first = workspace.tabs[0].root_pane;
let second = workspace.test_split(ratatui::layout::Direction::Horizontal);
- workspace.pane_state_mut(first).unwrap().manual_label = Some("worker".into());
- workspace.pane_state_mut(second).unwrap().manual_label = Some("worker".into());
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ let first_terminal_id = app.state.workspaces[0]
+ .pane_state(first)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .manual_label = Some("worker".into());
+ let second_terminal_id = app.state.workspaces[0]
+ .pane_state(second)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&second_terminal_id)
+ .unwrap()
+ .manual_label = Some("worker".into());
app.state.active = Some(0);
app.state.selected = 0;
@@ -1741,6 +1808,18 @@ mod tests {
workspace.test_split(ratatui::layout::Direction::Horizontal);
workspace.switch_tab(0);
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
+ let split_cwd = std::env::temp_dir();
+ let target_terminal_id = app.state.workspaces[0]
+ .pane_state(target_pane)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ app.state
+ .terminals
+ .get_mut(&target_terminal_id)
+ .unwrap()
+ .cwd = split_cwd.clone();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1761,6 +1840,10 @@ mod tests {
assert_eq!(response["result"]["type"], "pane_info");
assert_eq!(response["result"]["pane"]["tab_id"], target_tab_id);
+ assert_eq!(
+ response["result"]["pane"]["cwd"],
+ split_cwd.display().to_string()
+ );
assert_eq!(response["result"]["pane"]["focused"], false);
assert_eq!(app.state.active, Some(0));
assert_eq!(app.state.workspaces[0].active_tab, 0);
@@ -1782,13 +1865,9 @@ mod tests {
3
);
- let runtimes: Vec<_> = app.state.workspaces[0]
- .tabs
- .iter_mut()
- .flat_map(|tab| tab.runtimes.drain())
- .collect();
- for (pane_id, runtime) in runtimes {
- runtime.shutdown(pane_id);
+ let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
}
match original_shell {
Some(value) => std::env::set_var("SHELL", value),
@@ -1807,6 +1886,7 @@ mod tests {
let background_tab = workspace.test_add_tab(Some("worker"));
workspace.switch_tab(0);
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1832,13 +1912,9 @@ mod tests {
assert_eq!(app.state.active, Some(0));
assert_eq!(app.state.workspaces[0].active_tab, background_tab);
- let runtimes: Vec<_> = app.state.workspaces[0]
- .tabs
- .iter_mut()
- .flat_map(|tab| tab.runtimes.drain())
- .collect();
- for (pane_id, runtime) in runtimes {
- runtime.shutdown(pane_id);
+ let runtimes: Vec<_> = app.state.terminal_runtimes.drain().collect();
+ for (_terminal_id, runtime) in runtimes {
+ runtime.shutdown();
}
match original_shell {
Some(value) => std::env::set_var("SHELL", value),
@@ -1853,6 +1929,7 @@ mod tests {
let second_tab = workspace.test_add_tab(Some("logs"));
workspace.switch_tab(second_tab);
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1878,6 +1955,7 @@ mod tests {
let mut app = test_app();
let workspace = Workspace::test_new("api-pane-close-last");
app.state.workspaces = vec![workspace];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
@@ -1968,17 +2046,23 @@ mod tests {
let pane_id = ws.tabs[0].root_pane;
app.state.workspaces = vec![ws];
+ app.state.ensure_test_terminals();
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
+ let terminal_id = app.state.workspaces[0]
+ .pane_state(pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
app.handle_internal_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Pi),
state: AgentState::Working,
});
assert_eq!(
- app.state.workspaces[0].pane_state(pane_id).unwrap().state,
+ app.state.terminals.get(&terminal_id).unwrap().state,
AgentState::Working
);
@@ -2016,7 +2100,7 @@ mod tests {
app.drain_internal_events();
assert_eq!(
- app.state.workspaces[0].pane_state(pane_id).unwrap().state,
+ app.state.terminals.get(&terminal_id).unwrap().state,
AgentState::Idle,
"Working→Idle should still apply after temporary queue pressure"
);
@@ -2116,7 +2200,7 @@ mod tests {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24);
+ let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
@@ -2138,7 +2222,7 @@ mod tests {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24);
+ let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
@@ -2157,7 +2241,7 @@ mod tests {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
let focused = workspace.focused_pane_id().unwrap();
- let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24);
+ let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
@@ -2178,7 +2262,7 @@ mod tests {
let focused = workspace.focused_pane_id().unwrap();
let text = "中日한🙂";
let (runtime, mut rx) =
- PaneRuntime::test_with_channel_capacity(80, 24, text.chars().count());
+ TerminalRuntime::test_with_channel_capacity(80, 24, text.chars().count());
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
@@ -2203,7 +2287,7 @@ mod tests {
let focused = workspace.focused_pane_id().unwrap();
let text = "你好,今天我们测试一段比较长的语音输入。こんにちは。안녕하세요.🙂".repeat(64);
let char_count = text.chars().count();
- let (runtime, mut rx) = PaneRuntime::test_with_channel_capacity(80, 24, char_count);
+ let (runtime, mut rx) = TerminalRuntime::test_with_channel_capacity(80, 24, char_count);
workspace.tabs[0].runtimes.insert(focused, runtime);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 4d2f6c47..98ae4e0e 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -198,12 +198,12 @@ impl App {
.state
.active
.and_then(|idx| self.state.workspaces.get(idx))
- .is_some_and(Workspace::has_working_pane),
+ .is_some_and(|ws| ws.has_working_pane(&self.state.terminals)),
crate::app::state::AgentPanelScope::AllWorkspaces => self
.state
.workspaces
.iter()
- .any(Workspace::has_working_pane),
+ .any(|ws| ws.has_working_pane(&self.state.terminals)),
}
}
diff --git a/src/app/session.rs b/src/app/session.rs
index c075fd4c..85bdd1bb 100644
--- a/src/app/session.rs
+++ b/src/app/session.rs
@@ -27,6 +27,8 @@ impl App {
} else {
let snap = crate::persist::capture(
&self.state.workspaces,
+ &self.state.terminals,
+ &self.state.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 45473435..daed47f0 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -818,6 +818,10 @@ pub enum SidebarWidthSource {
/// All application state — pure data, no channels or async runtime.
/// Testable without PTYs or a tokio runtime.
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>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
pub selected: usize,
@@ -953,6 +957,57 @@ 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,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::terminal::TerminalRuntime> {
+ #[cfg(test)]
+ if let Some(runtime) = self.workspaces.get(ws_idx)?.test_runtimes.get(&pane_id) {
+ return Some(runtime);
+ }
+ #[cfg(test)]
+ if let Some(runtime) = self
+ .workspaces
+ .get(ws_idx)?
+ .tabs
+ .iter()
+ .find_map(|tab| tab.runtimes.get(&pane_id))
+ {
+ return Some(runtime);
+ }
+ let terminal_id = self.workspaces.get(ws_idx)?.terminal_id(pane_id)?;
+ self.terminal_runtimes.get(terminal_id)
+ }
+
+ #[cfg(test)]
+ pub(crate) fn runtime_for_pane(
+ &self,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::terminal::TerminalRuntime> {
+ self.workspaces.iter().find_map(|ws| {
+ #[cfg(test)]
+ if let Some(runtime) = ws.test_runtimes.get(&pane_id) {
+ return Some(runtime);
+ }
+ #[cfg(test)]
+ if let Some(runtime) = ws.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) {
+ return Some(runtime);
+ }
+ let terminal_id = ws.terminal_id(pane_id)?;
+ self.terminal_runtimes.get(terminal_id)
+ })
+ }
+
+ pub(crate) fn focused_runtime_in_workspace(
+ &self,
+ ws_idx: usize,
+ ) -> Option<&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)
+ }
+
pub fn is_active_pane(
&self,
ws_idx: usize,
@@ -1003,6 +1058,8 @@ impl AppState {
/// Create an AppState for testing — no channels, no PTYs.
pub fn test_new() -> Self {
Self {
+ terminals: std::collections::HashMap::new(),
+ terminal_runtimes: std::collections::HashMap::new(),
workspaces: Vec::new(),
active: None,
selected: 0,
@@ -1155,6 +1212,40 @@ impl AppState {
session_dirty: false,
}
}
+
+ /// Populate missing `TerminalState` entries for every pane so tests that
+ /// read or write terminal metadata don't need to manually create them.
+ pub fn ensure_test_terminals(&mut self) {
+ use crate::terminal::TerminalState;
+ for ws in &self.workspaces {
+ for tab in &ws.tabs {
+ for pane in tab.panes.values() {
+ if !self.terminals.contains_key(&pane.attached_terminal_id) {
+ let cwd = ws.identity_cwd.clone();
+ self.terminals.insert(
+ pane.attached_terminal_id.clone(),
+ TerminalState::new(pane.attached_terminal_id.clone(), cwd),
+ );
+ }
+ }
+ }
+ }
+ }
+
+ pub fn insert_test_runtime(
+ &mut self,
+ pane_id: crate::layout::PaneId,
+ runtime: crate::terminal::TerminalRuntime,
+ ) {
+ let Some(terminal_id) = self
+ .workspaces
+ .iter()
+ .find_map(|ws| ws.terminal_id(pane_id).cloned())
+ else {
+ return;
+ };
+ self.terminal_runtimes.insert(terminal_id, runtime);
+ }
}
#[cfg(test)]
diff --git a/src/app/terminal_targets.rs b/src/app/terminal_targets.rs
index cf90717d..94cf89d0 100644
--- a/src/app/terminal_targets.rs
+++ b/src/app/terminal_targets.rs
@@ -61,9 +61,11 @@ impl App {
.terminal_targets()
.into_iter()
.filter(|candidate| {
- self.state.workspaces[candidate.ws_idx]
- .pane_state(candidate.pane_id)
- .and_then(|pane| pane.manual_label.as_deref())
+ self.state
+ .terminals
+ .values()
+ .find(|terminal| terminal.id.to_string() == candidate.terminal_id)
+ .and_then(|terminal| terminal.manual_label.as_deref())
.is_some_and(|label| label == target)
})
.collect();
@@ -151,15 +153,20 @@ impl App {
let ws = self.state.workspaces.get(ws_idx)?;
let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
let pane = ws.pane_state(pane_id)?;
+ let terminal = self.state.terminals.get(&pane.attached_terminal_id)?;
Some(TerminalTargetCandidate {
- terminal_id: ws.terminal_id(pane_id)?.to_string(),
+ terminal_id: terminal.id.to_string(),
pane_id: self.public_pane_id(ws_idx, pane_id)?,
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)
+ .cwd_for_pane(
+ pane_id,
+ &self.state.terminals,
+ &self.state.terminal_runtimes,
+ )
.map(|cwd| cwd.display().to_string()),
- agent_status: pane_agent_status(pane.state, pane.seen),
+ agent_status: pane_agent_status(terminal.state, pane.seen),
})
}
}
diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs
index df47f6a3..802725f0 100644
--- a/src/app/theme_sync.rs
+++ b/src/app/theme_sync.rs
@@ -37,12 +37,8 @@ impl App {
return;
}
- for workspace in &self.state.workspaces {
- for tab in &workspace.tabs {
- for runtime in tab.runtimes.values() {
- runtime.apply_host_terminal_theme(self.state.host_terminal_theme);
- }
- }
+ for runtime in self.state.terminal_runtimes.values() {
+ runtime.apply_host_terminal_theme(self.state.host_terminal_theme);
}
self.render_dirty.store(true, Ordering::Release);