terminal-use 1.4.1

Headless virtual terminal for AI agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::sync::Arc;

use anyhow::{Context, Result};
use nix::libc;
use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus};
use nix::unistd::Pid;
use tokio::io::AsyncReadExt;
use tokio::sync::Mutex;

use crate::daemon::protocol::{CursorPos, MouseButton, MouseLastEvent, SessionInfo, TermSize};
use crate::pty;

/// Tu's idea of the synthetic mouse state for a session: where the cursor was
/// left after the most recent emitted event, which buttons are still held
/// (down without a matching up), and a snapshot of the most recent event.
///
/// The inner application is under no obligation to render the mouse cursor,
/// so this is the only authoritative source for "where am I and what's held"
/// when an agent loses track between calls.
#[derive(Debug, Default)]
pub struct MouseTracker {
    pub cursor: Option<CursorPos>,
    pub buttons_held: Vec<MouseButton>,
    pub last_event: Option<MouseLastEvent>,
}

impl MouseTracker {
    pub fn record_position(&mut self, col: u16, row: u16) {
        self.cursor = Some(CursorPos { row, col });
    }

    pub fn press(&mut self, button: MouseButton) {
        if !self.buttons_held.contains(&button) {
            self.buttons_held.push(button);
        }
    }

    pub fn release(&mut self, button: MouseButton) {
        self.buttons_held.retain(|b| *b != button);
    }

    /// Clear the cursor if it is now outside the new size.
    pub fn clamp_to_size(&mut self, size: &TermSize) {
        if let Some(pos) = self.cursor {
            if pos.col >= size.cols || pos.row >= size.rows {
                self.cursor = None;
            }
        }
    }
}

/// A terminal session: a child process in a PTY with a vt100 screen buffer.
pub struct Session {
    pub name: String,
    pub master_fd: OwnedFd,
    pub pid: Pid,
    pub parser: Arc<Mutex<crate::emu::Parser>>,
    pub size: TermSize,
    pub alive: bool,
    pub exit_code: Option<i32>,
    pub mouse: MouseTracker,
}

