Skip to main content

vtcode_core/tools/pty/manager/
session_ops.rs

1use super::super::formatting::{format_terminal_file, sanitize_session_id};
2use super::super::manager_utils::exit_status_code;
3use super::super::session::{CommandEchoState, PtySessionHandle};
4use super::PtyManager;
5use crate::tools::types::VTCodePtySession;
6use crate::utils::file_utils::{ensure_dir_exists, write_file_with_context};
7use anyhow::{Context, Result, anyhow};
8use portable_pty::PtySize;
9use std::io::Write;
10use std::sync::Arc;
11use tracing::warn;
12
13impl PtyManager {
14    pub fn list_sessions(&self) -> Vec<VTCodePtySession> {
15        let sessions = self.inner.sessions.lock();
16        sessions
17            .values()
18            .map(|handle| handle.snapshot_metadata())
19            .collect()
20    }
21
22    pub fn snapshot_session(&self, session_id: &str) -> Result<VTCodePtySession> {
23        let handle = self.session_handle(session_id)?;
24        Ok(handle.snapshot_metadata())
25    }
26
27    pub fn read_session_output(&self, session_id: &str, drain: bool) -> Result<Option<String>> {
28        let handle = self.session_handle(session_id)?;
29        Ok(handle.read_output(drain))
30    }
31
32    pub fn send_input_to_session(
33        &self,
34        session_id: &str,
35        data: &[u8],
36        append_newline: bool,
37    ) -> Result<usize> {
38        let handle = self.session_handle(session_id)?;
39
40        // Acquire last_input lock once and update conditionally
41        {
42            let mut last_input = handle.last_input.lock();
43            *last_input = if let Ok(input_text) = std::str::from_utf8(data) {
44                CommandEchoState::new(input_text, append_newline)
45            } else {
46                None
47            };
48        }
49
50        // Acquire writer lock once for all write operations
51        {
52            let mut writer_guard = handle.writer.lock();
53            let writer = writer_guard
54                .as_mut()
55                .ok_or_else(|| anyhow!("PTY session '{}' is no longer writable", session_id))?;
56
57            writer
58                .write_all(data)
59                .context("failed to write input to PTY session")?;
60
61            if append_newline {
62                writer
63                    .write_all(b"\n")
64                    .context("failed to write newline to PTY session")?;
65            }
66
67            writer
68                .flush()
69                .context("failed to flush PTY session input")?;
70        }
71
72        let written = data.len() + if append_newline { 1 } else { 0 };
73        Ok(written)
74    }
75
76    pub fn resize_session(&self, session_id: &str, size: PtySize) -> Result<VTCodePtySession> {
77        let handle = self.session_handle(session_id)?;
78
79        {
80            let master = handle.master.lock();
81            master
82                .resize(size)
83                .context("failed to resize PTY session")?;
84        }
85
86        {
87            let mut screen_state = handle.screen_state.lock();
88            screen_state.resize(size);
89        }
90
91        Ok(handle.snapshot_metadata())
92    }
93
94    pub fn is_session_completed(&self, session_id: &str) -> Result<Option<i32>> {
95        let handle = self.session_handle(session_id)?;
96        let mut child = handle.child.lock();
97        child
98            .try_wait()
99            .context("failed to poll PTY session status")
100            .map(|opt| opt.map(exit_status_code))
101    }
102
103    pub fn terminate_session(&self, session_id: &str) -> Result<()> {
104        let handle = self.session_handle(session_id)?;
105
106        {
107            let mut writer_guard = handle.writer.lock();
108            if let Some(mut writer) = writer_guard.take() {
109                let _ = writer.write_all(b"exit\n");
110                let _ = writer.flush();
111            }
112        }
113
114        handle.graceful_terminate();
115        Ok(())
116    }
117
118    /// Sync all terminal sessions to files for dynamic context discovery
119    ///
120    /// This implements Cursor-style dynamic context discovery:
121    /// - Each terminal session is written to `.vtcode/terminals/{session_id}.txt`
122    /// - Includes metadata header (cwd, last command, exit code)
123    /// - Agent can reference terminal output via grep/read_file
124    pub async fn sync_sessions_to_files(&self) -> Result<Vec<std::path::PathBuf>> {
125        let terminals_dir = self.workspace_root.join(".vtcode").join("terminals");
126        ensure_dir_exists(&terminals_dir).await.with_context(|| {
127            format!(
128                "Failed to create terminals directory: {}",
129                terminals_dir.display()
130            )
131        })?;
132
133        let sessions = self.list_sessions();
134        let mut written_files = Vec::with_capacity(sessions.len());
135
136        for session in &sessions {
137            let output = match self.read_session_output(&session.id, false) {
138                Ok(Some(output)) => output,
139                Ok(None) => String::new(),
140                Err(_) => continue,
141            };
142
143            let content = format_terminal_file(session, &output);
144            let file_path = terminals_dir.join(format!("{}.txt", sanitize_session_id(&session.id)));
145
146            if let Err(e) =
147                write_file_with_context(&file_path, &content, "terminal session file").await
148            {
149                warn!(
150                    session_id = %session.id,
151                    error = %e,
152                    "Failed to sync terminal session to file"
153                );
154                continue;
155            }
156
157            written_files.push(file_path);
158        }
159
160        // Write index file
161        let index_content = self.generate_terminals_index(&sessions);
162        let index_path = terminals_dir.join("INDEX.md");
163        write_file_with_context(&index_path, &index_content, "terminals index")
164            .await
165            .with_context(|| {
166                format!("Failed to write terminals index: {}", index_path.display())
167            })?;
168
169        tracing::info!(
170            sessions = sessions.len(),
171            files = written_files.len(),
172            "Synced terminal sessions to files"
173        );
174
175        Ok(written_files)
176    }
177
178    /// Generate INDEX.md content for terminal sessions
179    fn generate_terminals_index(&self, sessions: &[VTCodePtySession]) -> String {
180        let mut content = String::new();
181        content.push_str("# Terminal Sessions Index\n\n");
182        content.push_str("This file lists all active terminal sessions for dynamic discovery.\n");
183        content.push_str("Use `read_file` on individual session files for full output.\n\n");
184
185        if sessions.is_empty() {
186            content.push_str("*No active terminal sessions.*\n");
187        } else {
188            content.push_str(&format!("**Active Sessions**: {}\n\n", sessions.len()));
189            content.push_str("| Session ID | Command | Working Dir | Size |\n");
190            content.push_str("|------------|---------|-------------|------|\n");
191
192            for session in sessions {
193                let cwd = session.working_dir.as_deref().unwrap_or("-");
194                let cmd_truncated =
195                    vtcode_commons::formatting::truncate_byte_budget(&session.command, 22, "...");
196
197                content.push_str(&format!(
198                    "| `{}` | {} | {} | {}x{} |\n",
199                    session.id,
200                    cmd_truncated.replace('|', "\\|"),
201                    cwd.replace('|', "\\|"),
202                    session.cols,
203                    session.rows
204                ));
205            }
206
207            content.push_str("\n## Session Details\n\n");
208            for session in sessions {
209                content.push_str(&format!("### {}\n\n", session.id));
210                content.push_str(&format!("- **Command**: `{}`\n", session.command));
211                if !session.args.is_empty() {
212                    content.push_str(&format!("- **Args**: {}\n", session.args.join(" ")));
213                }
214                if let Some(cwd) = &session.working_dir {
215                    content.push_str(&format!("- **Working Dir**: {}\n", cwd));
216                }
217                content.push_str(&format!(
218                    "- **Terminal Size**: {}x{}\n",
219                    session.cols, session.rows
220                ));
221                content.push_str(&format!(
222                    "- **File**: `.vtcode/terminals/{}.txt`\n\n",
223                    sanitize_session_id(&session.id)
224                ));
225            }
226        }
227
228        content.push_str("---\n");
229        content.push_str("*Generated automatically. Do not edit manually.*\n");
230
231        content
232    }
233
234    /// Get the terminals directory path
235    pub fn terminals_dir(&self) -> std::path::PathBuf {
236        self.workspace_root.join(".vtcode").join("terminals")
237    }
238
239    pub fn close_session(&self, session_id: &str) -> Result<VTCodePtySession> {
240        // Remove session from global map first
241        let handle = {
242            let mut sessions = self.inner.sessions.lock();
243            sessions
244                .remove(session_id)
245                .ok_or_else(|| anyhow!("PTY session '{}' not found", session_id))?
246        };
247
248        // Lock order: writer -> child -> reader_thread (follow documented order)
249
250        // 1. Close writer
251        {
252            let mut writer_guard = handle.writer.lock();
253            if let Some(mut writer) = writer_guard.take() {
254                let _ = writer.write_all(b"exit\n");
255                let _ = writer.flush();
256            }
257        }
258
259        // 2. Terminate child process using graceful termination
260        // This uses SIGTERM first, then SIGKILL after a grace period
261        handle.graceful_terminate();
262
263        // 3. Join reader thread
264        {
265            let mut thread_guard = handle.reader_thread.lock();
266            if let Some(reader_thread) = thread_guard.take()
267                && let Err(panic) = reader_thread.join()
268            {
269                warn!(
270                    "PTY session '{}' reader thread panicked: {:?}",
271                    session_id, panic
272                );
273            }
274        }
275
276        // Snapshot metadata acquires the PTY master plus screen and scrollback state.
277        Ok(handle.snapshot_metadata())
278    }
279
280    pub fn terminate_all_sessions(&self) {
281        let session_ids: Vec<String> = {
282            let sessions = self.inner.sessions.lock();
283            sessions.keys().cloned().collect()
284        };
285        for id in session_ids {
286            if let Err(e) = self.close_session(&id) {
287                warn!("Failed to close PTY session {}: {}", id, e);
288            }
289        }
290    }
291
292    fn session_handle(&self, session_id: &str) -> Result<Arc<PtySessionHandle>> {
293        let sessions = self.inner.sessions.lock();
294        sessions
295            .get(session_id)
296            .cloned()
297            .ok_or_else(|| anyhow!("PTY session '{}' not found", session_id))
298    }
299}