aboutsummaryrefslogtreecommitdiff
path: root/src/workspace
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/workspace
parente4de58b37f58d59d146158239ed218a8accd5c3c (diff)
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/workspace')
-rw-r--r--src/workspace/aggregate.rs164
-rw-r--r--src/workspace/tab.rs222
2 files changed, 208 insertions, 178 deletions
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()
}
}