impl Session {
    /// Spawn a new session.
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        name: String,
        command: &str,
        args: &[String],
        size: TermSize,
        scrollback: usize,
        env: &[(String, String)],
        cwd: Option<&str>,
        term: &str,
        shell: bool,
    ) -> Result<Self> {
        let pty_proc = pty::spawn::spawn(command, args, &size, env, cwd, term, shell)?;
        let parser = crate::emu::Parser::new(size.rows, size.cols, scrollback);

        Ok(Self {
            name,
            master_fd: pty_proc.master_fd,
            pid: pty_proc.pid,
            parser: Arc::new(Mutex::new(parser)),
            size,
            alive: true,
            exit_code: None,
            mouse: MouseTracker::default(),
        })
    }

    /// Start a background task that reads PTY output and feeds it to the vt100 parser.
    pub fn start_reader(&self) -> Result<()> {
        let parser = self.parser.clone();

        // Two dup'd fds: one for the async reader, one for the writeback path
        // (parser-driven replies to terminal queries). They share the same
        // underlying open description, so writes are atomic even though the
        // tasks aren't synchronised at the fd level.
        let read_fd = nix::unistd::dup(&self.master_fd).context("dup master_fd (read)")?;
        let write_fd = nix::unistd::dup(&self.master_fd).context("dup master_fd (write)")?;

        tokio::spawn(async move {
            // Safety: we just dup'd the fd, so this is a valid owned fd.
            let std_file = unsafe { std::fs::File::from_raw_fd(read_fd.as_raw_fd()) };
            // Prevent the OwnedFd from closing separately — std_file now owns the underlying fd
            std::mem::forget(read_fd);

            let mut async_file = tokio::io::BufReader::new(tokio::fs::File::from_std(std_file));
            let mut buf = [0u8; 4096];

            loop {
                match async_file.read(&mut buf).await {
                    Ok(0) => break,
                    Ok(n) => {
                        let pending = {
                            let mut p = parser.lock().await;
                            p.process(&buf[..n]);
                            // The terminal may have queued replies (DA / cursor
                            // position reports / DCS terminfo queries / etc.)
                            // in response to queries from the inner app.
                            // Forward them back to the PTY so curses apps
                            // (vim, less, mc) don't hang waiting for them.
                            p.take_pending_writes()
                        };
                        if !pending.is_empty() {
                            let _ = crate::pty::input::write_to_pty(&write_fd, &pending);
                        }
                    }
                    Err(e) => {
                        if e.raw_os_error() == Some(libc::EIO) {
                            break;
                        }
                        eprintln!("PTY read error: {e}");
                        break;
                    }
                }
            }
        });

        Ok(())
    }

    /// Check if the child is still alive, updating status if it exited.
    pub fn poll_status(&mut self) {
        if !self.alive {
            return;
        }
        match waitpid(self.pid, Some(WaitPidFlag::WNOHANG)) {
            Ok(WaitStatus::StillAlive) => {}
            Ok(WaitStatus::Exited(_, code)) => {
                self.alive = false;
                self.exit_code = Some(code);
            }
            Ok(WaitStatus::Signaled(_, sig, _)) => {
                self.alive = false;
                self.exit_code = Some(128 + sig as i32);
            }
            Ok(_) => {}
            Err(_) => {
                self.alive = false;
            }
        }
    }

    /// Get the current screen contents as plain text.
    pub async fn screenshot_text(&self) -> String {
        let parser = self.parser.lock().await;
        let mut lines: Vec<String> = parser
            .screen()
            .text_rows()
            .into_iter()
            .map(|line| {
                let mut sanitized = String::with_capacity(line.len());
                push_sanitized(&mut sanitized, &line);
                sanitized.trim_end().to_string()
            })
            .collect();
        while lines.last().is_some_and(|l| l.is_empty()) {
            lines.pop();
        }
        lines.join("\n")
    }

    /// Get the current screen contents with ANSI formatting (raw bytes).
    pub async fn screenshot_ansi(&self) -> Vec<u8> {
        let parser = self.parser.lock().await;
        let screen = parser.screen();
        screen.contents_formatted()
    }

    /// Get the screen as a vector of ANSI-rendered row strings.
    /// Each string contains SGR escape codes for colors/attributes, suitable for
    /// embedding inside a frame (no cursor positioning escapes).
    pub async fn screenshot_cells(&self) -> Vec<String> {
        let parser = self.parser.lock().await;
        let screen = parser.screen();
        let mut rows = Vec::with_capacity(self.size.rows as usize);

        for row in 0..self.size.rows {
            let mut line = String::new();
            let mut prev_fg = crate::emu::Color::Default;
            let mut prev_bg = crate::emu::Color::Default;
            let mut prev_bold = false;
            let mut prev_inverse = false;
            let mut prev_underline = false;

            for col in 0..self.size.cols {
                let cell = screen.cell(row, col).unwrap();

                // Skip wide continuation cells
                if cell.is_wide_continuation() {
                    continue;
                }

                let fg = cell.fgcolor();
                let bg = cell.bgcolor();
                let bold = cell.bold();
                let inverse = cell.inverse();
                let underline = cell.underline();

                // Emit SGR changes
                let attrs_changed = fg != prev_fg
                    || bg != prev_bg
                    || bold != prev_bold
                    || inverse != prev_inverse
                    || underline != prev_underline;

                if attrs_changed {
                    // Reset and re-apply all active attributes
                    line.push_str("\x1b[0");
                    if bold {
                        line.push_str(";1");
                    }
                    if underline {
                        line.push_str(";4");
                    }
                    if inverse {
                        line.push_str(";7");
                    }
                    push_fg_sgr(&mut line, fg);
                    push_bg_sgr(&mut line, bg);
                    line.push('m');

                    prev_fg = fg;
                    prev_bg = bg;
                    prev_bold = bold;
                    prev_inverse = inverse;
                    prev_underline = underline;
                }

                let ch = cell.contents();
                if ch.is_empty() {
                    line.push(' ');
                } else {
                    push_sanitized(&mut line, ch);
                }
            }

            // Reset at end of row
            line.push_str("\x1b[0m");
            rows.push(line);
        }

        rows
    }

    /// Get the current cursor position.
    pub async fn cursor_pos(&self) -> CursorPos {
        let parser = self.parser.lock().await;
        let screen = parser.screen();
        CursorPos {
            row: screen.cursor_position().0,
            col: screen.cursor_position().1,
        }
    }

    /// Get scrollback contents.
    pub async fn scrollback(&self, lines: Option<usize>) -> String {
        let parser = self.parser.lock().await;
        let screen = parser.screen();
        let full = screen.contents();
        match lines {
            Some(n) => {
                let all_lines: Vec<&str> = full.lines().collect();
                let start = all_lines.len().saturating_sub(n);
                all_lines[start..].join("\n")
            }
            None => full,
        }
    }

    /// Get session info.
    pub fn info(&mut self) -> SessionInfo {
        self.poll_status();
        SessionInfo {
            name: self.name.clone(),
            pid: self.pid.as_raw() as u32,
            alive: self.alive,
            exit_code: self.exit_code,
            size: self.size.clone(),
        }
    }

    /// Write raw bytes to the PTY (keystrokes).
    pub fn write_bytes(&self, data: &[u8]) -> Result<()> {
        pty::input::write_to_pty(&self.master_fd, data)
    }

    /// Type text (write as-is).
    pub fn type_text(&self, text: &str) -> Result<()> {
        pty::input::write_to_pty(&self.master_fd, text.as_bytes())
    }

    /// Paste text using bracketed paste mode.
    pub fn paste_text(&self, text: &str) -> Result<()> {
        pty::input::bracketed_paste(&self.master_fd, text)
    }

    /// Resize the terminal.
    pub async fn resize(&mut self, size: TermSize) -> Result<()> {
        pty::resize::resize_pty(&self.master_fd, &size)?;
        let mut parser = self.parser.lock().await;
        parser.screen_mut().set_size(size.rows, size.cols);
        self.size = size.clone();
        self.mouse.clamp_to_size(&size);
        Ok(())
    }

    /// Kill the child process.
    pub fn kill(&mut self) {
        if self.alive {
            let _ = nix::sys::signal::kill(self.pid, nix::sys::signal::Signal::SIGTERM);
            std::thread::sleep(std::time::Duration::from_millis(100));
            self.poll_status();
            if self.alive {
                let _ = nix::sys::signal::kill(self.pid, nix::sys::signal::Signal::SIGKILL);
                let _ = waitpid(self.pid, None);
                self.alive = false;
                self.exit_code = Some(137);
            }
        }
    }
}

