aboutsummaryrefslogtreecommitdiff
path: root/src
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
parente4de58b37f58d59d146158239ed218a8accd5c3c (diff)
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src')
-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
-rw-r--r--src/pane.rs20
-rw-r--r--src/pane/state.rs600
-rw-r--r--src/persist/restore.rs149
-rw-r--r--src/persist/snapshot.rs70
-rw-r--r--src/server/headless.rs114
-rw-r--r--src/server/render_stream.rs8
-rw-r--r--src/terminal/mod.rs10
-rw-r--r--src/terminal/state.rs615
-rw-r--r--src/ui.rs20
-rw-r--r--src/ui/mobile.rs6
-rw-r--r--src/ui/panes.rs45
-rw-r--r--src/ui/scrollbar.rs2
-rw-r--r--src/ui/sidebar.rs38
-rw-r--r--src/workspace.rs139
-rw-r--r--src/workspace/aggregate.rs164
-rw-r--r--src/workspace/tab.rs222
33 files changed, 2054 insertions, 1360 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);
diff --git a/src/pane.rs b/src/pane.rs
index b5886933..592255a5 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -20,14 +20,12 @@ mod osc;
mod state;
mod terminal;
-use self::{
- state::stabilize_agent_state,
- terminal::{GhosttyPaneTerminal, PaneTerminal},
-};
+use self::terminal::{GhosttyPaneTerminal, PaneTerminal};
pub use self::{
- state::{EffectiveStateChange, PaneState},
+ state::PaneState,
terminal::{InputState, ScrollMetrics, TerminalCursorState},
};
+use crate::terminal::stabilize_agent_state;
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
const PANE_TERM: &str = "xterm-256color";
@@ -160,6 +158,7 @@ impl AgentDetectionPresence {
/// PTY runtime for a pane. Owns the terminal, I/O channels, and background tasks.
/// Dropping this shuts down all background tasks and closes the PTY.
pub struct PaneRuntime {
+ pane_id: PaneId,
terminal: Arc<PaneTerminal>,
sender: mpsc::Sender<Bytes>,
resize_tx: watch::Sender<(u16, u16, u32, u32)>,
@@ -181,10 +180,11 @@ pub enum WheelRouting {
impl Drop for PaneRuntime {
fn drop(&mut self) {
- // Abort detection task immediately.
+ // Abort detection task immediately and terminate the owned session.
// Reader/writer/resize tasks shut down naturally via channel close
// and PTY EOF when the rest of PaneRuntime is dropped.
self.detect_handle.abort();
+ shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
}
}
@@ -251,9 +251,9 @@ fn shutdown_pane_processes(pane_id: PaneId, child_pid: u32) {
}
impl PaneRuntime {
- pub fn shutdown(self, pane_id: PaneId) {
+ pub fn shutdown(self) {
self.detect_handle.abort();
- shutdown_pane_processes(pane_id, self.child_pid.load(Ordering::Acquire));
+ shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
}
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
@@ -748,6 +748,7 @@ impl PaneRuntime {
}
Ok(Self {
+ pane_id,
terminal,
sender: input_tx,
resize_tx,
@@ -1039,6 +1040,7 @@ impl PaneRuntime {
(
Self {
+ pane_id: PaneId::from_raw(0),
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
@@ -1121,6 +1123,7 @@ mod tests {
.mode_set(crate::ghostty::MODE_FOCUS_EVENT, true)
.unwrap();
let runtime = PaneRuntime {
+ pane_id: PaneId::from_raw(0),
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
@@ -1144,6 +1147,7 @@ mod tests {
let (resize_tx, _resize_rx) = watch::channel((80, 24, 0, 0));
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let runtime = PaneRuntime {
+ pane_id: PaneId::from_raw(0),
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
diff --git a/src/pane/state.rs b/src/pane/state.rs
index 2a2f789e..be7ebfae 100644
--- a/src/pane/state.rs
+++ b/src/pane/state.rs
@@ -1,606 +1,20 @@
-use std::collections::HashMap;
+use crate::terminal::TerminalId;
-use crate::detect::{Agent, AgentState};
-
-const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct HookAuthority {
- pub source: String,
- pub agent_label: String,
- pub state: AgentState,
- pub message: Option<String>,
- pub custom_status: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EffectiveStateChange {
- pub previous_agent_label: Option<String>,
- pub previous_known_agent: Option<Agent>,
- pub previous_state: AgentState,
- pub agent_label: Option<String>,
- pub known_agent: Option<Agent>,
- pub state: AgentState,
-}
-
-/// Observable state for a single pane.
-/// This is the only part of a pane that workspace logic and tests need.
+/// Viewport state for a pane.
+///
+/// Terminal identity, cwd, labels, and agent metadata live in TerminalState.
pub struct PaneState {
- pub detected_agent: Option<Agent>,
- pub fallback_state: AgentState,
- pub hook_authority: Option<HookAuthority>,
- pub manual_label: Option<String>,
- hook_report_sequences: HashMap<String, u64>,
- pub state: AgentState,
+ pub attached_terminal_id: TerminalId,
/// Whether the user has seen this pane since its last state change to Idle.
/// False = "Done" (agent finished while user was in another workspace).
pub seen: bool,
}
impl PaneState {
- pub fn new() -> Self {
+ pub fn new(attached_terminal_id: TerminalId) -> Self {
Self {
- detected_agent: None,
- fallback_state: AgentState::Unknown,
- hook_authority: None,
- manual_label: None,
- hook_report_sequences: HashMap::new(),
- state: AgentState::Unknown,
+ attached_terminal_id,
seen: true,
}
}
-
- pub fn set_detected_state(
- &mut self,
- agent: Option<Agent>,
- fallback_state: AgentState,
- ) -> Option<EffectiveStateChange> {
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- let previous_detected_agent = self.detected_agent;
- self.detected_agent = agent;
- self.fallback_state = fallback_state;
- if previous_detected_agent.is_some()
- && agent != previous_detected_agent
- && self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent
- })
- {
- self.hook_authority = None;
- }
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- #[cfg(test)]
- pub fn set_hook_authority(
- &mut self,
- source: String,
- agent_label: String,
- state: AgentState,
- message: Option<String>,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq)
- }
-
- pub fn set_hook_authority_with_custom_status(
- &mut self,
- source: String,
- agent_label: String,
- state: AgentState,
- message: Option<String>,
- custom_status: Option<String>,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- if !self.accept_hook_report(&source, seq) {
- return None;
- }
-
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- self.hook_authority = Some(HookAuthority {
- source,
- agent_label,
- state,
- message,
- custom_status,
- });
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool {
- let Some(seq) = seq else {
- return !self.hook_report_sequences.contains_key(source);
- };
-
- if self
- .hook_report_sequences
- .get(source)
- .is_some_and(|last_seq| seq <= *last_seq)
- {
- return false;
- }
-
- self.hook_report_sequences.insert(source.to_string(), seq);
- true
- }
-
- pub fn clear_hook_authority(
- &mut self,
- source: Option<&str>,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- let sequence_source = source.map(str::to_string).or_else(|| {
- self.hook_authority
- .as_ref()
- .map(|authority| authority.source.clone())
- });
- if let Some(source) = sequence_source.as_deref() {
- if !self.accept_hook_report(source, seq) {
- return None;
- }
- }
-
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- let should_clear = self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
- if !should_clear {
- return None;
- }
- self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- pub fn release_agent(
- &mut self,
- source: &str,
- agent_label: &str,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- if !self.accept_hook_report(source, seq) {
- return None;
- }
-
- let current_agent_label = self.effective_agent_label()?;
- if current_agent_label != agent_label {
- return None;
- }
-
- if self.hook_authority.as_ref().is_some_and(|authority| {
- authority.agent_label != agent_label || authority.source != source
- }) {
- return None;
- }
-
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- self.detected_agent = None;
- self.fallback_state = AgentState::Unknown;
- self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- pub fn effective_agent_label(&self) -> Option<&str> {
- self.hook_authority
- .as_ref()
- .map(|authority| authority.agent_label.as_str())
- .or_else(|| self.detected_agent.map(crate::detect::agent_label))
- }
-
- pub fn effective_known_agent(&self) -> Option<Agent> {
- if let Some(authority) = &self.hook_authority {
- return crate::detect::parse_agent_label(&authority.agent_label);
- }
- self.detected_agent
- }
-
- pub fn effective_custom_status(&self) -> Option<&str> {
- self.hook_authority
- .as_ref()
- .and_then(|authority| authority.custom_status.as_deref())
- }
-
- pub fn set_manual_label(&mut self, label: String) {
- let label = label.trim().to_string();
- self.manual_label = (!label.is_empty()).then_some(label);
- }
-
- pub fn clear_manual_label(&mut self) {
- self.manual_label = None;
- }
-
- pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> {
- self.manual_label.as_deref().or_else(|| {
- show_agent_labels
- .then(|| self.effective_agent_label())
- .flatten()
- })
- }
-
- fn recompute_effective_state(
- &mut self,
- previous_agent_label: Option<String>,
- previous_known_agent: Option<Agent>,
- previous_state: AgentState,
- ) -> Option<EffectiveStateChange> {
- let state = self
- .hook_authority
- .as_ref()
- .map(|authority| authority.state)
- .unwrap_or(self.fallback_state);
- let agent_label = self.effective_agent_label().map(str::to_string);
- let known_agent = self.effective_known_agent();
-
- if previous_agent_label == agent_label && previous_state == state {
- return None;
- }
-
- self.state = state;
- Some(EffectiveStateChange {
- previous_agent_label,
- previous_known_agent,
- previous_state,
- agent_label,
- known_agent,
- state,
- })
- }
-}
-
-pub(crate) fn stabilize_agent_state(
- agent: Option<Agent>,
- previous: AgentState,
- raw: AgentState,
- now: std::time::Instant,
- last_claude_working_at: &mut Option<std::time::Instant>,
-) -> AgentState {
- if agent != Some(Agent::Claude) {
- return raw;
- }
-
- match raw {
- AgentState::Working => {
- *last_claude_working_at = Some(now);
- AgentState::Working
- }
- AgentState::Blocked => AgentState::Blocked,
- AgentState::Idle if previous == AgentState::Working => {
- if last_claude_working_at
- .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD)
- {
- AgentState::Working
- } else {
- AgentState::Idle
- }
- }
- _ => raw,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn claude_working_is_sticky_for_short_gap() {
- let now = std::time::Instant::now();
- let mut last_working = None;
-
- let working = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Idle,
- AgentState::Working,
- now,
- &mut last_working,
- );
- assert_eq!(working, AgentState::Working);
-
- let still_working = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Working,
- AgentState::Idle,
- now + std::time::Duration::from_millis(400),
- &mut last_working,
- );
- assert_eq!(still_working, AgentState::Working);
- }
-
- #[test]
- fn claude_transitions_to_idle_after_hold_expires() {
- let now = std::time::Instant::now();
- let mut last_working = Some(now);
-
- let state = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Working,
- AgentState::Idle,
- now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1),
- &mut last_working,
- );
- assert_eq!(state, AgentState::Idle);
- }
-
- #[test]
- fn non_claude_states_are_unchanged() {
- let now = std::time::Instant::now();
- let mut last_working = None;
-
- let state = stabilize_agent_state(
- Some(Agent::Codex),
- AgentState::Working,
- AgentState::Idle,
- now,
- &mut last_working,
- );
- assert_eq!(state, AgentState::Idle);
- }
-
- #[test]
- fn hook_authority_overrides_fallback_for_same_agent() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- );
-
- assert_eq!(pane.detected_agent, Some(Agent::Pi));
- assert_eq!(pane.fallback_state, AgentState::Idle);
- assert_eq!(pane.effective_agent_label(), Some("pi"));
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn hook_authority_can_override_with_unknown_agent_label() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:custom".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- None,
- );
-
- assert_eq!(pane.detected_agent, Some(Agent::Pi));
- assert_eq!(pane.effective_agent_label(), Some("hermes"));
- assert_eq!(pane.effective_known_agent(), None);
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn border_label_prefers_manual_label_over_agent_label() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Claude), AgentState::Idle);
-
- assert_eq!(pane.border_label(false), None);
- assert_eq!(pane.border_label(true), Some("claude"));
-
- pane.set_manual_label(" reviewer ".into());
- assert_eq!(pane.border_label(false), Some("reviewer"));
- assert_eq!(pane.border_label(true), Some("reviewer"));
-
- pane.set_manual_label(" ".into());
- assert_eq!(pane.border_label(true), Some("claude"));
-
- pane.set_manual_label("reviewer".into());
- pane.clear_manual_label();
- assert_eq!(pane.border_label(true), Some("claude"));
- }
-
- #[test]
- fn hook_authority_survives_unrelated_detected_agent_clear() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:custom".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- None,
- );
-
- pane.set_detected_state(None, AgentState::Unknown);
-
- assert!(pane.hook_authority.is_some());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.effective_agent_label(), Some("hermes"));
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn detected_agent_clear_clears_matching_hook_authority() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:opencode".into(),
- "opencode".into(),
- AgentState::Idle,
- None,
- None,
- );
-
- pane.set_detected_state(None, AgentState::Unknown);
-
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.fallback_state, AgentState::Unknown);
- assert_eq!(pane.effective_agent_label(), None);
- assert_eq!(pane.state, AgentState::Unknown);
- }
-
- #[test]
- fn detected_agent_change_clears_previous_matching_hook_authority() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Codex), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:codex".into(),
- "codex".into(),
- AgentState::Idle,
- None,
- None,
- );
-
- pane.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
-
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, Some(Agent::OpenCode));
- assert_eq!(pane.effective_agent_label(), Some("opencode"));
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn release_agent_clears_identity_immediately() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- );
-
- pane.release_agent("herdr:pi", "pi", None);
-
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.fallback_state, AgentState::Unknown);
- assert_eq!(pane.state, AgentState::Unknown);
- }
-
- #[test]
- fn custom_status_is_hook_authority_metadata() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Claude), AgentState::Idle);
- pane.set_hook_authority_with_custom_status(
- "herdr:claude".into(),
- "claude".into(),
- AgentState::Idle,
- None,
- Some("scheduled".into()),
- None,
- );
-
- assert_eq!(pane.state, AgentState::Idle);
- assert_eq!(pane.effective_custom_status(), Some("scheduled"));
-
- pane.clear_hook_authority(Some("herdr:claude"), None);
-
- assert_eq!(pane.effective_custom_status(), None);
- }
-
- #[test]
- fn stale_hook_report_sequence_is_ignored_for_same_source() {
- let mut pane = PaneState::new();
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change = pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Idle,
- None,
- Some(19),
- );
-
- assert!(change.is_none());
- assert_eq!(pane.state, AgentState::Working);
- assert_eq!(
- pane.hook_authority.as_ref().unwrap().state,
- AgentState::Working
- );
- }
-
- #[test]
- fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
- let mut pane = PaneState::new();
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change =
- pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Idle, None, None);
-
- assert!(change.is_none());
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn stale_release_sequence_is_ignored_for_same_source() {
- let mut pane = PaneState::new();
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change = pane.release_agent("herdr:pi", "pi", Some(19));
-
- assert!(change.is_none());
- assert_eq!(pane.state, AgentState::Working);
- assert!(pane.hook_authority.is_some());
- }
-
- #[test]
- fn stale_clear_all_sequence_is_checked_against_current_authority_source() {
- let mut pane = PaneState::new();
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change = pane.clear_hook_authority(None, Some(19));
-
- assert!(change.is_none());
- assert_eq!(pane.state, AgentState::Working);
- assert!(pane.hook_authority.is_some());
- }
-
- #[test]
- fn same_sequence_from_different_sources_is_independent() {
- let mut pane = PaneState::new();
- pane.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- pane.set_hook_authority(
- "custom:pi".into(),
- "pi".into(),
- AgentState::Idle,
- None,
- Some(19),
- );
-
- assert_eq!(pane.state, AgentState::Idle);
- assert_eq!(pane.hook_authority.as_ref().unwrap().source, "custom:pi");
- }
}
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 1e10df6d..e53a61d9 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -9,7 +9,8 @@ use tracing::{error, warn};
use crate::events::AppEvent;
use crate::layout::{Node, PaneId, TileLayout};
-use crate::pane::{PaneRuntime, PaneState};
+use crate::pane::PaneState;
+use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
use crate::workspace::Workspace;
use super::{DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot};
@@ -23,22 +24,32 @@ pub fn restore(
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
-) -> Vec<Workspace> {
- snapshot
- .workspaces
- .iter()
- .filter_map(|ws_snap| {
- restore_workspace(
- ws_snap,
- rows,
- cols,
- scrollback_limit_bytes,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )
- })
- .collect()
+) -> (
+ Vec<Workspace>,
+ HashMap<TerminalId, TerminalState>,
+ HashMap<TerminalId, TerminalRuntime>,
+) {
+ let mut workspaces = Vec::new();
+ let mut terminals = HashMap::new();
+ let mut terminal_runtimes = HashMap::new();
+ for ws_snap in &snapshot.workspaces {
+ if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace(
+ ws_snap,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ ) {
+ for terminal in restored_terminals {
+ terminals.insert(terminal.id.clone(), terminal);
+ }
+ terminal_runtimes.extend(restored_runtimes);
+ workspaces.push(workspace);
+ }
+ }
+ (workspaces, terminals, terminal_runtimes)
}
fn restore_workspace(
@@ -49,13 +60,19 @@ fn restore_workspace(
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
-) -> Option<Workspace> {
+) -> Option<(
+ Workspace,
+ Vec<TerminalState>,
+ HashMap<TerminalId, TerminalRuntime>,
+)> {
let mut tabs = Vec::new();
+ let mut terminals = Vec::new();
+ let mut terminal_runtimes = HashMap::new();
let mut public_pane_numbers = HashMap::new();
let mut next_public_pane_number = 1;
for (idx, tab_snap) in snap.tabs.iter().enumerate() {
- let tab = restore_tab(
+ let (tab, restored_terminals, restored_runtimes) = restore_tab(
tab_snap,
idx + 1,
rows,
@@ -69,6 +86,8 @@ fn restore_workspace(
public_pane_numbers.insert(pane_id, next_public_pane_number);
next_public_pane_number += 1;
}
+ terminals.extend(restored_terminals);
+ terminal_runtimes.extend(restored_runtimes);
tabs.push(tab);
}
@@ -76,20 +95,26 @@ fn restore_workspace(
return None;
}
- Some(Workspace {
- id: snap
- .id
- .clone()
- .unwrap_or_else(crate::workspace::generate_workspace_id),
- custom_name: snap.custom_name.clone(),
- identity_cwd: snap.identity_cwd.clone(),
- cached_git_branch: None,
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number,
- active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
- tabs,
- })
+ Some((
+ Workspace {
+ id: snap
+ .id
+ .clone()
+ .unwrap_or_else(crate::workspace::generate_workspace_id),
+ custom_name: snap.custom_name.clone(),
+ identity_cwd: snap.identity_cwd.clone(),
+ cached_git_branch: None,
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number,
+ active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
+ tabs,
+ #[cfg(test)]
+ test_runtimes: HashMap::new(),
+ },
+ terminals,
+ terminal_runtimes,
+ ))
}
fn restore_tab(
@@ -101,7 +126,11 @@ fn restore_tab(
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
-) -> Option<crate::workspace::Tab> {
+) -> Option<(
+ crate::workspace::Tab,
+ Vec<TerminalState>,
+ HashMap<TerminalId, TerminalRuntime>,
+)> {
let (node, id_map) = restore_node_remapped(&snap.layout);
let reverse_id_map: HashMap<PaneId, u32> = id_map
.iter()
@@ -110,9 +139,8 @@ fn restore_tab(
let pane_ids = collect_pane_ids(&node);
let mut panes = HashMap::new();
- let mut pane_cwds = HashMap::new();
- let mut terminal_ids = HashMap::new();
- let mut runtimes = HashMap::new();
+ let mut terminals = Vec::new();
+ let mut terminal_runtimes = HashMap::new();
for id in &pane_ids {
let saved_cwd = reverse_id_map
.get(id)
@@ -142,7 +170,7 @@ fn restore_tab(
.and_then(|old_id| snap.panes.get(old_id))
.and_then(|p| p.label.clone());
- match PaneRuntime::spawn(
+ match TerminalRuntime::spawn(
*id,
rows,
cols,
@@ -154,12 +182,14 @@ fn restore_tab(
render_dirty.clone(),
) {
Ok(runtime) => {
- let mut pane_state = PaneState::new();
- pane_state.manual_label = saved_label;
- panes.insert(*id, pane_state);
- pane_cwds.insert(*id, cwd.clone());
- terminal_ids.insert(*id, crate::terminal::TerminalId::alloc());
- runtimes.insert(*id, runtime);
+ let terminal_id = TerminalId::alloc();
+ let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone());
+ if let Some(label) = saved_label {
+ terminal.set_manual_label(label);
+ }
+ panes.insert(*id, PaneState::new(terminal_id.clone()));
+ terminal_runtimes.insert(terminal_id, runtime);
+ terminals.push(terminal);
}
Err(e) => {
error!(
@@ -193,20 +223,23 @@ fn restore_tab(
let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?;
let layout = TileLayout::from_saved(node, focus);
- Some(crate::workspace::Tab {
- custom_name: snap.custom_name.clone(),
- number,
- root_pane,
- layout,
- panes,
- pane_cwds,
- terminal_ids,
- runtimes,
- zoomed: snap.zoomed,
- events,
- render_notify,
- render_dirty,
- })
+ Some((
+ crate::workspace::Tab {
+ custom_name: snap.custom_name.clone(),
+ number,
+ root_pane,
+ layout,
+ panes,
+ #[cfg(test)]
+ runtimes: HashMap::new(),
+ zoomed: snap.zoomed,
+ events,
+ render_notify,
+ render_dirty,
+ },
+ terminals,
+ terminal_runtimes,
+ ))
}
pub(super) fn prune_restored_node(node: Node, surviving: &HashSet<PaneId>) -> Option<Node> {
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
index 76ed96f5..245fbb76 100644
--- a/src/persist/snapshot.rs
+++ b/src/persist/snapshot.rs
@@ -195,6 +195,14 @@ fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option<u32> {
/// Capture the current app state into a serializable snapshot.
pub fn capture(
workspaces: &[Workspace],
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+ terminal_runtimes: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalRuntime,
+ >,
active: Option<usize>,
selected: usize,
agent_panel_scope: crate::app::state::AgentPanelScope,
@@ -203,7 +211,10 @@ pub fn capture(
) -> SessionSnapshot {
SessionSnapshot {
version: SNAPSHOT_VERSION,
- workspaces: workspaces.iter().map(capture_workspace).collect(),
+ workspaces: workspaces
+ .iter()
+ .map(|workspace| capture_workspace(workspace, terminals, terminal_runtimes))
+ .collect(),
active,
selected,
agent_panel_scope,
@@ -212,25 +223,53 @@ pub fn capture(
}
}
-fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
+fn capture_workspace(
+ ws: &Workspace,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+ terminal_runtimes: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalRuntime,
+ >,
+) -> WorkspaceSnapshot {
WorkspaceSnapshot {
id: Some(ws.id.clone()),
custom_name: ws.custom_name.clone(),
identity_cwd: ws
.resolved_identity_cwd()
.unwrap_or_else(|| ws.identity_cwd.clone()),
- tabs: ws.tabs.iter().map(capture_tab).collect(),
+ tabs: ws
+ .tabs
+ .iter()
+ .map(|tab| capture_tab(tab, terminals, terminal_runtimes))
+ .collect(),
active_tab: ws.active_tab,
}
}
-fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
+fn capture_tab(
+ tab: &crate::workspace::Tab,
+ terminals: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalState,
+ >,
+ terminal_runtimes: &std::collections::HashMap<
+ crate::terminal::TerminalId,
+ crate::terminal::TerminalRuntime,
+ >,
+) -> TabSnapshot {
let mut panes = HashMap::new();
for id in tab.panes.keys() {
let cwd = tab
- .cwd_for_pane(*id)
+ .cwd_for_pane(*id, terminals, terminal_runtimes)
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let label = tab.panes.get(id).and_then(|pane| pane.manual_label.clone());
+ let label = tab
+ .panes
+ .get(id)
+ .and_then(|pane| terminals.get(&pane.attached_terminal_id))
+ .and_then(|terminal| terminal.manual_label.clone());
panes.insert(id.raw(), PaneSnapshot { cwd, label });
}
TabSnapshot {
@@ -310,6 +349,7 @@ mod tests {
fn state_with_workspaces(names: &[&str]) -> AppState {
let mut state = AppState::test_new();
state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
+ state.ensure_test_terminals();
if !state.workspaces.is_empty() {
state.active = Some(0);
state.selected = 0;
@@ -321,6 +361,8 @@ mod tests {
fn capture_from_state(state: &AppState) -> SessionSnapshot {
capture(
&state.workspaces,
+ &state.terminals,
+ &state.terminal_runtimes,
state.active,
state.selected,
state.agent_panel_scope,
@@ -657,13 +699,17 @@ mod tests {
fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
let mut state = state_with_workspaces(&["one"]);
let root = state.workspaces[0].tabs[0].root_pane;
- state.workspaces[0].tabs[0]
- .pane_cwds
- .insert(root, PathBuf::from("/tmp/pion"));
+ state.workspaces[0].identity_cwd = PathBuf::from("/tmp/pion");
let second = state.workspaces[0].test_split(Direction::Horizontal);
- state.workspaces[0].tabs[0]
- .pane_cwds
- .insert(second, PathBuf::from("/tmp/herdr"));
+ state.ensure_test_terminals();
+ let root_terminal_id = state.workspaces[0].tabs[0].panes[&root]
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&root_terminal_id).unwrap().cwd = PathBuf::from("/tmp/pion");
+ let second_terminal_id = state.workspaces[0].tabs[0].panes[&second]
+ .attached_terminal_id
+ .clone();
+ state.terminals.get_mut(&second_terminal_id).unwrap().cwd = PathBuf::from("/tmp/herdr");
let snapshot = capture_from_state(&state);
let workspace = &snapshot.workspaces[0];
diff --git a/src/server/headless.rs b/src/server/headless.rs
index a1e7669b..fbfef251 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -126,7 +126,10 @@ fn toast_message_from_state_change(
.find_map(|(ws_idx, ws)| {
ws.tabs.iter().find_map(|tab| {
let pane = tab.panes.get(&pane_id)?;
- let agent_label = pane.effective_agent_label()?;
+ let agent_label = state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .and_then(|terminal| terminal.effective_agent_label())?;
Some(format!(
"{} {}: {}",
agent_label,
@@ -762,9 +765,14 @@ impl HeadlessServer {
.workspaces
.iter()
.find_map(|ws| {
- ws.tabs
- .iter()
- .find_map(|tab| tab.panes.get(&pane_id).map(|pane| pane.state))
+ ws.tabs.iter().find_map(|tab| {
+ let pane = tab.panes.get(&pane_id)?;
+ self.app
+ .state
+ .terminals
+ .get(&pane.attached_terminal_id)
+ .map(|terminal| terminal.state)
+ })
})
.unwrap_or(crate::detect::AgentState::Unknown)
}
@@ -1332,7 +1340,8 @@ impl HeadlessServer {
// bypasses drain_internal_events_with_forwarding. Headless mode disables
// local sound playback, so sound notifications need to be forwarded here.
let toast_before = self.app.state.toast.clone();
- let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> =
+ let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> = {
+ let terminals = &self.app.state.terminals;
self.app
.state
.workspaces
@@ -1340,12 +1349,15 @@ impl HeadlessServer {
.enumerate()
.flat_map(|(ws_idx, ws)| {
ws.tabs.iter().flat_map(move |tab| {
- tab.panes
- .iter()
- .map(move |(&pane_id, pane)| (ws_idx, pane_id, pane.state))
+ tab.panes.iter().filter_map(move |(&pane_id, pane)| {
+ terminals
+ .get(&pane.attached_terminal_id)
+ .map(|terminal| (ws_idx, pane_id, terminal.state))
+ })
})
})
- .collect();
+ .collect()
+ };
self.sync_foreground_client_state();
let response = self.app.handle_api_request(msg.request);
@@ -1391,7 +1403,16 @@ impl HeadlessServer {
continue;
};
- let new_state = pane_after.state;
+ let Some(terminal_after) = self
+ .app
+ .state
+ .terminals
+ .get(&pane_after.attached_terminal_id)
+ else {
+ continue;
+ };
+
+ let new_state = terminal_after.state;
if new_state == *prev_state {
continue;
}
@@ -1400,7 +1421,7 @@ impl HeadlessServer {
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
- let agent = pane_after.effective_known_agent();
+ let agent = terminal_after.effective_known_agent();
debug!(
ws_idx,
@@ -1419,7 +1440,13 @@ impl HeadlessServer {
*prev_state,
new_state,
) {
- if let Some(agent_label) = pane_after.effective_agent_label() {
+ if let Some(agent_label) = self
+ .app
+ .state
+ .terminals
+ .get(&pane_after.attached_terminal_id)
+ .and_then(|terminal| terminal.effective_agent_label())
+ {
let event_text = match kind {
crate::app::state::ToastKind::NeedsAttention => "needs attention",
crate::app::state::ToastKind::Finished => "finished",
@@ -2129,9 +2156,9 @@ mod tests {
let mut state = AppState::test_new();
let mut ws = crate::workspace::Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
pane_id,
- crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
);
state.workspaces = vec![ws];
@@ -2165,9 +2192,9 @@ mod tests {
let mut state = AppState::test_new();
let mut ws = crate::workspace::Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
pane_id,
- crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left\x1b[?25l"),
);
state.workspaces = vec![ws];
@@ -2201,9 +2228,9 @@ mod tests {
let mut state = AppState::test_new();
let mut ws = crate::workspace::Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
pane_id,
- crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
);
state.workspaces = vec![ws];
@@ -2227,8 +2254,9 @@ mod tests {
for line in 0..80 {
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
}
- let runtime = crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
- ws.tabs[0].runtimes.insert(pane_id, runtime);
+ let runtime =
+ crate::terminal::TerminalRuntime::test_with_scrollback_bytes(20, 5, 4096, &bytes);
+ ws.insert_test_runtime(pane_id, runtime);
state.workspaces = vec![ws];
state.active = Some(0);
@@ -2237,8 +2265,8 @@ mod tests {
let area = Rect::new(0, 0, 80, 24);
let _ = crate::server::render_stream::render_virtual(&mut state, area, true);
- let runtime = state.workspaces[0]
- .runtime(pane_id)
+ let runtime = state
+ .runtime_for_pane(pane_id)
.expect("pane runtime after initial render");
runtime.scroll_up(6);
assert!(crate::ui::pane_is_scrolled_back(runtime));
@@ -2498,11 +2526,11 @@ mod tests {
let background_pane = workspace.tabs[background_tab].root_pane;
workspace.tabs[0].runtimes.insert(
active_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(80, 24, b""),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
);
workspace.tabs[background_tab].runtimes.insert(
background_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(80, 24, b""),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""),
);
server.app.state.workspaces = vec![workspace];
server.app.state.active = Some(0);
@@ -2528,11 +2556,20 @@ mod tests {
let terminal_area = server.app.state.view.terminal_area;
let expected = (terminal_area.height, terminal_area.width.saturating_sub(1));
assert_eq!(
- server.app.state.workspaces[0].tabs[0].runtimes[&active_pane].current_size(),
+ server
+ .app
+ .state
+ .runtime_for_pane(active_pane)
+ .unwrap()
+ .current_size(),
expected
);
assert_eq!(
- server.app.state.workspaces[0].tabs[background_tab].runtimes[&background_pane]
+ server
+ .app
+ .state
+ .runtime_for_pane(background_pane)
+ .unwrap()
.current_size(),
expected
);
@@ -3029,12 +3066,22 @@ mod tests {
#[test]
fn stale_api_agent_report_does_not_forward_done_sound() {
let mut server = test_headless_server();
- let mut background = crate::workspace::Workspace::test_new("background");
+ let background = crate::workspace::Workspace::test_new("background");
let pane_id = background.tabs[0].root_pane;
let public_pane_id = format!("{}-1", background.id);
- background.tabs[0]
- .panes
- .get_mut(&pane_id)
+ let foreground = crate::workspace::Workspace::test_new("foreground");
+ server.app.state.workspaces = vec![background, foreground];
+ server.app.state.ensure_test_terminals();
+ let terminal_id = server.app.state.workspaces[0]
+ .pane_state(pane_id)
+ .unwrap()
+ .attached_terminal_id
+ .clone();
+ server
+ .app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
.unwrap()
.set_hook_authority(
"herdr:pi".into(),
@@ -3043,8 +3090,6 @@ mod tests {
None,
Some(20),
);
- let foreground = crate::workspace::Workspace::test_new("foreground");
- server.app.state.workspaces = vec![background, foreground];
server.app.state.active = Some(1);
server.app.state.selected = 1;
server.app.state.mode = crate::app::Mode::Terminal;
@@ -3085,10 +3130,7 @@ mod tests {
assert!(changed);
assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok());
assert_eq!(
- server.app.state.workspaces[0]
- .pane_state(pane_id)
- .unwrap()
- .state,
+ server.app.state.terminals.get(&terminal_id).unwrap().state,
crate::detect::AgentState::Working
);
assert!(
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index a921960c..94a43980 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -272,7 +272,10 @@ pub(crate) fn visible_hyperlinks(app_state: &AppState) -> Vec<((u16, u16), Strin
let mut links = Vec::new();
for info in &app_state.view.pane_infos {
- if let Some(runtime) = tab.runtimes.get(&info.id) {
+ if let Some(runtime) = tab
+ .terminal_id(info.id)
+ .and_then(|terminal_id| app_state.terminal_runtimes.get(terminal_id))
+ {
links.extend(runtime.visible_hyperlinks(info.inner_rect));
}
}
@@ -285,13 +288,12 @@ fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> {
}
let ws_idx = app_state.active?;
- let ws = app_state.workspaces.get(ws_idx)?;
let info = app_state
.view
.pane_infos
.iter()
.find(|info| info.is_focused)?;
- let rt = ws.runtime(info.id)?;
+ let rt = app_state.runtime_for_pane_in_workspace(ws_idx, info.id)?;
let cursor = rt.cursor_state(info.inner_rect, true)?;
Some(CursorState {
x: cursor.x,
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 489f7876..5f9bb41b 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -1,3 +1,13 @@
mod id;
+pub mod state;
pub use id::TerminalId;
+pub(crate) use state::stabilize_agent_state;
+pub use state::{EffectiveStateChange, TerminalState};
+
+/// Live runtime for a server-owned terminal.
+///
+/// The implementation still lives in the pane module during this migration, but
+/// all new ownership should address it by `TerminalId` through this terminal
+/// noun.
+pub type TerminalRuntime = crate::pane::PaneRuntime;
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
new file mode 100644
index 00000000..df89b688
--- /dev/null
+++ b/src/terminal/state.rs
@@ -0,0 +1,615 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use crate::detect::{Agent, AgentState};
+use crate::terminal::TerminalId;
+
+const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookAuthority {
+ pub source: String,
+ pub agent_label: String,
+ pub state: AgentState,
+ pub message: Option<String>,
+ pub custom_status: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EffectiveStateChange {
+ pub previous_agent_label: Option<String>,
+ pub previous_known_agent: Option<Agent>,
+ pub previous_state: AgentState,
+ pub agent_label: Option<String>,
+ pub known_agent: Option<Agent>,
+ pub state: AgentState,
+ pub custom_status: Option<String>,
+}
+
+/// Pure state for a server-owned terminal.
+///
+/// During the migration this is still one-to-one with a pane-backed PTY, but
+/// pane/view state no longer owns terminal identity, cwd, labels, or agent
+/// metadata.
+pub struct TerminalState {
+ pub id: TerminalId,
+ pub cwd: PathBuf,
+ pub detected_agent: Option<Agent>,
+ pub fallback_state: AgentState,
+ pub hook_authority: Option<HookAuthority>,
+ pub manual_label: Option<String>,
+ hook_report_sequences: HashMap<String, u64>,
+ pub state: AgentState,
+ pub revision: u64,
+ pub launch_argv: Option<Vec<String>>,
+}
+
+impl TerminalState {
+ pub fn new(id: TerminalId, cwd: PathBuf) -> Self {
+ Self {
+ id,
+ cwd,
+ detected_agent: None,
+ fallback_state: AgentState::Unknown,
+ hook_authority: None,
+ manual_label: None,
+ hook_report_sequences: HashMap::new(),
+ state: AgentState::Unknown,
+ revision: 0,
+ launch_argv: None,
+ }
+ }
+
+ pub fn with_launch_argv(mut self, argv: Vec<String>) -> Self {
+ self.launch_argv = Some(argv);
+ self
+ }
+
+ pub fn set_detected_state(
+ &mut self,
+ agent: Option<Agent>,
+ fallback_state: AgentState,
+ ) -> Option<EffectiveStateChange> {
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let previous_detected_agent = self.detected_agent;
+ self.detected_agent = agent;
+ self.fallback_state = fallback_state;
+ if previous_detected_agent.is_some()
+ && agent != previous_detected_agent
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label) == previous_detected_agent
+ })
+ {
+ self.hook_authority = None;
+ }
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ #[cfg(test)]
+ pub fn set_hook_authority(
+ &mut self,
+ source: String,
+ agent_label: String,
+ state: AgentState,
+ message: Option<String>,
+ seq: Option<u64>,
+ ) -> Option<EffectiveStateChange> {
+ self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq)
+ }
+
+ pub fn set_hook_authority_with_custom_status(
+ &mut self,
+ source: String,
+ agent_label: String,
+ state: AgentState,
+ message: Option<String>,
+ custom_status: Option<String>,
+ seq: Option<u64>,
+ ) -> Option<EffectiveStateChange> {
+ if !self.accept_hook_report(&source, seq) {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ self.hook_authority = Some(HookAuthority {
+ source,
+ agent_label,
+ state,
+ message,
+ custom_status,
+ });
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool {
+ let Some(seq) = seq else {
+ return !self.hook_report_sequences.contains_key(source);
+ };
+
+ if self
+ .hook_report_sequences
+ .get(source)
+ .is_some_and(|last_seq| seq <= *last_seq)
+ {
+ return false;
+ }
+
+ self.hook_report_sequences.insert(source.to_string(), seq);
+ true
+ }
+
+ pub fn clear_hook_authority(
+ &mut self,
+ source: Option<&str>,
+ seq: Option<u64>,
+ ) -> Option<EffectiveStateChange> {
+ let sequence_source = source.map(str::to_string).or_else(|| {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.source.clone())
+ });
+ if let Some(source) = sequence_source.as_deref() {
+ if !self.accept_hook_report(source, seq) {
+ return None;
+ }
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let should_clear = self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
+ if !should_clear {
+ return None;
+ }
+ self.hook_authority = None;
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn release_agent(
+ &mut self,
+ source: &str,
+ agent_label: &str,
+ seq: Option<u64>,
+ ) -> Option<EffectiveStateChange> {
+ if !self.accept_hook_report(source, seq) {
+ return None;
+ }
+
+ let current_agent_label = self.effective_agent_label()?;
+ if current_agent_label != agent_label {
+ return None;
+ }
+
+ if self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.agent_label != agent_label || authority.source != source
+ }) {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ self.detected_agent = None;
+ self.fallback_state = AgentState::Unknown;
+ self.hook_authority = None;
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn effective_agent_label(&self) -> Option<&str> {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.agent_label.as_str())
+ .or_else(|| self.detected_agent.map(crate::detect::agent_label))
+ }
+
+ pub fn effective_known_agent(&self) -> Option<Agent> {
+ if let Some(authority) = &self.hook_authority {
+ return crate::detect::parse_agent_label(&authority.agent_label);
+ }
+ self.detected_agent
+ }
+
+ pub fn effective_custom_status(&self) -> Option<&str> {
+ self.hook_authority
+ .as_ref()
+ .and_then(|authority| authority.custom_status.as_deref())
+ }
+
+ pub fn set_manual_label(&mut self, label: String) {
+ let label = label.trim().to_string();
+ self.manual_label = (!label.is_empty()).then_some(label);
+ }
+
+ pub fn clear_manual_label(&mut self) {
+ self.manual_label = None;
+ }
+
+ pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> {
+ self.manual_label.as_deref().or_else(|| {
+ show_agent_labels
+ .then(|| self.effective_agent_label())
+ .flatten()
+ })
+ }
+
+ fn recompute_effective_state(
+ &mut self,
+ previous_agent_label: Option<String>,
+ previous_known_agent: Option<Agent>,
+ previous_state: AgentState,
+ ) -> Option<EffectiveStateChange> {
+ let state = self
+ .hook_authority
+ .as_ref()
+ .map(|authority| authority.state)
+ .unwrap_or(self.fallback_state);
+ let agent_label = self.effective_agent_label().map(str::to_string);
+ let known_agent = self.effective_known_agent();
+
+ let custom_status = self.effective_custom_status().map(str::to_string);
+
+ if previous_agent_label == agent_label && previous_state == state {
+ return None;
+ }
+
+ self.state = state;
+ Some(EffectiveStateChange {
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ agent_label,
+ known_agent,
+ state,
+ custom_status,
+ })
+ }
+}
+
+pub(crate) fn stabilize_agent_state(
+ agent: Option<Agent>,
+ previous: AgentState,
+ raw: AgentState,
+ now: std::time::Instant,
+ last_claude_working_at: &mut Option<std::time::Instant>,
+) -> AgentState {
+ if agent != Some(Agent::Claude) {
+ return raw;
+ }
+
+ match raw {
+ AgentState::Working => {
+ *last_claude_working_at = Some(now);
+ AgentState::Working
+ }
+ AgentState::Blocked => AgentState::Blocked,
+ AgentState::Idle if previous == AgentState::Working => {
+ if last_claude_working_at
+ .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD)
+ {
+ AgentState::Working
+ } else {
+ AgentState::Idle
+ }
+ }
+ _ => raw,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn test_terminal() -> TerminalState {
+ TerminalState::new(TerminalId::alloc(), "/tmp".into())
+ }
+
+ #[test]
+ fn claude_working_is_sticky_for_short_gap() {
+ let now = std::time::Instant::now();
+ let mut last_working = None;
+
+ let working = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ AgentState::Working,
+ now,
+ &mut last_working,
+ );
+ assert_eq!(working, AgentState::Working);
+
+ let still_working = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentState::Idle,
+ now + std::time::Duration::from_millis(400),
+ &mut last_working,
+ );
+ assert_eq!(still_working, AgentState::Working);
+ }
+
+ #[test]
+ fn claude_transitions_to_idle_after_hold_expires() {
+ let now = std::time::Instant::now();
+ let mut last_working = Some(now);
+
+ let state = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentState::Idle,
+ now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1),
+ &mut last_working,
+ );
+ assert_eq!(state, AgentState::Idle);
+ }
+
+ #[test]
+ fn non_claude_states_are_unchanged() {
+ let now = std::time::Instant::now();
+ let mut last_working = None;
+
+ let state = stabilize_agent_state(
+ Some(Agent::Codex),
+ AgentState::Working,
+ AgentState::Idle,
+ now,
+ &mut last_working,
+ );
+ assert_eq!(state, AgentState::Idle);
+ }
+
+ #[test]
+ fn hook_authority_overrides_fallback_for_same_agent() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
+ assert_eq!(terminal.fallback_state, AgentState::Idle);
+ assert_eq!(terminal.effective_agent_label(), Some("pi"));
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn hook_authority_can_override_with_unknown_agent_label() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
+ assert_eq!(terminal.effective_agent_label(), Some("hermes"));
+ assert_eq!(terminal.effective_known_agent(), None);
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn border_label_prefers_manual_label_over_agent_label() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Claude), AgentState::Idle);
+
+ assert_eq!(terminal.border_label(false), None);
+ assert_eq!(terminal.border_label(true), Some("claude"));
+
+ terminal.set_manual_label(" reviewer ".into());
+ assert_eq!(terminal.border_label(false), Some("reviewer"));
+ assert_eq!(terminal.border_label(true), Some("reviewer"));
+
+ terminal.set_manual_label(" ".into());
+ assert_eq!(terminal.border_label(true), Some("claude"));
+
+ terminal.set_manual_label("reviewer".into());
+ terminal.clear_manual_label();
+ assert_eq!(terminal.border_label(true), Some("claude"));
+ }
+
+ #[test]
+ fn hook_authority_survives_unrelated_detected_agent_clear() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state(None, AgentState::Unknown);
+
+ assert!(terminal.hook_authority.is_some());
+ assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.effective_agent_label(), Some("hermes"));
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn detected_agent_clear_clears_matching_hook_authority() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Idle,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state(None, AgentState::Unknown);
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.fallback_state, AgentState::Unknown);
+ assert_eq!(terminal.effective_agent_label(), None);
+ assert_eq!(terminal.state, AgentState::Unknown);
+ }
+
+ #[test]
+ fn detected_agent_change_clears_previous_matching_hook_authority() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:codex".into(),
+ "codex".into(),
+ AgentState::Idle,
+ None,
+ None,
+ );
+
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, Some(Agent::OpenCode));
+ assert_eq!(terminal.effective_agent_label(), Some("opencode"));
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn release_agent_clears_identity_immediately() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+
+ terminal.release_agent("herdr:pi", "pi", None);
+
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, None);
+ assert_eq!(terminal.fallback_state, AgentState::Unknown);
+ assert_eq!(terminal.state, AgentState::Unknown);
+ }
+
+ #[test]
+ fn stale_hook_report_sequence_is_ignored_for_same_source() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+
+ let change = terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ Some(19),
+ );
+
+ assert!(change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal.hook_authority.as_ref().unwrap().state,
+ AgentState::Working
+ );
+ }
+
+ #[test]
+ fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+
+ let change = terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ None,
+ );
+
+ assert!(change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+
+ #[test]
+ fn stale_release_sequence_is_ignored_for_same_source() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+
+ let change = terminal.release_agent("herdr:pi", "pi", Some(19));
+
+ assert!(change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.hook_authority.is_some());
+ }
+
+ #[test]
+ fn stale_clear_all_sequence_is_checked_against_current_authority_source() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+
+ let change = terminal.clear_hook_authority(None, Some(19));
+
+ assert!(change.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.hook_authority.is_some());
+ }
+
+ #[test]
+ fn same_sequence_from_different_sources_is_independent() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+
+ terminal.set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ Some(19),
+ );
+
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(
+ terminal.hook_authority.as_ref().unwrap().source,
+ "custom:pi"
+ );
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index 819cc0eb..a92eb1fe 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -127,7 +127,7 @@ fn resize_background_tab_panes_to_terminal_area(
if app.active == Some(ws_idx) && tab_idx == ws.active_tab_index() {
continue;
}
- resize_tab_panes(tab, terminal_area, cell_size);
+ resize_tab_panes(app, tab, terminal_area, cell_size);
}
}
}
@@ -396,13 +396,13 @@ mod tests {
let first_pane = ws.tabs[0].root_pane;
let second_pane = ws.test_split(ratatui::layout::Direction::Horizontal);
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"left"),
);
- ws.tabs[0].runtimes.insert(
+ ws.insert_test_runtime(
second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"),
+ crate::terminal::TerminalRuntime::test_with_screen_bytes(20, 5, b"r\r\nb"),
);
ws.tabs[0].layout.focus_pane(first_pane);
@@ -480,10 +480,14 @@ mod tests {
let repo = temp_git_repo("main");
ws.identity_cwd = repo.clone();
let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].pane_cwds.insert(root_pane, repo.clone());
ws.refresh_git_ahead_behind();
app.workspaces = vec![ws];
+ app.ensure_test_terminals();
+ let root_terminal_id = app.workspaces[0].tabs[0].panes[&root_pane]
+ .attached_terminal_id
+ .clone();
+ app.terminals.get_mut(&root_terminal_id).unwrap().cwd = repo.clone();
app.selected = 0;
app.mode = Mode::Navigate;
@@ -681,9 +685,9 @@ mod tests {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
- 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(
12,
4,
4096,
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs
index 9e8c57e9..8bcdf184 100644
--- a/src/ui/mobile.rs
+++ b/src/ui/mobile.rs
@@ -266,7 +266,7 @@ fn render_header_status(app: &AppState, frame: &mut Frame, area: Rect) {
return;
};
- let (state, seen) = ws.aggregate_state();
+ let (state, seen) = ws.aggregate_state(&app.terminals);
let (dot, dot_style) = if matches!(state, AgentState::Working) {
(
super::spinner_frame(app.spinner_tick),
@@ -430,7 +430,7 @@ fn render_mobile_switcher_content(app: &AppState, frame: &mut Frame, viewport: R
let active = Some(idx) == app.active;
let selected = idx == app.selected;
let bg = mobile_item_bg(selected, active, p);
- let (state, seen) = ws.aggregate_state();
+ let (state, seen) = ws.aggregate_state(&app.terminals);
let (dot, dot_style) = state_dot(state, seen, p);
let title = Line::from(vec![
Span::styled(" ", Style::default().bg(bg)),
@@ -824,7 +824,7 @@ fn agent_priority_label(app: &AppState) -> String {
let mut blocked = 0usize;
let mut working = 0usize;
let mut done = 0usize;
- for detail in ws.pane_details() {
+ for detail in ws.pane_details(&app.terminals) {
match (detail.state, detail.seen) {
(AgentState::Blocked, _) => blocked += 1,
(AgentState::Working, _) => working += 1,
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index 289d5fab..c90183ea 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -11,9 +11,9 @@ use super::widgets::panel_contrast_fg;
use crate::app::state::Palette;
use crate::app::{AppState, Mode};
use crate::layout::PaneInfo;
-use crate::pane::PaneRuntime;
+use crate::terminal::TerminalRuntime;
-pub(crate) fn pane_is_scrolled_back(rt: &PaneRuntime) -> bool {
+pub(crate) fn pane_is_scrolled_back(rt: &TerminalRuntime) -> bool {
rt.scroll_metrics()
.is_some_and(|metrics| metrics.offset_from_bottom > 0)
}
@@ -55,7 +55,20 @@ fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect {
)
}
-fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) {
+fn runtime_for_tab_pane<'a>(
+ app: &'a AppState,
+ tab: &'a crate::workspace::Tab,
+ pane_id: crate::layout::PaneId,
+) -> Option<&'a TerminalRuntime> {
+ #[cfg(test)]
+ if let Some(runtime) = tab.runtimes.get(&pane_id) {
+ return Some(runtime);
+ }
+ let terminal_id = tab.terminal_id(pane_id)?;
+ app.terminal_runtimes.get(terminal_id)
+}
+
+fn stable_scrollbar_gutter(rt: &TerminalRuntime, pane_inner: Rect) -> (Rect, Option<Rect>) {
let inner_rect = stable_terminal_inner_rect(pane_inner);
if inner_rect == pane_inner {
return (inner_rect, None);
@@ -76,6 +89,7 @@ fn stable_scrollbar_gutter(rt: &PaneRuntime, pane_inner: Rect) -> (Rect, Option<
/// Resize every visible runtime in a tab to the geometry it would receive if the tab were selected.
pub(super) fn resize_tab_panes(
+ app: &AppState,
tab: &crate::workspace::Tab,
area: Rect,
cell_size: crate::kitty_graphics::HostCellSize,
@@ -84,7 +98,7 @@ pub(super) fn resize_tab_panes(
if tab.zoomed {
let focused_id = tab.layout.focused();
- if let Some(rt) = tab.runtimes.get(&focused_id) {
+ if let Some(rt) = runtime_for_tab_pane(app, tab, focused_id) {
let inner_rect = stable_terminal_inner_rect(area);
rt.resize(
inner_rect.height,
@@ -103,7 +117,7 @@ pub(super) fn resize_tab_panes(
area
};
- if let Some(rt) = tab.runtimes.get(&info.id) {
+ if let Some(rt) = runtime_for_tab_pane(app, tab, info.id) {
let inner_rect = stable_terminal_inner_rect(pane_inner);
rt.resize(
inner_rect.height,
@@ -136,7 +150,7 @@ pub(super) fn compute_pane_infos(
let focused_id = ws.layout.focused();
let mut inner_rect = area;
let mut scrollbar_rect = None;
- if let Some(rt) = ws.runtimes.get(&focused_id) {
+ if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, focused_id) {
(inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, area);
if resize_panes {
rt.resize(
@@ -175,7 +189,7 @@ pub(super) fn compute_pane_infos(
let mut inner_rect = pane_inner;
let mut scrollbar_rect = None;
- if let Some(rt) = ws.runtimes.get(&info.id) {
+ if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) {
(inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner);
if resize_panes {
rt.resize(
@@ -208,7 +222,7 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
let terminal_active = app.mode == Mode::Terminal;
for info in &app.view.pane_infos {
- if let Some(rt) = ws.runtimes.get(&info.id) {
+ if let Some(rt) = app.runtime_for_pane_in_workspace(ws_idx, info.id) {
if multi_pane {
let (border_style, border_set) = if info.is_focused && terminal_active {
(
@@ -233,7 +247,10 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
.border_set(border_set);
if let Some(title) = ws
.pane_state(info.id)
- .and_then(|pane| pane.border_label(app.show_agent_labels_on_pane_borders))
+ .and_then(|pane| app.terminals.get(&pane.attached_terminal_id))
+ .and_then(|terminal| {
+ terminal.border_label(app.show_agent_labels_on_pane_borders)
+ })
.and_then(|label| pane_border_title(label, info.rect.width))
{
block = block.title(Line::from(Span::styled(title, border_style)));
@@ -360,7 +377,7 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
#[cfg(test)]
mod tests {
use super::*;
- use crate::pane::PaneRuntime;
+ use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
#[test]
@@ -381,7 +398,7 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
+ TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
);
app.workspaces = vec![workspace];
app.active = Some(0);
@@ -408,7 +425,7 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- PaneRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
+ TerminalRuntime::test_with_scrollback_bytes(40, 8, 1024, b"ready\n"),
);
app.workspaces = vec![workspace];
app.active = Some(0);
@@ -434,7 +451,7 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- PaneRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"),
+ TerminalRuntime::test_with_scrollback_bytes(4, 8, 1024, b"ready\n"),
);
app.workspaces = vec![workspace];
app.active = Some(0);
@@ -460,7 +477,7 @@ mod tests {
let root_pane = workspace.tabs[0].root_pane;
workspace.tabs[0].runtimes.insert(
root_pane,
- PaneRuntime::test_with_scrollback_bytes(
+ TerminalRuntime::test_with_scrollback_bytes(
40,
8,
1024,
diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs
index 34e739d0..b73b2c50 100644
--- a/src/ui/scrollbar.rs
+++ b/src/ui/scrollbar.rs
@@ -165,7 +165,7 @@ pub(super) fn render_pane_scrollbar(
app: &AppState,
frame: &mut Frame,
info: &PaneInfo,
- rt: &crate::pane::PaneRuntime,
+ rt: &crate::terminal::TerminalRuntime,
) {
let Some(metrics) = rt.scroll_metrics() else {
return;
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
index 29f3525d..eb6d6fab 100644
--- a/src/ui/sidebar.rs
+++ b/src/ui/sidebar.rs
@@ -116,7 +116,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
let Some(ws) = app.workspaces.get(ws_idx) else {
return Vec::new();
};
- ws.pane_details()
+ ws.pane_details(&app.terminals)
.into_iter()
.map(|detail| AgentPanelEntry {
ws_idx,
@@ -138,7 +138,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
.flat_map(|(ws_idx, ws)| {
let multi_tab = ws.tabs.len() > 1;
let workspace_label = ws.display_name();
- ws.pane_details()
+ ws.pane_details(&app.terminals)
.into_iter()
.map(move |detail| AgentPanelEntry {
ws_idx,
@@ -429,7 +429,7 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area:
if y >= ws_area.y + ws_area.height {
break;
}
- let (agg_state, agg_seen) = ws.aggregate_state();
+ let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals);
let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
let is_selected = visible_idx == app.selected && is_navigating;
let is_active = Some(visible_idx) == app.active;
@@ -487,7 +487,7 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area:
if detail_content_area != Rect::default() {
if let Some(ws_idx) = detail_ws_idx {
if let Some(ws) = app.workspaces.get(ws_idx) {
- for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
+ for (detail_idx, detail) in ws.pane_details(&app.terminals).iter().enumerate() {
let y = detail_content_area.y + detail_idx as u16;
if y >= detail_content_area.y + detail_content_area.height {
break;
@@ -603,7 +603,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
let is_active = Some(i) == app.active;
let is_dragged = dragged_ws_idx == Some(i);
let highlighted = selected || is_active || is_dragged;
- let (agg_state, agg_seen) = ws.aggregate_state();
+ let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals);
if highlighted {
let bg = if selected {
@@ -899,24 +899,28 @@ mod tests {
#[test]
fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
let mut app = crate::app::state::AppState::test_new();
- 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_tab = second.test_add_tab(Some("logs"));
let second_pane = second.tabs[second_tab].root_pane;
- second.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
app.workspaces = vec![first, second];
+ app.ensure_test_terminals();
+ let first_terminal_id = app.workspaces[0].tabs[0].panes[&first_pane]
+ .attached_terminal_id
+ .clone();
+ app.terminals
+ .get_mut(&first_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_terminal_id = app.workspaces[1].tabs[second_tab].panes[&second_pane]
+ .attached_terminal_id
+ .clone();
+ app.terminals
+ .get_mut(&second_terminal_id)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
app.active = Some(0);
app.selected = 0;
app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
diff --git a/src/workspace.rs b/src/workspace.rs
index 59b3e35d..553206c4 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -12,8 +12,8 @@ use crate::events::AppEvent;
use crate::layout::PaneId;
#[cfg(test)]
use crate::layout::TileLayout;
-use crate::pane::{PaneRuntime, PaneState};
-use crate::terminal::TerminalId;
+use crate::pane::PaneState;
+use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
mod aggregate;
mod git;
@@ -62,6 +62,8 @@ pub struct Workspace {
pub(crate) next_public_pane_number: usize,
pub tabs: Vec<Tab>,
pub active_tab: usize,
+ #[cfg(test)]
+ pub(crate) test_runtimes: HashMap<PaneId, TerminalRuntime>,
}
impl Deref for Workspace {
@@ -90,7 +92,7 @@ impl Workspace {
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_tab(
initial_cwd,
rows,
@@ -114,7 +116,7 @@ impl Workspace {
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_tab(
initial_cwd,
rows,
@@ -139,8 +141,8 @@ impl Workspace {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
argv: Option<&[String]>,
- ) -> std::io::Result<Self> {
- let tab = if let Some(argv) = argv {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
+ let (tab, terminal, runtime) = if let Some(argv) = argv {
Tab::new_argv_command(
1,
initial_cwd.clone(),
@@ -168,17 +170,23 @@ impl Workspace {
};
let mut public_pane_numbers = HashMap::new();
public_pane_numbers.insert(tab.root_pane, 1);
- Ok(Self {
- id: generate_workspace_id(),
- custom_name: None,
- identity_cwd: initial_cwd.clone(),
- cached_git_branch: git_branch(&initial_cwd),
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number: 2,
- tabs: vec![tab],
- active_tab: 0,
- })
+ Ok((
+ Self {
+ id: generate_workspace_id(),
+ custom_name: None,
+ identity_cwd: initial_cwd.clone(),
+ cached_git_branch: git_branch(&initial_cwd),
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number: 2,
+ tabs: vec![tab],
+ active_tab: 0,
+ #[cfg(test)]
+ test_runtimes: HashMap::new(),
+ },
+ terminal,
+ runtime,
+ ))
}
pub fn active_tab(&self) -> Option<&Tab> {
@@ -215,7 +223,7 @@ impl Workspace {
cwd: PathBuf,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<usize> {
+ ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
self.create_tab_with_runtime(
rows,
cols,
@@ -234,7 +242,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
argv: Option<&[String]>,
- ) -> std::io::Result<usize> {
+ ) -> std::io::Result<(usize, TerminalState, TerminalRuntime)> {
let number = self.tabs.len() + 1;
let events = self
.active_tab()
@@ -249,7 +257,7 @@ impl Workspace {
.map(|tab| tab.render_dirty.clone())
.expect("workspace must always have at least one tab");
- let tab = if let Some(argv) = argv {
+ let (tab, terminal, runtime) = if let Some(argv) = argv {
Tab::new_argv_command(
number,
cwd,
@@ -277,7 +285,7 @@ impl Workspace {
};
self.register_new_pane(tab.root_pane);
self.tabs.push(tab);
- Ok(self.tabs.len() - 1)
+ Ok((self.tabs.len() - 1, terminal, runtime))
}
pub fn close_tab(&mut self, idx: usize) -> bool {
@@ -335,8 +343,8 @@ impl Workspace {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
- let new_id = self
+ ) -> std::io::Result<crate::workspace::tab::NewPane> {
+ let new_pane = self
.active_tab_mut()
.expect("workspace must always have at least one tab")
.split_focused(
@@ -347,8 +355,8 @@ impl Workspace {
scrollback_limit_bytes,
host_terminal_theme,
)?;
- self.register_new_pane(new_id);
- Ok(new_id)
+ self.register_new_pane(new_pane.pane_id);
+ Ok(new_pane)
}
pub fn split_pane(
@@ -361,7 +369,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
- ) -> Option<std::io::Result<(usize, PaneId)>> {
+ ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
pane_id,
direction,
@@ -387,7 +395,7 @@ impl Workspace {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
- ) -> Option<std::io::Result<(usize, PaneId)>> {
+ ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
self.split_pane_with_runtime(
pane_id,
direction,
@@ -413,12 +421,12 @@ impl Workspace {
host_terminal_theme: crate::terminal_theme::TerminalTheme,
focus_new_pane: bool,
argv: Option<&[String]>,
- ) -> Option<std::io::Result<(usize, PaneId)>> {
+ ) -> Option<std::io::Result<(usize, crate::workspace::tab::NewPane)>> {
let tab_idx = self.find_tab_index_for_pane(pane_id)?;
let tab = &mut self.tabs[tab_idx];
let previous_focus = tab.layout.focused();
tab.layout.focus_pane(pane_id);
- let new_id = match if let Some(argv) = argv {
+ let new_pane = match if let Some(argv) = argv {
tab.split_focused_argv_command(
direction,
rows,
@@ -438,7 +446,7 @@ impl Workspace {
host_terminal_theme,
)
} {
- Ok(new_id) => new_id,
+ Ok(new_pane) => new_pane,
Err(err) => {
tab.layout.focus_pane(previous_focus);
return Some(Err(err));
@@ -447,8 +455,8 @@ impl Workspace {
if !focus_new_pane {
tab.layout.focus_pane(previous_focus);
}
- self.register_new_pane(new_id);
- Some(Ok((tab_idx, new_id)))
+ self.register_new_pane(new_pane.pane_id);
+ Some(Ok((tab_idx, new_pane)))
}
/// Close the focused pane. Returns true if the workspace should close.
@@ -462,11 +470,8 @@ impl Workspace {
return tab_count <= 1 || self.close_active_tab_and_report();
}
- if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) {
+ if let Some((removed, _terminal_id)) = self.active_tab_mut().and_then(Tab::close_focused) {
self.unregister_pane(removed);
- if let Some(runtime) = runtime {
- runtime.shutdown(removed);
- }
}
false
}
@@ -494,7 +499,7 @@ impl Workspace {
return false;
}
- if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) {
+ if let Some((removed, _terminal_id)) = self.tabs[tab_idx].remove_pane(pane_id) {
self.unregister_pane(removed);
}
false
@@ -509,10 +514,7 @@ impl Workspace {
}
pub fn resolved_identity_cwd(&self) -> Option<PathBuf> {
- self.tabs
- .first()
- .and_then(Tab::root_cwd)
- .or_else(|| Some(self.identity_cwd.clone()))
+ Some(self.identity_cwd.clone())
}
pub fn display_name(&self) -> String {
@@ -557,10 +559,6 @@ impl Workspace {
}
}
- pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
- self.active_tab().and_then(Tab::focused_runtime)
- }
-
pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> {
self.tabs
.iter()
@@ -571,16 +569,6 @@ impl Workspace {
self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id))
}
- pub fn pane_state_mut(&mut self, pane_id: PaneId) -> Option<&mut PaneState> {
- self.tabs
- .iter_mut()
- .find_map(|tab| tab.panes.get_mut(&pane_id))
- }
-
- pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> {
- self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id))
- }
-
pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> {
self.tabs.iter().find_map(|tab| tab.terminal_id(pane_id))
}
@@ -611,11 +599,8 @@ impl Workspace {
return false;
}
- if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) {
+ if let Some((removed, _terminal_id)) = self.tabs[tab_idx].close_pane(pane_id) {
self.unregister_pane(removed);
- if let Some(runtime) = runtime {
- runtime.shutdown(removed);
- }
}
false
}
@@ -660,20 +645,15 @@ impl Workspace {
let render_dirty = Arc::new(AtomicBool::new(false));
let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
let (layout, root_id) = TileLayout::new();
+ let terminal_id = TerminalId::alloc();
let mut panes = HashMap::new();
- panes.insert(root_id, PaneState::new());
- let mut pane_cwds = HashMap::new();
- pane_cwds.insert(root_id, identity_cwd.clone());
- let mut terminal_ids = HashMap::new();
- terminal_ids.insert(root_id, TerminalId::alloc());
+ panes.insert(root_id, PaneState::new(terminal_id));
let tab = Tab {
custom_name: None,
number: 1,
root_pane: root_id,
layout,
panes,
- pane_cwds,
- terminal_ids,
runtimes: HashMap::new(),
zoomed: false,
events,
@@ -692,16 +672,19 @@ impl Workspace {
next_public_pane_number: 2,
tabs: vec![tab],
active_tab: 0,
+ test_runtimes: HashMap::new(),
}
}
+ pub(crate) fn insert_test_runtime(&mut self, pane_id: PaneId, runtime: TerminalRuntime) {
+ self.test_runtimes.insert(pane_id, runtime);
+ }
+
pub(crate) fn test_split(&mut self, direction: Direction) -> PaneId {
let tab = self.active_tab_mut().expect("workspace must have tab");
let new_id = tab.layout.split_focused(direction);
- tab.panes.insert(new_id, PaneState::new());
- let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
- tab.pane_cwds.insert(new_id, cwd);
- tab.terminal_ids.insert(new_id, TerminalId::alloc());
+ tab.panes
+ .insert(new_id, PaneState::new(TerminalId::alloc()));
self.register_new_pane(new_id);
new_id
}
@@ -712,20 +695,13 @@ impl Workspace {
let render_dirty = Arc::new(AtomicBool::new(false));
let (layout, root_id) = TileLayout::new();
let mut panes = HashMap::new();
- panes.insert(root_id, PaneState::new());
- let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
- let mut pane_cwds = HashMap::new();
- pane_cwds.insert(root_id, cwd);
- let mut terminal_ids = HashMap::new();
- terminal_ids.insert(root_id, TerminalId::alloc());
+ panes.insert(root_id, PaneState::new(TerminalId::alloc()));
let tab = Tab {
custom_name: name.map(str::to_string),
number: self.tabs.len() + 1,
root_pane: root_id,
layout,
panes,
- pane_cwds,
- terminal_ids,
runtimes: HashMap::new(),
zoomed: false,
events,
@@ -743,13 +719,10 @@ mod tests {
use super::*;
#[test]
- fn workspace_identity_follows_first_tab_root_pane_cwd() {
+ fn workspace_identity_uses_identity_cwd() {
let mut ws = Workspace::test_new("ignored");
ws.custom_name = None;
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .pane_cwds
- .insert(root_pane, PathBuf::from("/herdr-test/pion"));
+ ws.identity_cwd = PathBuf::from("/herdr-test/pion");
assert_eq!(ws.display_name(), "pion");
assert_eq!(
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index 0507781e..3ac7ea9b 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -1,5 +1,8 @@
+use std::collections::HashMap;
+
use crate::detect::{Agent, AgentState};
use crate::layout::PaneId;
+use crate::terminal::{TerminalId, TerminalState};
use super::{Tab, Workspace};
@@ -18,29 +21,32 @@ pub struct PaneDetail {
}
impl Tab {
- pub fn has_working_pane(&self) -> bool {
- self.panes
- .values()
- .any(|pane| pane.state == AgentState::Working)
+ pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool {
+ self.panes.values().any(|pane| {
+ terminals
+ .get(&pane.attached_terminal_id)
+ .is_some_and(|terminal| terminal.state == AgentState::Working)
+ })
}
- pub fn pane_details(&self) -> Vec<PaneDetail> {
+ pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> {
self.layout
.pane_ids()
.iter()
.filter_map(|id| {
let pane = self.panes.get(id)?;
- let agent_label = pane.effective_agent_label()?.to_string();
+ let terminal = terminals.get(&pane.attached_terminal_id)?;
+ let agent_label = terminal.effective_agent_label()?.to_string();
Some(PaneDetail {
pane_id: *id,
tab_idx: self.number.saturating_sub(1),
tab_label: self.display_name(),
label: agent_label.clone(),
agent_label,
- agent: pane.effective_known_agent(),
- state: pane.state,
+ agent: terminal.effective_known_agent(),
+ state: terminal.state,
seen: pane.seen,
- custom_status: pane.effective_custom_status().map(str::to_string),
+ custom_status: terminal.effective_custom_status().map(str::to_string),
})
})
.collect()
@@ -58,24 +64,31 @@ fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
}
impl Workspace {
- pub fn aggregate_state(&self) -> (AgentState, bool) {
+ pub fn aggregate_state(
+ &self,
+ terminals: &HashMap<TerminalId, TerminalState>,
+ ) -> (AgentState, bool) {
self.tabs
.iter()
.flat_map(|tab| tab.panes.values())
- .map(|pane| (pane.state, pane.seen))
+ .filter_map(|pane| {
+ terminals
+ .get(&pane.attached_terminal_id)
+ .map(|terminal| (terminal.state, pane.seen))
+ })
.max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
.unwrap_or((AgentState::Unknown, true))
}
- pub fn has_working_pane(&self) -> bool {
- self.tabs.iter().any(Tab::has_working_pane)
+ pub fn has_working_pane(&self, terminals: &HashMap<TerminalId, TerminalState>) -> bool {
+ self.tabs.iter().any(|tab| tab.has_working_pane(terminals))
}
- pub fn pane_details(&self) -> Vec<PaneDetail> {
+ pub fn pane_details(&self, terminals: &HashMap<TerminalId, TerminalState>) -> Vec<PaneDetail> {
let multi_tab = self.tabs.len() > 1;
self.tabs
.iter()
- .flat_map(Tab::pane_details)
+ .flat_map(|tab| tab.pane_details(terminals))
.map(|mut detail| {
if multi_tab {
detail.label = format!("{}·{}", detail.tab_label, detail.agent_label);
@@ -93,10 +106,18 @@ mod tests {
use super::*;
use crate::detect::Agent;
+ fn terminal_for_pane(ws: &Workspace, pane_id: PaneId) -> TerminalState {
+ TerminalState::new(ws.terminal_id(pane_id).unwrap().clone(), "/tmp".into())
+ }
+
#[test]
fn aggregate_state_all_unknown() {
let ws = Workspace::test_new("test");
- let (state, seen) = ws.aggregate_state();
+ let mut terminals = HashMap::new();
+ let root = ws.tabs[0].root_pane;
+ let terminal = terminal_for_pane(&ws, root);
+ terminals.insert(terminal.id.clone(), terminal);
+ let (state, seen) = ws.aggregate_state(&terminals);
assert_eq!(state, AgentState::Unknown);
assert!(seen);
}
@@ -111,10 +132,16 @@ mod tests {
.find(|id| **id != id2)
.copied()
.unwrap();
- ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
- ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
+ let mut terminals = HashMap::new();
+ let mut root_terminal = terminal_for_pane(&ws, root_id);
+ root_terminal.state = AgentState::Idle;
+ terminals.insert(root_terminal.id.clone(), root_terminal);
+ let mut second_terminal = terminal_for_pane(&ws, id2);
+ second_terminal.state = AgentState::Working;
+ terminals.insert(second_terminal.id.clone(), second_terminal);
+
+ let (state, seen) = ws.aggregate_state(&terminals);
- let (state, seen) = ws.aggregate_state();
assert_eq!(state, AgentState::Working);
assert!(seen);
}
@@ -129,68 +156,61 @@ mod tests {
.find(|id| **id != id2)
.copied()
.unwrap();
+ let mut terminals = HashMap::new();
+ let mut root_terminal = terminal_for_pane(&ws, root_id);
+ root_terminal.state = AgentState::Idle;
+ terminals.insert(root_terminal.id.clone(), root_terminal);
+ let mut second_terminal = terminal_for_pane(&ws, id2);
+ second_terminal.state = AgentState::Working;
+ terminals.insert(second_terminal.id.clone(), second_terminal);
let root = ws.tabs[0].panes.get_mut(&root_id).unwrap();
- root.state = AgentState::Idle;
root.seen = false;
- ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
- let (state, seen) = ws.aggregate_state();
+ let (state, seen) = ws.aggregate_state(&terminals);
+
assert_eq!(state, AgentState::Idle);
assert!(!seen);
}
#[test]
- fn pane_details_hide_plain_shells() {
- let mut ws = Workspace::test_new("test");
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
- ws.test_split(Direction::Horizontal);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].label, "pi");
- }
-
- #[test]
- fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("main".into());
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
-
- let tab_idx = ws.test_add_tab(Some("logs"));
- let second_root_pane = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&second_root_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 2);
- assert!(details.iter().any(|detail| detail.label == "main·pi"));
- assert!(details.iter().any(|detail| detail.label == "logs·claude"));
- }
-
- #[test]
- fn pane_details_include_hook_reported_unknown_agents() {
+ fn pane_details_includes_tab_context_for_multi_tab_workspace() {
let mut ws = Workspace::test_new("test");
+ ws.tabs[0].custom_name = Some("main".into());
let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&root_pane)
- .unwrap()
- .set_hook_authority(
- "custom:hermes".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- None,
- );
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].agent_label, "hermes");
- assert_eq!(details[0].agent, None);
+ let second_tab = ws.test_add_tab(Some("review"));
+ let review_pane = ws.tabs[second_tab].root_pane;
+ let mut terminals = HashMap::new();
+ let mut root_terminal = terminal_for_pane(&ws, root_pane);
+ root_terminal.set_hook_authority(
+ "test".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ );
+ terminals.insert(root_terminal.id.clone(), root_terminal);
+ let mut review_terminal = terminal_for_pane(&ws, review_pane);
+ review_terminal.set_hook_authority(
+ "test".into(),
+ "claude".into(),
+ AgentState::Idle,
+ None,
+ None,
+ );
+ terminals.insert(review_terminal.id.clone(), review_terminal);
+
+ let labels: Vec<_> = ws
+ .pane_details(&terminals)
+ .into_iter()
+ .map(|detail| (detail.label, detail.agent_label, detail.agent))
+ .collect();
+
+ assert_eq!(
+ labels,
+ vec![
+ ("main·pi".into(), "pi".into(), Some(Agent::Pi)),
+ ("review·claude".into(), "claude".into(), Some(Agent::Claude)),
+ ]
+ );
}
}
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index d809f944..943a45a0 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -8,8 +8,16 @@ use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
-use crate::pane::{PaneRuntime, PaneState};
-use crate::terminal::TerminalId;
+use crate::pane::PaneState;
+use crate::terminal::{TerminalId, TerminalRuntime, TerminalState};
+
+pub(crate) type DetachedPane = (PaneId, TerminalId);
+
+pub struct NewPane {
+ pub pane_id: PaneId,
+ pub terminal: TerminalState,
+ pub runtime: TerminalRuntime,
+}
enum SplitCommand<'a> {
Shell {
@@ -27,29 +35,16 @@ pub struct Tab {
/// Identity source for this tab's pane tree.
pub root_pane: PaneId,
pub layout: TileLayout,
- /// Pane state — always present, testable without PTYs.
+ /// Pane viewport state — always present, testable without PTYs.
pub panes: HashMap<PaneId, PaneState>,
- /// Best-effort cwd cache per pane. Live runtime cwd wins when available.
- pub pane_cwds: HashMap<PaneId, PathBuf>,
- /// One-to-one terminal identity for each pane-backed PTY during the transition.
- pub terminal_ids: HashMap<PaneId, TerminalId>,
- /// Pane runtimes — only present in production (empty in tests).
- pub runtimes: HashMap<PaneId, PaneRuntime>,
+ #[cfg(test)]
+ pub runtimes: HashMap<PaneId, TerminalRuntime>,
pub zoomed: bool,
pub events: mpsc::Sender<AppEvent>,
pub(crate) render_notify: Arc<Notify>,
pub(crate) render_dirty: Arc<AtomicBool>,
}
-impl Drop for Tab {
- fn drop(&mut self) {
- let runtimes = std::mem::take(&mut self.runtimes);
- for (pane_id, runtime) in runtimes {
- runtime.shutdown(pane_id);
- }
- }
-}
-
impl Tab {
pub fn new(
number: usize,
@@ -61,7 +56,7 @@ impl Tab {
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_runtime(
number,
initial_cwd,
@@ -87,7 +82,7 @@ impl Tab {
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
Self::new_with_runtime(
number,
initial_cwd,
@@ -114,10 +109,10 @@ impl Tab {
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
argv: Option<&[String]>,
- ) -> std::io::Result<Self> {
+ ) -> std::io::Result<(Self, TerminalState, TerminalRuntime)> {
let (layout, root_id) = TileLayout::new();
let runtime = if let Some(argv) = argv {
- PaneRuntime::spawn_argv_command(
+ TerminalRuntime::spawn_argv_command(
root_id,
rows,
cols,
@@ -130,7 +125,7 @@ impl Tab {
render_dirty.clone(),
)?
} else {
- PaneRuntime::spawn(
+ TerminalRuntime::spawn(
root_id,
rows,
cols,
@@ -143,29 +138,33 @@ impl Tab {
)?
};
+ let terminal_id = TerminalId::alloc();
+ let terminal = match argv {
+ Some(argv) => {
+ TerminalState::new(terminal_id.clone(), initial_cwd).with_launch_argv(argv.to_vec())
+ }
+ None => TerminalState::new(terminal_id.clone(), initial_cwd),
+ };
let mut panes = HashMap::new();
- panes.insert(root_id, PaneState::new());
- let mut pane_cwds = HashMap::new();
- pane_cwds.insert(root_id, initial_cwd);
- let mut terminal_ids = HashMap::new();
- terminal_ids.insert(root_id, TerminalId::alloc());
- let mut runtimes = HashMap::new();
- runtimes.insert(root_id, runtime);
+ panes.insert(root_id, PaneState::new(terminal_id));
- Ok(Self {
- custom_name: None,
- number,
- root_pane: root_id,
- layout,
- panes,
- pane_cwds,
- terminal_ids,
- runtimes,
- zoomed: false,
- events,
- render_notify,
- render_dirty,
- })
+ Ok((
+ Self {
+ custom_name: None,
+ number,
+ root_pane: root_id,
+ layout,
+ panes,
+ #[cfg(test)]
+ runtimes: HashMap::new(),
+ zoomed: false,
+ events,
+ render_notify,
+ render_dirty,
+ },
+ terminal,
+ runtime,
+ ))
}
pub fn display_name(&self) -> String {
@@ -190,7 +189,7 @@ impl Tab {
cwd: Option<PathBuf>,
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
+ ) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
rows,
@@ -212,7 +211,7 @@ impl Tab {
extra_env: &[(String, String)],
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
+ ) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
rows,
@@ -233,7 +232,7 @@ impl Tab {
argv: &[String],
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
+ ) -> std::io::Result<NewPane> {
self.split_focused_with_runtime(
direction,
rows,
@@ -254,25 +253,33 @@ impl Tab {
scrollback_limit_bytes: usize,
host_terminal_theme: crate::terminal_theme::TerminalTheme,
command: Option<SplitCommand<'_>>,
- ) -> std::io::Result<PaneId> {
+ ) -> std::io::Result<NewPane> {
+ let previous_focus = self.layout.focused();
let new_id = self.layout.split_focused(direction);
let actual_cwd =
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ let launch_argv = if let Some(SplitCommand::Argv { argv }) = &command {
+ Some((*argv).to_vec())
+ } else {
+ None
+ };
let runtime = match command {
- Some(SplitCommand::Shell { command, extra_env }) => PaneRuntime::spawn_shell_command(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- command,
- extra_env,
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?,
- Some(SplitCommand::Argv { argv }) => PaneRuntime::spawn_argv_command(
+ Some(SplitCommand::Shell { command, extra_env }) => {
+ TerminalRuntime::spawn_shell_command(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ command,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )
+ }
+ Some(SplitCommand::Argv { argv }) => TerminalRuntime::spawn_argv_command(
new_id,
rows,
cols,
@@ -283,8 +290,8 @@ impl Tab {
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- )?,
- None => PaneRuntime::spawn(
+ ),
+ None => TerminalRuntime::spawn(
new_id,
rows,
cols,
@@ -294,30 +301,46 @@ impl Tab {
self.events.clone(),
self.render_notify.clone(),
self.render_dirty.clone(),
- )?,
+ ),
+ };
+ let runtime = match runtime {
+ Ok(runtime) => runtime,
+ Err(err) => {
+ self.layout.close_focused();
+ self.layout.focus_pane(previous_focus);
+ return Err(err);
+ }
};
- self.panes.insert(new_id, PaneState::new());
- self.pane_cwds.insert(new_id, actual_cwd);
- self.terminal_ids.insert(new_id, TerminalId::alloc());
- self.runtimes.insert(new_id, runtime);
+ let terminal_id = TerminalId::alloc();
+ let terminal = match launch_argv {
+ Some(argv) => {
+ TerminalState::new(terminal_id.clone(), actual_cwd).with_launch_argv(argv)
+ }
+ None => TerminalState::new(terminal_id.clone(), actual_cwd),
+ };
+ self.panes.insert(new_id, PaneState::new(terminal_id));
self.zoomed = false;
- Ok(new_id)
+ Ok(NewPane {
+ pane_id: new_id,
+ terminal,
+ runtime,
+ })
}
- pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
+ pub fn close_focused(&mut self) -> Option<DetachedPane> {
let pane_id = self.layout.focused();
self.detach_pane(pane_id)
}
- pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ pub fn close_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> {
self.detach_pane(pane_id)
}
- pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> {
self.detach_pane(pane_id)
}
- fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ fn detach_pane(&mut self, pane_id: PaneId) -> Option<DetachedPane> {
if self.layout.pane_count() <= 1 {
return None;
}
@@ -333,15 +356,13 @@ impl Tab {
self.layout.focus_pane(prev_focus);
}
- self.panes.remove(&pane_id);
- self.pane_cwds.remove(&pane_id);
- self.terminal_ids.remove(&pane_id);
- let runtime = self.runtimes.remove(&pane_id);
+ let pane = self.panes.remove(&pane_id)?;
+ let terminal_id = pane.attached_terminal_id;
self.zoomed = false;
if let Some(next_root) = next_root {
self.root_pane = next_root;
}
- Some((pane_id, runtime))
+ Some((pane_id, terminal_id))
}
fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
@@ -351,37 +372,26 @@ impl Tab {
self.layout.pane_ids().into_iter().find(|id| *id != closing)
}
- pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
- self.runtimes.get(&self.layout.focused())
- }
-
pub fn terminal_id(&self, pane_id: PaneId) -> Option<&TerminalId> {
- self.terminal_ids.get(&pane_id)
- }
-
- pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> {
- self.runtimes
+ self.panes
.get(&pane_id)
- .and_then(|rt| rt.cwd())
- .or_else(|| self.pane_cwds.get(&pane_id).cloned())
+ .map(|pane| &pane.attached_terminal_id)
}
- pub fn root_cwd(&self) -> Option<PathBuf> {
- self.cwd_for_pane(self.root_pane)
- }
-
- #[cfg(test)]
- #[allow(dead_code)] // retained for focused layout-order assertions in tests
- pub fn pane_states(&self) -> Vec<(crate::detect::AgentState, bool)> {
- self.layout
- .pane_ids()
- .iter()
- .map(|id| {
- self.panes
- .get(id)
- .map(|p| (p.state, p.seen))
- .unwrap_or((crate::detect::AgentState::Unknown, true))
+ pub fn cwd_for_pane(
+ &self,
+ pane_id: PaneId,
+ terminals: &HashMap<TerminalId, TerminalState>,
+ terminal_runtimes: &HashMap<TerminalId, TerminalRuntime>,
+ ) -> Option<PathBuf> {
+ let terminal_id = self.terminal_id(pane_id)?;
+ terminal_runtimes
+ .get(terminal_id)
+ .and_then(|rt| rt.cwd())
+ .or_else(|| {
+ terminals
+ .get(terminal_id)
+ .map(|terminal| terminal.cwd.clone())
})
- .collect()
}
}