Skip to main content

tmai_core/
command_sender.rs

1use anyhow::Result;
2use std::sync::Arc;
3
4use crate::hooks::registry::HookRegistry;
5use crate::ipc::server::IpcServer;
6use crate::pty::registry::PtyRegistry;
7use crate::runtime::RuntimeAdapter;
8use crate::state::SharedState;
9use crate::utils::keys::tmux_key_to_bytes;
10
11/// Unified command sender with 4-tier fallback: PTY session → IPC → RuntimeAdapter (tmux) → PTY inject
12///
13/// Tier priority follows reliability:
14/// - **PTY session**: Direct write to spawned PTY session — most reliable for WebUI-spawned agents
15/// - **IPC**: `tmai wrap` provides PTY master — most reliable for wrapped agents
16/// - **tmux send-keys**: tmux native mechanism — reliable when tmux is available
17/// - **PTY inject**: TIOCSTI via `/proc/{pid}/fd/0` — last resort, requires kernel support
18pub struct CommandSender {
19    ipc_server: Option<Arc<IpcServer>>,
20    runtime: Arc<dyn RuntimeAdapter>,
21    app_state: SharedState,
22    hook_registry: Option<HookRegistry>,
23    pty_registry: Option<Arc<PtyRegistry>>,
24}
25
26impl CommandSender {
27    /// Create a new CommandSender
28    pub fn new(
29        ipc_server: Option<Arc<IpcServer>>,
30        runtime: Arc<dyn RuntimeAdapter>,
31        app_state: SharedState,
32    ) -> Self {
33        Self {
34            ipc_server,
35            runtime,
36            app_state,
37            hook_registry: None,
38            pty_registry: None,
39        }
40    }
41
42    /// Attach a HookRegistry for PTY injection PID resolution
43    pub fn with_hook_registry(mut self, registry: HookRegistry) -> Self {
44        self.hook_registry = Some(registry);
45        self
46    }
47
48    /// Attach a PtyRegistry for direct PTY session writes
49    pub fn with_pty_registry(mut self, registry: Arc<PtyRegistry>) -> Self {
50        self.pty_registry = Some(registry);
51        self
52    }
53
54    /// Try writing directly to a PTY session (for WebUI-spawned agents)
55    fn try_pty_session_write(&self, target: &str, data: &[u8]) -> bool {
56        if let Some(ref registry) = self.pty_registry {
57            // target may be the session_id directly
58            if let Some(session) = registry.get(target) {
59                if session.is_running() {
60                    return session.write_input(data).is_ok();
61                }
62            }
63            // Also check via pty_session_id in agent state
64            let session_id = {
65                let state = self.app_state.read();
66                state
67                    .agents
68                    .get(target)
69                    .and_then(|a| a.pty_session_id.clone())
70            };
71            if let Some(sid) = session_id {
72                if let Some(session) = registry.get(&sid) {
73                    if session.is_running() {
74                        return session.write_input(data).is_ok();
75                    }
76                }
77            }
78        }
79        false
80    }
81
82    /// Send keys via PTY session → IPC → tmux send-keys → PTY inject
83    pub fn send_keys(&self, target: &str, keys: &str) -> Result<()> {
84        // Tier 0: Direct PTY session write (convert tmux key names to bytes)
85        let key_bytes = tmux_key_to_bytes(keys);
86        if self.try_pty_session_write(target, &key_bytes) {
87            return Ok(());
88        }
89        // Tier 1: IPC
90        if let Some(ref ipc) = self.ipc_server {
91            if let Some(pane_id) = self.get_pane_id_for_target(target) {
92                if ipc.try_send_keys(&pane_id, keys, false) {
93                    return Ok(());
94                }
95            }
96        }
97        // Tier 2: RuntimeAdapter (tmux send-keys)
98        if self.runtime.send_keys(target, keys).is_ok() {
99            return Ok(());
100        }
101        // Tier 3: PTY injection via /proc/{pid}/fd/0 (TIOCSTI)
102        if let Some(pid) = self.resolve_pid_for_target(target) {
103            if pid > 0 {
104                return crate::pty_inject::inject_text(pid, keys);
105            }
106        }
107        anyhow::bail!("All send_keys tiers failed for target {}", target)
108    }
109
110    /// Send literal keys via PTY session → IPC → tmux send-keys → PTY inject
111    pub fn send_keys_literal(&self, target: &str, keys: &str) -> Result<()> {
112        // Tier 0: Direct PTY session write (literal = raw bytes, no key name conversion)
113        if self.try_pty_session_write(target, keys.as_bytes()) {
114            return Ok(());
115        }
116        // Tier 1: IPC
117        if let Some(ref ipc) = self.ipc_server {
118            if let Some(pane_id) = self.get_pane_id_for_target(target) {
119                if ipc.try_send_keys(&pane_id, keys, true) {
120                    return Ok(());
121                }
122            }
123        }
124        // Tier 2: RuntimeAdapter (tmux send-keys)
125        if self.runtime.send_keys_literal(target, keys).is_ok() {
126            return Ok(());
127        }
128        // Tier 3: PTY injection (literal text)
129        if let Some(pid) = self.resolve_pid_for_target(target) {
130            if pid > 0 {
131                return crate::pty_inject::inject_text_literal(pid, keys);
132            }
133        }
134        anyhow::bail!("All send_keys_literal tiers failed for target {}", target)
135    }
136
137    /// Send text + Enter via PTY session → IPC → tmux send-keys → PTY inject
138    pub fn send_text_and_enter(&self, target: &str, text: &str) -> Result<()> {
139        // Tier 0: Direct PTY session write (text + carriage return)
140        let mut data = text.as_bytes().to_vec();
141        data.push(b'\r');
142        if self.try_pty_session_write(target, &data) {
143            return Ok(());
144        }
145        // Tier 1: IPC
146        if let Some(ref ipc) = self.ipc_server {
147            if let Some(pane_id) = self.get_pane_id_for_target(target) {
148                if ipc.try_send_keys_and_enter(&pane_id, text) {
149                    return Ok(());
150                }
151            }
152        }
153        // Tier 2: RuntimeAdapter (tmux send-keys)
154        if self.runtime.send_text_and_enter(target, text).is_ok() {
155            return Ok(());
156        }
157        // Tier 3: PTY injection (text + Enter)
158        if let Some(pid) = self.resolve_pid_for_target(target) {
159            if pid > 0 {
160                return crate::pty_inject::inject_text_and_enter(pid, text);
161            }
162        }
163        anyhow::bail!("All send_text_and_enter tiers failed for target {}", target)
164    }
165
166    /// Access the runtime adapter for direct operations (focus_pane, kill_pane, etc.)
167    pub fn runtime(&self) -> &Arc<dyn RuntimeAdapter> {
168        &self.runtime
169    }
170
171    /// Access the IPC server (needed for Poller registry)
172    pub fn ipc_server(&self) -> Option<&Arc<IpcServer>> {
173        self.ipc_server.as_ref()
174    }
175
176    /// Look up pane_id from target using the mapping in AppState
177    fn get_pane_id_for_target(&self, target: &str) -> Option<String> {
178        let state = self.app_state.read();
179        state.target_to_pane_id.get(target).cloned()
180    }
181
182    /// Resolve the PID for a target agent via HookRegistry or AppState
183    fn resolve_pid_for_target(&self, target: &str) -> Option<u32> {
184        // Try HookRegistry: target → pane_id → HookState.pid
185        if let Some(ref registry) = self.hook_registry {
186            let pane_id = {
187                let state = self.app_state.read();
188                state.target_to_pane_id.get(target).cloned()
189            };
190            if let Some(pane_id) = pane_id {
191                let reg = registry.read();
192                if let Some(hook_state) = reg.get(&pane_id) {
193                    if let Some(pid) = hook_state.pid {
194                        return Some(pid);
195                    }
196                }
197            }
198        }
199
200        // Fallback: check MonitoredAgent.pid in AppState (direct lookup by agent ID)
201        let state = self.app_state.read();
202        if let Some(agent) = state.agents.get(target) {
203            if agent.pid > 0 {
204                return Some(agent.pid);
205            }
206        }
207        // Also try matching by target field (for tmux-based agents)
208        for agent in state.agents.values() {
209            if agent.target == target && agent.pid > 0 {
210                return Some(agent.pid);
211            }
212        }
213        None
214    }
215}