impl Drop for Session {
    fn drop(&mut self) {
        self.kill();
    }
}

/// Append a cell's text content to `out`, replacing any control byte (< 0x20,
/// excluding tab) with a space. A misbehaving inner app — or a sequence the
/// vt100 parser doesn't recognise — can leave a stray ESC (0x1B) inside a
/// cell; if we forwarded that raw it would re-enter the user's terminal as
/// the start of an escape sequence and render as caret-notation (`^[`),
/// corrupting the row.
fn push_sanitized(out: &mut String, content: &str) {
    for c in content.chars() {
        if (c as u32) < 0x20 && c != '\t' {
            out.push(' ');
        } else {
            out.push(c);
        }
    }
}

fn push_fg_sgr(s: &mut String, color: crate::emu::Color) {
    match color {
        crate::emu::Color::Default => {}
        crate::emu::Color::Idx(i) => {
            if i < 8 {
                s.push_str(&format!(";{}", 30 + i));
            } else if i < 16 {
                s.push_str(&format!(";{}", 90 + i - 8));
            } else {
                s.push_str(&format!(";38;5;{}", i));
            }
        }
        crate::emu::Color::Rgb(r, g, b) => {
            s.push_str(&format!(";38;2;{};{};{}", r, g, b));
        }
    }
}

fn push_bg_sgr(s: &mut String, color: crate::emu::Color) {
    match color {
        crate::emu::Color::Default => {}
        crate::emu::Color::Idx(i) => {
            if i < 8 {
                s.push_str(&format!(";{}", 40 + i));
            } else if i < 16 {
                s.push_str(&format!(";{}", 100 + i - 8));
            } else {
                s.push_str(&format!(";48;5;{}", i));
            }
        }
        crate::emu::Color::Rgb(r, g, b) => {
            s.push_str(&format!(";48;2;{};{};{}", r, g, b));
        }
    }
}