Skip to main content

tmai_core/runtime/
standalone.rs

1//! StandaloneAdapter — hook/IPC-only runtime, no tmux required.
2//!
3//! Agents are registered via hook SessionStart events and unregistered
4//! on SessionEnd. Discovery returns the internal agent registry.
5//! Screen observation returns empty content (hooks provide status directly).
6//! Control methods return errors (IPC handles control as primary path).
7
8use anyhow::Result;
9use parking_lot::RwLock;
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU32, Ordering};
12use std::sync::Arc;
13
14use super::RuntimeAdapter;
15use crate::tmux::PaneInfo;
16
17/// RuntimeAdapter for standalone (webui) mode without tmux.
18///
19/// Agents are tracked via an internal registry populated by hook events.
20/// Synthetic targets use the format `standalone:0.{id}` to satisfy
21/// the `session:window.pane` pattern used throughout the codebase.
22pub struct StandaloneAdapter {
23    /// Registered agents keyed by session_id (from hook events)
24    agents: Arc<RwLock<HashMap<String, PaneInfo>>>,
25    /// Counter for generating unique pane indices
26    next_id: AtomicU32,
27}
28
29impl StandaloneAdapter {
30    /// Create a new empty StandaloneAdapter.
31    pub fn new() -> Self {
32        Self {
33            agents: Arc::new(RwLock::new(HashMap::new())),
34            next_id: AtomicU32::new(1),
35        }
36    }
37
38    /// Register an agent from a hook SessionStart event.
39    ///
40    /// Creates a synthetic `PaneInfo` with a `standalone:0.{id}` target.
41    /// Returns the generated target identifier.
42    pub fn register_agent(
43        &self,
44        session_id: &str,
45        cwd: &str,
46        title: &str,
47        command: &str,
48        pid: u32,
49    ) -> String {
50        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
51        let target = format!("standalone:0.{}", id);
52        let pane_id = id.to_string();
53
54        let pane = PaneInfo {
55            target: target.clone(),
56            session: "standalone".to_string(),
57            window_index: 0,
58            pane_index: id,
59            pane_id,
60            window_name: command.to_string(),
61            command: command.to_string(),
62            pid,
63            title: title.to_string(),
64            cwd: cwd.to_string(),
65        };
66
67        let mut agents = self.agents.write();
68        agents.insert(session_id.to_string(), pane);
69        target
70    }
71
72    /// Unregister an agent (hook SessionEnd event).
73    pub fn unregister_agent(&self, session_id: &str) {
74        let mut agents = self.agents.write();
75        agents.remove(session_id);
76    }
77
78    /// Update agent metadata (cwd, title) from hook events.
79    pub fn update_agent(&self, session_id: &str, cwd: Option<&str>, title: Option<&str>) {
80        let mut agents = self.agents.write();
81        if let Some(pane) = agents.get_mut(session_id) {
82            if let Some(cwd) = cwd {
83                pane.cwd = cwd.to_string();
84            }
85            if let Some(title) = title {
86                pane.title = title.to_string();
87            }
88        }
89    }
90
91    /// Look up the synthetic target for a session_id.
92    pub fn target_for_session(&self, session_id: &str) -> Option<String> {
93        let agents = self.agents.read();
94        agents.get(session_id).map(|p| p.target.clone())
95    }
96
97    /// Get a reference to the agent registry (for testing).
98    pub fn agent_count(&self) -> usize {
99        self.agents.read().len()
100    }
101}
102
103impl Default for StandaloneAdapter {
104    fn default() -> Self {
105        Self::new()
106    }
107}
108
109impl RuntimeAdapter for StandaloneAdapter {
110    // --- Discovery ---
111
112    fn list_all_panes(&self) -> Result<Vec<PaneInfo>> {
113        let agents = self.agents.read();
114        Ok(agents.values().cloned().collect())
115    }
116
117    fn list_panes(&self) -> Result<Vec<PaneInfo>> {
118        // Standalone has no concept of attached/detached; return all
119        self.list_all_panes()
120    }
121
122    fn list_sessions(&self) -> Result<Vec<String>> {
123        let agents = self.agents.read();
124        if agents.is_empty() {
125            Ok(vec![])
126        } else {
127            Ok(vec!["standalone".to_string()])
128        }
129    }
130
131    fn is_available(&self) -> bool {
132        // Standalone is always available (no external dependency)
133        true
134    }
135
136    // --- Observation ---
137
138    fn capture_pane(&self, _target: &str) -> Result<String> {
139        // No screen capture in standalone mode; hooks provide status directly
140        Ok(String::new())
141    }
142
143    fn capture_pane_plain(&self, _target: &str) -> Result<String> {
144        Ok(String::new())
145    }
146
147    fn get_pane_title(&self, target: &str) -> Result<String> {
148        let agents = self.agents.read();
149        for pane in agents.values() {
150            if pane.target == target {
151                return Ok(pane.title.clone());
152            }
153        }
154        Ok(String::new())
155    }
156
157    // --- Control ---
158
159    fn send_keys(&self, _target: &str, _keys: &str) -> Result<()> {
160        // In standalone mode, IPC is the primary control path.
161        // If IPC fails, there is no tmux fallback.
162        anyhow::bail!("send_keys not available in standalone mode (use IPC)")
163    }
164
165    fn send_keys_literal(&self, _target: &str, _keys: &str) -> Result<()> {
166        anyhow::bail!("send_keys_literal not available in standalone mode (use IPC)")
167    }
168
169    fn send_text_and_enter(&self, _target: &str, _text: &str) -> Result<()> {
170        anyhow::bail!("send_text_and_enter not available in standalone mode (use IPC)")
171    }
172
173    // --- Focus / Lifecycle ---
174
175    fn focus_pane(&self, _target: &str) -> Result<()> {
176        anyhow::bail!("focus_pane not available in standalone mode")
177    }
178
179    fn kill_pane(&self, _target: &str) -> Result<()> {
180        anyhow::bail!("kill_pane not available in standalone mode")
181    }
182
183    // --- Metadata ---
184
185    fn name(&self) -> &str {
186        "standalone"
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_standalone_creation() {
196        let adapter = StandaloneAdapter::new();
197        assert_eq!(adapter.name(), "standalone");
198        assert!(adapter.is_available());
199    }
200
201    #[test]
202    fn test_register_and_list() {
203        let adapter = StandaloneAdapter::new();
204
205        let target =
206            adapter.register_agent("sess-1", "/home/user/project", "Working", "claude", 1234);
207        assert!(target.starts_with("standalone:0."));
208
209        let panes = adapter.list_all_panes().unwrap();
210        assert_eq!(panes.len(), 1);
211        assert_eq!(panes[0].cwd, "/home/user/project");
212        assert_eq!(panes[0].command, "claude");
213        assert_eq!(panes[0].pid, 1234);
214    }
215
216    #[test]
217    fn test_register_multiple_and_unregister() {
218        let adapter = StandaloneAdapter::new();
219
220        adapter.register_agent("sess-1", "/path/a", "A", "claude", 100);
221        adapter.register_agent("sess-2", "/path/b", "B", "codex", 200);
222        assert_eq!(adapter.agent_count(), 2);
223
224        adapter.unregister_agent("sess-1");
225        assert_eq!(adapter.agent_count(), 1);
226
227        let panes = adapter.list_all_panes().unwrap();
228        assert_eq!(panes[0].command, "codex");
229    }
230
231    #[test]
232    fn test_update_agent() {
233        let adapter = StandaloneAdapter::new();
234        adapter.register_agent("sess-1", "/old/path", "Old Title", "claude", 100);
235
236        adapter.update_agent("sess-1", Some("/new/path"), Some("New Title"));
237
238        let panes = adapter.list_all_panes().unwrap();
239        assert_eq!(panes[0].cwd, "/new/path");
240        assert_eq!(panes[0].title, "New Title");
241    }
242
243    #[test]
244    fn test_target_for_session() {
245        let adapter = StandaloneAdapter::new();
246        assert!(adapter.target_for_session("nonexistent").is_none());
247
248        let target = adapter.register_agent("sess-1", "/path", "T", "claude", 100);
249        assert_eq!(adapter.target_for_session("sess-1"), Some(target));
250    }
251
252    #[test]
253    fn test_observation_returns_empty() {
254        let adapter = StandaloneAdapter::new();
255        assert_eq!(adapter.capture_pane("standalone:0.1").unwrap(), "");
256        assert_eq!(adapter.capture_pane_plain("standalone:0.1").unwrap(), "");
257    }
258
259    #[test]
260    fn test_control_methods_error() {
261        let adapter = StandaloneAdapter::new();
262        assert!(adapter.send_keys("standalone:0.1", "Enter").is_err());
263        assert!(adapter
264            .send_keys_literal("standalone:0.1", "hello")
265            .is_err());
266        assert!(adapter
267            .send_text_and_enter("standalone:0.1", "hello")
268            .is_err());
269        assert!(adapter.focus_pane("standalone:0.1").is_err());
270        assert!(adapter.kill_pane("standalone:0.1").is_err());
271    }
272
273    #[test]
274    fn test_session_management_not_supported() {
275        let adapter = StandaloneAdapter::new();
276        assert!(adapter.create_session("test", "/tmp", None).is_err());
277        assert!(adapter.new_window("test", "/tmp", None).is_err());
278        assert!(adapter.split_window("test", "/tmp").is_err());
279        assert!(adapter.get_current_location().is_err());
280    }
281
282    #[test]
283    fn test_list_sessions_empty_and_nonempty() {
284        let adapter = StandaloneAdapter::new();
285        assert!(adapter.list_sessions().unwrap().is_empty());
286
287        adapter.register_agent("sess-1", "/path", "T", "claude", 100);
288        let sessions = adapter.list_sessions().unwrap();
289        assert_eq!(sessions, vec!["standalone"]);
290    }
291
292    #[test]
293    fn test_get_pane_title_from_registry() {
294        let adapter = StandaloneAdapter::new();
295        let target = adapter.register_agent("sess-1", "/path", "My Title", "claude", 100);
296        assert_eq!(adapter.get_pane_title(&target).unwrap(), "My Title");
297        assert_eq!(adapter.get_pane_title("nonexistent:0.99").unwrap(), "");
298    }
299}