aboutsummaryrefslogtreecommitdiff
path: root/src/terminal
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/terminal
parente4de58b37f58d59d146158239ed218a8accd5c3c (diff)
refactor: extract terminal state and runtime from panes
Diffstat (limited to 'src/terminal')
-rw-r--r--src/terminal/mod.rs10
-rw-r--r--src/terminal/state.rs615
2 files changed, 625 insertions, 0 deletions
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"
+ );
+ }
+}