Skip to main content

atman_runtime/tools/
term.rs

1#![allow(dead_code)]
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex};
7use std::time::Instant;
8
9use tokio::sync::broadcast;
10use tokio::task::JoinHandle;
11
12use crate::error::RuntimeError;
13
14const DEFAULT_ROWS: u16 = 24;
15const DEFAULT_COLS: u16 = 80;
16const STREAM_CHANNEL_CAPACITY: usize = 256;
17
18#[derive(Debug, Clone, Hash, PartialEq, Eq)]
19pub struct TermHandle {
20    pub session_id: String,
21    pub local_id: u64,
22}
23
24impl TermHandle {
25    pub fn parse(s: &str) -> Option<Self> {
26        let rest = s.strip_prefix("term_")?;
27        let idx = rest.rfind('_')?;
28        let session_id = rest[..idx].to_string();
29        let local_id = rest[idx + 1..].parse().ok()?;
30        Some(Self {
31            session_id,
32            local_id,
33        })
34    }
35}
36
37impl std::fmt::Display for TermHandle {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "term_{}_{}", self.session_id, self.local_id)
40    }
41}
42
43#[derive(Debug, Clone)]
44pub enum TermState {
45    Running {
46        pid: u32,
47        started_at: u64,
48    },
49    Exited {
50        exit_code: Option<i32>,
51        ended_at: u64,
52    },
53    Failed {
54        error: String,
55        ended_at: u64,
56    },
57    Killed {
58        ended_at: u64,
59    },
60}
61
62impl TermState {
63    pub fn is_running(&self) -> bool {
64        matches!(self, TermState::Running { .. })
65    }
66
67    pub fn to_snapshot(&self) -> TermStateSnapshot {
68        match self {
69            TermState::Running { .. } => TermStateSnapshot::Running,
70            TermState::Exited { exit_code, .. } => TermStateSnapshot::Exited {
71                exit_code: *exit_code,
72            },
73            TermState::Failed { error, .. } => TermStateSnapshot::Failed {
74                error: error.clone(),
75            },
76            TermState::Killed { .. } => TermStateSnapshot::Killed,
77        }
78    }
79}
80
81#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
82#[serde(tag = "kind")]
83pub enum TermStateSnapshot {
84    Running,
85    Exited { exit_code: Option<i32> },
86    Failed { error: String },
87    Killed,
88}
89
90#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
91pub struct TerminalScreen {
92    pub rows: u16,
93    pub cols: u16,
94    pub cells: Vec<TerminalCell>,
95    pub cursor: Option<(u16, u16)>,
96    pub alt_screen: bool,
97}
98
99#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
100pub struct TerminalCell {
101    pub chars: String,
102    pub fg: TerminalColor,
103    pub bg: TerminalColor,
104    pub bold: bool,
105    pub italic: bool,
106    pub underline: bool,
107    pub inverse: bool,
108    pub dim: bool,
109    pub wide: bool,
110    #[serde(default)]
111    pub wide_continuation: bool,
112}
113
114impl Default for TerminalCell {
115    fn default() -> Self {
116        Self {
117            chars: String::new(),
118            fg: TerminalColor::Default,
119            bg: TerminalColor::Default,
120            bold: false,
121            italic: false,
122            underline: false,
123            inverse: false,
124            dim: false,
125            wide: false,
126            wide_continuation: false,
127        }
128    }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
132pub enum TerminalColor {
133    Default,
134    Idx(u8),
135    Rgb(u8, u8, u8),
136}
137
138impl From<vt100::Color> for TerminalColor {
139    fn from(c: vt100::Color) -> Self {
140        match c {
141            vt100::Color::Default => TerminalColor::Default,
142            vt100::Color::Idx(i) => TerminalColor::Idx(i),
143            vt100::Color::Rgb(r, g, b) => TerminalColor::Rgb(r, g, b),
144        }
145    }
146}
147
148pub fn snapshot_screen(parser: &vt100::Parser) -> TerminalScreen {
149    let screen = parser.screen();
150    let (rows, cols) = screen.size();
151    let mut cells = Vec::with_capacity(rows as usize * cols as usize);
152    for row in 0..rows {
153        for col in 0..cols {
154            if let Some(c) = screen.cell(row, col) {
155                cells.push(TerminalCell {
156                    chars: c.contents().to_string(),
157                    fg: c.fgcolor().into(),
158                    bg: c.bgcolor().into(),
159                    bold: c.bold(),
160                    italic: c.italic(),
161                    underline: c.underline(),
162                    inverse: c.inverse(),
163                    dim: c.dim(),
164                    wide: c.is_wide(),
165                    wide_continuation: c.is_wide_continuation(),
166                });
167            } else {
168                cells.push(TerminalCell::default());
169            }
170        }
171    }
172    let cursor = if screen.hide_cursor() {
173        None
174    } else {
175        Some(screen.cursor_position())
176    };
177    TerminalScreen {
178        rows,
179        cols,
180        cells,
181        cursor,
182        alt_screen: screen.alternate_screen(),
183    }
184}
185
186#[derive(Debug, Clone)]
187pub enum TermStreamEvent {
188    Chunk {
189        bytes: Vec<u8>,
190        screen: TerminalScreen,
191        state: TermState,
192    },
193    Exited {
194        exit_code: Option<i32>,
195    },
196}
197
198pub struct TermEntry {
199    pub handle: TermHandle,
200    pub session_id: String,
201    pub pty_size: portable_pty::PtySize,
202    pub parser: Arc<Mutex<vt100::Parser>>,
203    pub writer: Mutex<Box<dyn std::io::Write + Send>>,
204    pub state: Arc<Mutex<TermState>>,
205    pub stream_tx: broadcast::Sender<TermStreamEvent>,
206    pub log_path: PathBuf,
207    pub reader_task: Mutex<Option<JoinHandle<()>>>,
208    pub child: Mutex<Option<Box<dyn portable_pty::Child + Send + Sync>>>,
209    pub master: Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>,
210    pub started_at: Instant,
211    pub task_id: Mutex<Option<crate::task_registry::TaskId>>,
212}
213
214impl TermEntry {
215    pub fn snapshot(&self) -> TerminalScreen {
216        let parser = self.parser.lock().expect("parser poisoned");
217        snapshot_screen(&parser)
218    }
219
220    pub fn current_state(&self) -> TermState {
221        self.state.lock().expect("state poisoned").clone()
222    }
223
224    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), RuntimeError> {
225        {
226            let mut parser = self.parser.lock().expect("parser poisoned");
227            parser.screen_mut().set_size(rows, cols);
228        }
229        let master = self.master.lock().expect("master poisoned");
230        if let Some(master) = master.as_ref() {
231            master
232                .resize(portable_pty::PtySize {
233                    rows,
234                    cols,
235                    pixel_width: 0,
236                    pixel_height: 0,
237                })
238                .map_err(|e| RuntimeError::ToolFailed(format!("term resize: pty resize: {e}")))?;
239        }
240        Ok(())
241    }
242}
243
244#[derive(Default)]
245pub struct TermRegistry {
246    next_id: AtomicU64,
247    entries: Mutex<HashMap<String, Arc<TermEntry>>>,
248    task_registry: Option<crate::task_registry::TaskRegistry>,
249}
250
251impl TermRegistry {
252    pub fn new() -> Self {
253        Self::default()
254    }
255
256    pub fn with_task_registry(mut self, tr: crate::task_registry::TaskRegistry) -> Self {
257        self.task_registry = Some(tr);
258        self
259    }
260
261    pub fn next_handle(&self, session_id: &str) -> TermHandle {
262        let local_id = self.next_id.fetch_add(1, Ordering::Relaxed);
263        TermHandle {
264            session_id: session_id.to_string(),
265            local_id,
266        }
267    }
268
269    pub fn insert(&self, entry: Arc<TermEntry>) {
270        let key = entry.handle.to_string();
271        self.entries
272            .lock()
273            .expect("entries poisoned")
274            .insert(key, entry);
275    }
276
277    pub fn get(&self, handle_str: &str) -> Option<Arc<TermEntry>> {
278        self.entries
279            .lock()
280            .expect("entries poisoned")
281            .get(handle_str)
282            .cloned()
283    }
284
285    pub fn kill_all(&self) {
286        let entries = self.entries.lock().expect("entries poisoned");
287        for (_, entry) in entries.iter() {
288            let mut child = entry.child.lock().expect("child poisoned");
289            if let Some(child) = child.as_mut() {
290                let _ = child.kill();
291            }
292        }
293    }
294
295    pub fn lookup(
296        &self,
297        handle_str: &str,
298        session_id: &str,
299    ) -> Result<Arc<TermEntry>, RuntimeError> {
300        let handle = TermHandle::parse(handle_str).ok_or_else(|| {
301            RuntimeError::ToolFailed(format!("term: invalid handle: {handle_str}"))
302        })?;
303        if handle.session_id != session_id {
304            return Err(RuntimeError::ToolFailed(format!(
305                "term: handle {handle_str} does not belong to session {session_id}"
306            )));
307        }
308        self.get(handle_str).ok_or_else(|| {
309            RuntimeError::ToolFailed(format!("term: handle not found: {handle_str}"))
310        })
311    }
312
313    pub fn list(&self, session_id: &str) -> Vec<(String, TermState)> {
314        self.entries
315            .lock()
316            .expect("entries poisoned")
317            .iter()
318            .filter(|(_, e)| e.session_id == session_id)
319            .map(|(k, e)| (k.clone(), e.current_state()))
320            .collect()
321    }
322}
323
324fn now_ms() -> u64 {
325    std::time::SystemTime::now()
326        .duration_since(std::time::UNIX_EPOCH)
327        .map(|d| d.as_millis() as u64)
328        .unwrap_or(0)
329}
330
331const READ_BUF_SIZE: usize = 4096;
332
333impl TermRegistry {
334    #[allow(clippy::too_many_arguments)]
335    pub fn spawn_entry(
336        self: &Arc<Self>,
337        rows: u16,
338        cols: u16,
339        session_id: String,
340        session_dir: PathBuf,
341        pty_result: crate::sandbox::PtySpawnResult,
342        tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
343        label: String,
344        cancel: tokio_util::sync::CancellationToken,
345    ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError> {
346        let handle = self.next_handle(&session_id);
347        let handle_str = handle.to_string();
348
349        std::fs::create_dir_all(&session_dir).map_err(|e| {
350            RuntimeError::ToolFailed(format!("term.spawn: create session_dir: {e}"))
351        })?;
352        let log_path = session_dir.join(format!("term_{}.log", handle_str));
353
354        let parser = vt100::Parser::new(rows, cols, 0);
355        let parser = Arc::new(Mutex::new(parser));
356        let state = Arc::new(Mutex::new(TermState::Running {
357            pid: 0,
358            started_at: now_ms(),
359        }));
360        let (stream_tx, _stream_rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
361
362        let log_file = std::fs::OpenOptions::new()
363            .create(true)
364            .append(true)
365            .open(&log_path)
366            .map_err(|e| RuntimeError::ToolFailed(format!("term.spawn: open log: {e}")))?;
367
368        let entry = Arc::new(TermEntry {
369            handle: handle.clone(),
370            session_id: session_id.clone(),
371            pty_size: portable_pty::PtySize {
372                rows,
373                cols,
374                pixel_width: 0,
375                pixel_height: 0,
376            },
377            parser: parser.clone(),
378            writer: Mutex::new(pty_result.writer),
379            state: state.clone(),
380            stream_tx: stream_tx.clone(),
381            log_path: log_path.clone(),
382            reader_task: Mutex::new(None),
383            child: Mutex::new(Some(pty_result.child)),
384            master: Mutex::new(Some(pty_result.master)),
385            started_at: Instant::now(),
386            task_id: Mutex::new(None),
387        });
388
389        let kill_entry = entry.clone();
390        let task_id = self.task_registry.as_ref().map(|tr| {
391            let hook: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
392                let mut child = kill_entry.child.lock().expect("child poisoned");
393                if let Some(child) = child.as_mut() {
394                    let _ = child.kill();
395                }
396            });
397            tr.register_with_kill_hook(
398                crate::task_registry::TaskKind::Terminal,
399                label,
400                handle_str.clone(),
401                session_id.clone(),
402                cancel,
403                Some(hook),
404            )
405        });
406        *entry.task_id.lock().unwrap() = task_id.clone();
407
408        let reader = pty_result.reader;
409        let handle_for_loop = handle_str.clone();
410        let task_registry = self.task_registry.clone();
411        let join = tokio::task::spawn_blocking(move || {
412            run_reader_loop(
413                reader,
414                parser,
415                state,
416                stream_tx,
417                log_file,
418                tui_stream_tx,
419                handle_for_loop,
420                task_registry,
421                task_id,
422            );
423        });
424        *entry.reader_task.lock().expect("reader_task poisoned") = Some(join);
425
426        self.insert(entry.clone());
427        Ok((handle, entry))
428    }
429}
430
431impl Drop for TermRegistry {
432    fn drop(&mut self) {
433        self.kill_all();
434    }
435}
436
437#[allow(clippy::too_many_arguments)]
438fn run_reader_loop(
439    mut reader: Box<dyn std::io::Read + Send>,
440    parser: Arc<Mutex<vt100::Parser>>,
441    state: Arc<Mutex<TermState>>,
442    stream_tx: broadcast::Sender<TermStreamEvent>,
443    mut log_file: std::fs::File,
444    tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
445    handle: String,
446    task_registry: Option<crate::task_registry::TaskRegistry>,
447    task_id: Option<crate::task_registry::TaskId>,
448) {
449    let mut buf = [0u8; READ_BUF_SIZE];
450    let mut last_screen: Option<TerminalScreen> = None;
451    loop {
452        match reader.read(&mut buf) {
453            Ok(0) => break,
454            Ok(n) => {
455                let chunk = &buf[..n];
456                let _ = log_file.write_all(chunk);
457                let screen = {
458                    let mut p = parser.lock().expect("parser poisoned");
459                    p.process(chunk);
460                    snapshot_screen(&p)
461                };
462                let screen_changed = last_screen.as_ref() != Some(&screen);
463                let st = state.lock().expect("state poisoned").clone();
464                let _ = stream_tx.send(TermStreamEvent::Chunk {
465                    bytes: chunk.to_vec(),
466                    screen: screen.clone(),
467                    state: st.clone(),
468                });
469                if let Some(tx) = &tui_stream_tx {
470                    let tui_screen = if screen_changed {
471                        last_screen = Some(screen.clone());
472                        Some(screen)
473                    } else {
474                        None
475                    };
476                    let _ = tx.send(crate::stream::StreamFrame::TerminalChunk {
477                        handle: handle.clone(),
478                        bytes: chunk.to_vec(),
479                        screen: tui_screen,
480                        state: st.to_snapshot(),
481                    });
482                }
483            }
484            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
485            Err(_) => break,
486        }
487    }
488
489    let exit_code = None;
490    {
491        let mut s = state.lock().expect("state poisoned");
492        *s = TermState::Exited {
493            exit_code,
494            ended_at: now_ms(),
495        };
496    }
497    let _ = stream_tx.send(TermStreamEvent::Exited { exit_code });
498    if let Some(tx) = &tui_stream_tx {
499        let _ = tx.send(crate::stream::StreamFrame::TerminalExited { handle, exit_code });
500    }
501
502    if let (Some(tr), Some(tid)) = (task_registry, task_id) {
503        tr.finish(&tid, crate::task_registry::TaskStatus::Ok);
504    }
505}
506
507use std::io::Write;
508
509use std::path::Path;
510
511use crate::sandbox::PtySpawnResult;
512use crate::tool::{ApprovalLevel, Tier, Tool};
513use crate::value::Value;
514
515fn extract_string(
516    args: &crate::tool::ToolArgs,
517    name: &str,
518    pos: usize,
519) -> Result<String, RuntimeError> {
520    if let Some(v) = args.named(name) {
521        if let Value::Str(s) = v {
522            return Ok(s.clone());
523        }
524        return Err(RuntimeError::ToolFailed(format!(
525            "term: arg {name} must be string"
526        )));
527    }
528    if let Ok(Value::Str(s)) = args.positional(pos) {
529        return Ok(s.clone());
530    }
531    Err(RuntimeError::MissingArg(format!("term: {name}")))
532}
533
534fn extract_optional_string(args: &crate::tool::ToolArgs, name: &str) -> Option<String> {
535    args.named(name).and_then(|v| {
536        if let Value::Str(s) = v {
537            Some(s.clone())
538        } else {
539            None
540        }
541    })
542}
543
544fn extract_optional_int(args: &crate::tool::ToolArgs, name: &str) -> Option<i64> {
545    args.named(name).and_then(|v| {
546        if let Value::Int(i) = v {
547            Some(*i)
548        } else {
549            None
550        }
551    })
552}
553
554pub struct TermSpawn;
555
556impl Tool for TermSpawn {
557    fn name(&self) -> &str {
558        "term.spawn"
559    }
560    fn tier(&self) -> Tier {
561        Tier::Four
562    }
563    fn description(&self) -> Option<&str> {
564        Some(
565            "Spawn a PTY-backed interactive terminal. Supports TUI apps (vim, top, ssh, codex).\nReturns handle + state + dimensions. Does NOT return screen content — use\nterm.capture to read the screen.\n\nTypical flow:\n1. term.spawn(cmd: \"your command\", rows: 24, cols: 80)\n2. term.input(handle: \"...\", text: \"ls -la\") or key: \"enter\"\n3. term.capture(handle: \"...\") — returns screen as text by default\n4. Repeat 2-3 as needed\n5. term.kill(handle: \"...\")",
566        )
567    }
568    fn input_schema(&self) -> serde_json::Value {
569        serde_json::json!({
570            "type": "object",
571            "properties": {
572                "cmd": {"type": "string"},
573                "rows": {"type": "integer", "default": 24},
574                "cols": {"type": "integer", "default": 80},
575                "cwd": {"type": "string"},
576                "env": {"type": "object"}
577            }
578        })
579    }
580    fn call<'a>(
581        &'a self,
582        args: crate::tool::ToolArgs,
583        ctx: &'a crate::tool::ToolCtx,
584    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
585        Box::pin(async move { spawn_impl(args, ctx).await })
586    }
587}
588
589async fn spawn_impl(
590    args: crate::tool::ToolArgs,
591    ctx: &crate::tool::ToolCtx,
592) -> crate::tool::ToolResult {
593    let cmd_str = extract_optional_string(&args, "cmd");
594    let rows = extract_optional_int(&args, "rows")
595        .map(|v| v as u16)
596        .unwrap_or(DEFAULT_ROWS)
597        .max(1);
598    let cols = extract_optional_int(&args, "cols")
599        .map(|v| v as u16)
600        .unwrap_or(DEFAULT_COLS)
601        .max(2);
602    let cwd = extract_optional_string(&args, "cwd")
603        .map(std::path::PathBuf::from)
604        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
605    let env: Vec<(String, String)> = if let Some(Value::Struct(fields)) = args.named("env") {
606        fields
607            .iter()
608            .filter_map(|(k, v)| {
609                if let Value::Str(s) = v {
610                    Some((k.clone(), s.clone()))
611                } else {
612                    None
613                }
614            })
615            .collect()
616    } else {
617        Vec::new()
618    };
619
620    let registry = ctx
621        .term_registry
622        .clone()
623        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: registry not available".into()))?;
624    let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
625    let session_dir = ctx
626        .session_dir
627        .clone()
628        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: session_dir not available".into()))?;
629
630    let pty_size = portable_pty::PtySize {
631        rows,
632        cols,
633        pixel_width: 0,
634        pixel_height: 0,
635    };
636    let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
637    let cmd_args: Vec<&str> = if let Some(ref c) = cmd_str {
638        vec!["sh", "-c", c.as_str()]
639    } else {
640        vec![default_shell.as_str()]
641    };
642    let env_refs: Vec<(String, String)> = env.clone();
643
644    let pty_result = if let Some(sandbox) = &ctx.sandbox {
645        match sandbox
646            .spawn_pty(&cmd_args, &env_refs, &cwd, pty_size)
647            .await
648        {
649            Ok(r) => r,
650            Err(e) => {
651                let msg = e.to_string();
652                if msg.contains("Operation not permitted") || msg.contains("denied") {
653                    let outcome = crate::approval::request_approval(
654                        ctx,
655                        "term.spawn",
656                        "term.spawn",
657                        &args,
658                        ApprovalLevel::Dangerous,
659                        None,
660                    )
661                    .await;
662                    match outcome {
663                        crate::approval::ApprovalOutcome::Approve => {
664                            sandbox
665                                .spawn_pty_relaxed(&cmd_args, &env_refs, &cwd, pty_size)
666                                .await?
667                        }
668                        crate::approval::ApprovalOutcome::Deny { reason } => {
669                            return Err(RuntimeError::ToolFailed(format!(
670                                "term.spawn denied: {reason}"
671                            )));
672                        }
673                    }
674                } else {
675                    return Err(e);
676                }
677            }
678        }
679    } else {
680        spawn_pty_direct(&cmd_args, &env_refs, &cwd, pty_size)?
681    };
682
683    let (handle, entry) = registry.spawn_entry(
684        rows,
685        cols,
686        session_id,
687        session_dir,
688        pty_result,
689        ctx.stream_tx.clone(),
690        cmd_str.unwrap_or_else(|| "terminal".into()),
691        {
692            let tc = ctx.cancel.clone();
693            tc.child_token()
694        },
695    )?;
696
697    let state = entry.current_state();
698    let text = {
699        let parser = entry.parser.lock().expect("parser poisoned");
700        parser.screen().contents()
701    };
702    Ok(Value::Struct(vec![
703        ("handle".into(), Value::Str(handle.to_string())),
704        ("state".into(), state_to_value(&state)),
705        ("rows".into(), Value::Int(rows as i64)),
706        ("cols".into(), Value::Int(cols as i64)),
707        ("text".into(), Value::Str(text)),
708    ]))
709}
710
711fn spawn_pty_direct(
712    cmd: &[&str],
713    env: &[(String, String)],
714    cwd: &Path,
715    pty_size: portable_pty::PtySize,
716) -> Result<PtySpawnResult, RuntimeError> {
717    let pty_system = portable_pty::native_pty_system();
718    let pair = pty_system
719        .openpty(pty_size)
720        .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
721    let mut builder = portable_pty::CommandBuilder::new(cmd[0]);
722    for arg in &cmd[1..] {
723        builder.arg(arg);
724    }
725    builder.cwd(cwd);
726    for (k, v) in env {
727        builder.env(k, v);
728    }
729    let child = pair
730        .slave
731        .spawn_command(builder)
732        .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
733    let reader = pair
734        .master
735        .try_clone_reader()
736        .map_err(|e| RuntimeError::ToolFailed(format!("pty reader: {e}")))?;
737    let writer = pair
738        .master
739        .take_writer()
740        .map_err(|e| RuntimeError::ToolFailed(format!("pty writer: {e}")))?;
741    Ok(PtySpawnResult {
742        child,
743        reader,
744        writer,
745        master: pair.master,
746    })
747}
748
749fn screen_to_value(screen: &TerminalScreen) -> Value {
750    let cells: Vec<Value> = screen
751        .cells
752        .iter()
753        .map(|c| {
754            Value::Struct(vec![
755                ("chars".into(), Value::Str(c.chars.clone())),
756                ("fg".into(), color_to_value(c.fg)),
757                ("bg".into(), color_to_value(c.bg)),
758                ("bold".into(), Value::Bool(c.bold)),
759                ("italic".into(), Value::Bool(c.italic)),
760                ("underline".into(), Value::Bool(c.underline)),
761                ("inverse".into(), Value::Bool(c.inverse)),
762                ("dim".into(), Value::Bool(c.dim)),
763                ("wide".into(), Value::Bool(c.wide)),
764                ("wide_continuation".into(), Value::Bool(c.wide_continuation)),
765            ])
766        })
767        .collect();
768    Value::Struct(vec![
769        ("rows".into(), Value::Int(screen.rows as i64)),
770        ("cols".into(), Value::Int(screen.cols as i64)),
771        ("cells".into(), Value::List(cells)),
772        (
773            "cursor".into(),
774            match screen.cursor {
775                Some((r, c)) => Value::Struct(vec![
776                    ("row".into(), Value::Int(r as i64)),
777                    ("col".into(), Value::Int(c as i64)),
778                ]),
779                None => Value::Unit,
780            },
781        ),
782        ("alt_screen".into(), Value::Bool(screen.alt_screen)),
783    ])
784}
785
786fn color_to_value(c: TerminalColor) -> Value {
787    match c {
788        TerminalColor::Default => Value::Str("default".into()),
789        TerminalColor::Idx(i) => Value::Int(i as i64),
790        TerminalColor::Rgb(r, g, b) => Value::Struct(vec![
791            ("r".into(), Value::Int(r as i64)),
792            ("g".into(), Value::Int(g as i64)),
793            ("b".into(), Value::Int(b as i64)),
794        ]),
795    }
796}
797
798fn state_to_value(state: &TermState) -> Value {
799    match state {
800        TermState::Running { pid, started_at } => Value::Struct(vec![
801            ("kind".into(), Value::Str("running".into())),
802            ("pid".into(), Value::Int(*pid as i64)),
803            ("started_at".into(), Value::Int(*started_at as i64)),
804        ]),
805        TermState::Exited {
806            exit_code,
807            ended_at,
808        } => Value::Struct(vec![
809            ("kind".into(), Value::Str("exited".into())),
810            (
811                "exit_code".into(),
812                exit_code
813                    .map(|c| Value::Int(c as i64))
814                    .unwrap_or(Value::Unit),
815            ),
816            ("ended_at".into(), Value::Int(*ended_at as i64)),
817        ]),
818        TermState::Failed { error, ended_at } => Value::Struct(vec![
819            ("kind".into(), Value::Str("failed".into())),
820            ("error".into(), Value::Str(error.clone())),
821            ("ended_at".into(), Value::Int(*ended_at as i64)),
822        ]),
823        TermState::Killed { ended_at } => Value::Struct(vec![
824            ("kind".into(), Value::Str("killed".into())),
825            ("ended_at".into(), Value::Int(*ended_at as i64)),
826        ]),
827    }
828}
829
830pub struct TermInput;
831impl Tool for TermInput {
832    fn name(&self) -> &str {
833        "term.input"
834    }
835    fn tier(&self) -> Tier {
836        Tier::Four
837    }
838    fn description(&self) -> Option<&str> {
839        Some(
840            "Send input to a terminal's PTY. Use `text` for literal text, or `key` for\nspecial keys (enter, tab, esc, backspace, up, down, left, right, ctrl+c,\nctrl+d, ctrl+z). Use key: \"enter\" to submit a command, not text: \"\\r\".",
841        )
842    }
843    fn input_schema(&self) -> serde_json::Value {
844        serde_json::json!({
845            "type": "object",
846            "properties": {
847                "handle": {"type": "string"},
848                "text": {"type": "string", "description": "Literal text to write. Do NOT use \\r or \\n here — use key:\"enter\" instead."},
849                "key": {"type": "string", "enum": ["enter", "tab", "esc", "backspace", "up", "down", "left", "right", "ctrl+c", "ctrl+d", "ctrl+z"]}
850            },
851            "required": ["handle"]
852        })
853    }
854    fn call<'a>(
855        &'a self,
856        args: crate::tool::ToolArgs,
857        ctx: &'a crate::tool::ToolCtx,
858    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
859        Box::pin(async move {
860            let handle = extract_string(&args, "handle", 0)?;
861            let text = extract_optional_string(&args, "text").unwrap_or_default();
862            let key = extract_optional_string(&args, "key");
863            let registry = ctx.term_registry.clone().ok_or_else(|| {
864                RuntimeError::ToolFailed("term.input: registry not available".into())
865            })?;
866            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
867            let entry = registry.lookup(&handle, &session_id)?;
868
869            let mut payload = text.into_bytes();
870            if let Some(k) = &key {
871                payload.extend_from_slice(&key_to_bytes(k));
872            }
873            if payload.is_empty() {
874                return Err(RuntimeError::ToolFailed(
875                    "term.input: provide at least one of `text` or `key`".into(),
876                ));
877            }
878
879            let n = {
880                let mut w = entry.writer.lock().expect("writer poisoned");
881                w.write_all(&payload)
882                    .map_err(|e| RuntimeError::ToolFailed(format!("term.input write: {e}")))?;
883                payload.len()
884            };
885            Ok(Value::Struct(vec![
886                ("ok".into(), Value::Bool(true)),
887                ("bytes_written".into(), Value::Int(n as i64)),
888            ]))
889        })
890    }
891}
892
893fn key_to_bytes(key: &str) -> Vec<u8> {
894    match key {
895        "enter" => vec![b'\r'],
896        "tab" => vec![b'\t'],
897        "esc" => vec![0x1b],
898        "backspace" => vec![0x7f],
899        "up" => vec![0x1b, b'[', b'A'],
900        "down" => vec![0x1b, b'[', b'B'],
901        "right" => vec![0x1b, b'[', b'C'],
902        "left" => vec![0x1b, b'[', b'D'],
903        "ctrl+c" => vec![0x03],
904        "ctrl+d" => vec![0x04],
905        "ctrl+z" => vec![0x1a],
906        _ => Vec::new(),
907    }
908}
909
910pub struct TermCapture;
911impl Tool for TermCapture {
912    fn name(&self) -> &str {
913        "term.capture"
914    }
915    fn tier(&self) -> Tier {
916        Tier::Four
917    }
918    fn description(&self) -> Option<&str> {
919        Some(
920            "Read the terminal screen. Default returns plain text (format: \"text\").\nUse start_row/end_row to read only part of the screen — e.g. last 5 rows\nto check the prompt or command output without wasting context.\n\nBest practices:\n- After sending a command, capture to see the result.\n- Use start_row/end_row to read only the relevant part (e.g. last 10 rows).\n- format: \"screen\" returns full cell data with colors/styles — rarely needed,\n  only use when you need to inspect TUI layout or colors.\n- Default format: \"text\" is sufficient for most cases (reading command output,\n  checking prompts, seeing error messages).",
921        )
922    }
923    fn input_schema(&self) -> serde_json::Value {
924        serde_json::json!({
925            "type": "object",
926            "properties": {
927                "handle": {"type": "string"},
928                "format": {"type": "string", "enum": ["text", "screen"], "default": "text"},
929                "start_row": {"type": "integer", "default": 0, "description": "Start row (0-based). Default 0."},
930                "end_row": {"type": "integer", "description": "End row (exclusive). Default: full height."}
931            },
932            "required": ["handle"]
933        })
934    }
935    fn call<'a>(
936        &'a self,
937        args: crate::tool::ToolArgs,
938        ctx: &'a crate::tool::ToolCtx,
939    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
940        Box::pin(async move {
941            let handle = extract_string(&args, "handle", 0)?;
942            let format = extract_optional_string(&args, "format").unwrap_or_else(|| "text".into());
943            let registry = ctx.term_registry.clone().ok_or_else(|| {
944                RuntimeError::ToolFailed("term.capture: registry not available".into())
945            })?;
946            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
947            let entry = registry.lookup(&handle, &session_id)?;
948            let screen = entry.snapshot();
949            let start_row = extract_optional_int(&args, "start_row")
950                .unwrap_or(0)
951                .clamp(0, screen.rows as i64) as u16;
952            let end_row = extract_optional_int(&args, "end_row")
953                .unwrap_or(screen.rows as i64)
954                .clamp(start_row as i64, screen.rows as i64) as u16;
955            let state = entry.current_state();
956            let mut fields = vec![
957                ("handle".into(), Value::Str(handle.clone())),
958                ("state".into(), state_to_value(&state)),
959                ("rows".into(), Value::Int(screen.rows as i64)),
960                ("cols".into(), Value::Int(screen.cols as i64)),
961            ];
962            if format == "screen" {
963                let cols = screen.cols as usize;
964                let start = start_row as usize * cols;
965                let end = end_row as usize * cols;
966                let cursor = screen.cursor.and_then(|(row, col)| {
967                    (start_row..end_row)
968                        .contains(&row)
969                        .then_some((row - start_row, col))
970                });
971                let partial_screen = TerminalScreen {
972                    rows: end_row - start_row,
973                    cols: screen.cols,
974                    cells: screen.cells[start..end].to_vec(),
975                    cursor,
976                    alt_screen: screen.alt_screen,
977                };
978                fields[2] = ("rows".into(), Value::Int(partial_screen.rows as i64));
979                fields.push(("screen".into(), screen_to_value(&partial_screen)));
980            } else {
981                let parser = entry.parser.lock().expect("parser poisoned");
982                let text = parser
983                    .screen()
984                    .rows(0, screen.cols)
985                    .skip(start_row as usize)
986                    .take((end_row - start_row) as usize)
987                    .collect::<Vec<_>>()
988                    .join("\n");
989                fields.push(("text".into(), Value::Str(text)));
990            }
991            Ok(Value::Struct(fields))
992        })
993    }
994}
995
996pub struct TermResize;
997impl Tool for TermResize {
998    fn name(&self) -> &str {
999        "term.resize"
1000    }
1001    fn tier(&self) -> Tier {
1002        Tier::Four
1003    }
1004    fn description(&self) -> Option<&str> {
1005        Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1006    }
1007    fn input_schema(&self) -> serde_json::Value {
1008        serde_json::json!({
1009            "type": "object",
1010            "properties": {
1011                "handle": {"type": "string"},
1012                "rows": {"type": "integer"},
1013                "cols": {"type": "integer"}
1014            },
1015            "required": ["handle", "rows", "cols"]
1016        })
1017    }
1018    fn call<'a>(
1019        &'a self,
1020        args: crate::tool::ToolArgs,
1021        ctx: &'a crate::tool::ToolCtx,
1022    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1023        Box::pin(async move {
1024            let handle = extract_string(&args, "handle", 0)?;
1025            let rows = extract_optional_int(&args, "rows")
1026                .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1027                as u16;
1028            let cols = extract_optional_int(&args, "cols")
1029                .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1030                as u16;
1031            let registry = ctx.term_registry.clone().ok_or_else(|| {
1032                RuntimeError::ToolFailed("term.resize: registry not available".into())
1033            })?;
1034            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1035            let entry = registry.lookup(&handle, &session_id)?;
1036            entry.resize(rows, cols)?;
1037            Ok(Value::Struct(vec![
1038                ("ok".into(), Value::Bool(true)),
1039                ("rows".into(), Value::Int(rows as i64)),
1040                ("cols".into(), Value::Int(cols as i64)),
1041            ]))
1042        })
1043    }
1044}
1045
1046pub struct TermKill;
1047impl Tool for TermKill {
1048    fn name(&self) -> &str {
1049        "term.kill"
1050    }
1051    fn tier(&self) -> Tier {
1052        Tier::Four
1053    }
1054    fn description(&self) -> Option<&str> {
1055        Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1056    }
1057    fn input_schema(&self) -> serde_json::Value {
1058        serde_json::json!({
1059            "type": "object",
1060            "properties": {"handle": {"type": "string"}},
1061            "required": ["handle"]
1062        })
1063    }
1064    fn call<'a>(
1065        &'a self,
1066        args: crate::tool::ToolArgs,
1067        ctx: &'a crate::tool::ToolCtx,
1068    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1069        Box::pin(async move {
1070            let handle = extract_string(&args, "handle", 0)?;
1071            let registry = ctx.term_registry.clone().ok_or_else(|| {
1072                RuntimeError::ToolFailed("term.kill: registry not available".into())
1073            })?;
1074            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1075            let entry = registry.lookup(&handle, &session_id)?;
1076            {
1077                let mut child = entry.child.lock().expect("child poisoned");
1078                if let Some(child) = child.as_mut() {
1079                    let _ = child.kill();
1080                }
1081            }
1082            {
1083                let mut state = entry.state.lock().expect("state poisoned");
1084                *state = TermState::Killed { ended_at: now_ms() };
1085            }
1086            Ok(Value::Struct(vec![
1087                ("ok".into(), Value::Bool(true)),
1088                ("state".into(), Value::Str("killed".into())),
1089            ]))
1090        })
1091    }
1092}
1093
1094pub struct TermList;
1095impl Tool for TermList {
1096    fn name(&self) -> &str {
1097        "term.list"
1098    }
1099    fn tier(&self) -> Tier {
1100        Tier::Four
1101    }
1102    fn description(&self) -> Option<&str> {
1103        Some("List all terminal handles in the current session.")
1104    }
1105    fn input_schema(&self) -> serde_json::Value {
1106        serde_json::json!({
1107            "type": "object",
1108            "properties": {"all": {"type": "boolean", "default": false}}
1109        })
1110    }
1111    fn call<'a>(
1112        &'a self,
1113        args: crate::tool::ToolArgs,
1114        ctx: &'a crate::tool::ToolCtx,
1115    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1116        Box::pin(async move {
1117            let all = args
1118                .named("all")
1119                .and_then(|v| {
1120                    if let Value::Bool(b) = v {
1121                        Some(*b)
1122                    } else {
1123                        None
1124                    }
1125                })
1126                .unwrap_or(false);
1127            let registry = ctx.term_registry.clone().ok_or_else(|| {
1128                RuntimeError::ToolFailed("term.list: registry not available".into())
1129            })?;
1130            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1131            let _ = all;
1132            let list = registry.list(&session_id);
1133            let entries: Vec<Value> = list
1134                .iter()
1135                .map(|(h, st)| {
1136                    Value::Struct(vec![
1137                        ("handle".into(), Value::Str(h.clone())),
1138                        ("state".into(), state_to_value(st)),
1139                    ])
1140                })
1141                .collect();
1142            Ok(Value::Struct(vec![(
1143                "terminals".into(),
1144                Value::List(entries),
1145            )]))
1146        })
1147    }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152    use super::*;
1153
1154    #[test]
1155    fn handle_parse_roundtrip() {
1156        let h = TermHandle {
1157            session_id: "abc".into(),
1158            local_id: 7,
1159        };
1160        assert_eq!(h.to_string(), "term_abc_7");
1161        let back = TermHandle::parse("term_abc_7").unwrap();
1162        assert_eq!(back, h);
1163    }
1164
1165    #[test]
1166    fn handle_parse_rejects_bad_format() {
1167        assert!(TermHandle::parse("not_term").is_none());
1168        assert!(TermHandle::parse("term_nosuffix").is_none());
1169        assert!(TermHandle::parse("term_x_notnum").is_none());
1170    }
1171
1172    #[test]
1173    fn snapshot_screen_captures_text() {
1174        let mut parser = vt100::Parser::new(3, 5, 0);
1175        parser.process(b"hello");
1176        let screen = snapshot_screen(&parser);
1177        assert_eq!(screen.rows, 3);
1178        assert_eq!(screen.cols, 5);
1179        assert_eq!(screen.cells.len(), 15);
1180        assert_eq!(screen.cells[0].chars, "h");
1181        assert_eq!(screen.cells[4].chars, "o");
1182    }
1183
1184    #[test]
1185    fn registry_lookup_rejects_cross_session() {
1186        let registry = Arc::new(TermRegistry::new());
1187        let h = registry.next_handle("session_a");
1188        let entry = Arc::new(TermEntry {
1189            handle: h.clone(),
1190            session_id: "session_a".into(),
1191            pty_size: portable_pty::PtySize {
1192                rows: 24,
1193                cols: 80,
1194                pixel_width: 0,
1195                pixel_height: 0,
1196            },
1197            parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
1198            writer: Mutex::new(Box::new(std::io::sink())),
1199            state: Arc::new(Mutex::new(TermState::Running {
1200                pid: 0,
1201                started_at: 0,
1202            })),
1203            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
1204            log_path: std::env::temp_dir().join("term_test_dummy.log"),
1205            reader_task: Mutex::new(None),
1206            child: Mutex::new(None),
1207            master: Mutex::new(None),
1208            started_at: Instant::now(),
1209            task_id: Mutex::new(None),
1210        });
1211        registry.insert(entry);
1212        assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
1213        assert!(registry.lookup(&h.to_string(), "session_b").is_err());
1214    }
1215}