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::{Arc, Mutex};
6use std::time::Instant;
7
8use tokio::sync::broadcast;
9use tokio::task::JoinHandle;
10
11use crate::error::RuntimeError;
12
13pub const DEFAULT_ROWS: u16 = 24;
14pub const DEFAULT_COLS: u16 = 80;
15const STREAM_CHANNEL_CAPACITY: usize = 256;
16
17#[derive(Debug, Clone, Hash, PartialEq, Eq)]
18pub struct TermHandle {
19    pub session_id: String,
20    pub local_id: u64,
21}
22
23impl TermHandle {
24    pub fn parse(s: &str) -> Option<Self> {
25        let rest = s.strip_prefix("term_")?;
26        let idx = rest.rfind('_')?;
27        let session_id = rest[..idx].to_string();
28        let local_id = rest[idx + 1..].parse().ok()?;
29        Some(Self {
30            session_id,
31            local_id,
32        })
33    }
34}
35
36impl std::fmt::Display for TermHandle {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "term_{}_{}", self.session_id, self.local_id)
39    }
40}
41
42#[derive(Debug, Clone)]
43pub enum TermState {
44    Running {
45        pid: u32,
46        started_at: u64,
47    },
48    Exited {
49        exit_code: Option<i32>,
50        ended_at: u64,
51    },
52    Failed {
53        error: String,
54        ended_at: u64,
55    },
56    Killed {
57        ended_at: u64,
58    },
59}
60
61impl TermState {
62    pub fn is_running(&self) -> bool {
63        matches!(self, TermState::Running { .. })
64    }
65
66    pub fn to_snapshot(&self) -> TermStateSnapshot {
67        match self {
68            TermState::Running { .. } => TermStateSnapshot::Running,
69            TermState::Exited { exit_code, .. } => TermStateSnapshot::Exited {
70                exit_code: *exit_code,
71            },
72            TermState::Failed { error, .. } => TermStateSnapshot::Failed {
73                error: error.clone(),
74            },
75            TermState::Killed { .. } => TermStateSnapshot::Killed,
76        }
77    }
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81#[serde(tag = "kind")]
82pub enum TermStateSnapshot {
83    Running,
84    Exited { exit_code: Option<i32> },
85    Failed { error: String },
86    Killed,
87}
88
89#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct TerminalScreen {
91    pub rows: u16,
92    pub cols: u16,
93    pub cells: Vec<TerminalCell>,
94    pub cursor: Option<(u16, u16)>,
95    pub alt_screen: bool,
96}
97
98#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
99pub struct TerminalCell {
100    pub chars: String,
101    pub fg: TerminalColor,
102    pub bg: TerminalColor,
103    pub bold: bool,
104    pub italic: bool,
105    pub underline: bool,
106    pub inverse: bool,
107    pub dim: bool,
108    pub wide: bool,
109    #[serde(default)]
110    pub wide_continuation: bool,
111}
112
113impl Default for TerminalCell {
114    fn default() -> Self {
115        Self {
116            chars: String::new(),
117            fg: TerminalColor::Default,
118            bg: TerminalColor::Default,
119            bold: false,
120            italic: false,
121            underline: false,
122            inverse: false,
123            dim: false,
124            wide: false,
125            wide_continuation: false,
126        }
127    }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub enum TerminalColor {
132    Default,
133    Idx(u8),
134    Rgb(u8, u8, u8),
135}
136
137impl From<vt100::Color> for TerminalColor {
138    fn from(c: vt100::Color) -> Self {
139        match c {
140            vt100::Color::Default => TerminalColor::Default,
141            vt100::Color::Idx(i) => TerminalColor::Idx(i),
142            vt100::Color::Rgb(r, g, b) => TerminalColor::Rgb(r, g, b),
143        }
144    }
145}
146
147pub fn snapshot_screen(parser: &vt100::Parser) -> TerminalScreen {
148    let screen = parser.screen();
149    let (rows, cols) = screen.size();
150    let mut cells = Vec::with_capacity(rows as usize * cols as usize);
151    for row in 0..rows {
152        for col in 0..cols {
153            if let Some(c) = screen.cell(row, col) {
154                cells.push(TerminalCell {
155                    chars: c.contents().to_string(),
156                    fg: c.fgcolor().into(),
157                    bg: c.bgcolor().into(),
158                    bold: c.bold(),
159                    italic: c.italic(),
160                    underline: c.underline(),
161                    inverse: c.inverse(),
162                    dim: c.dim(),
163                    wide: c.is_wide(),
164                    wide_continuation: c.is_wide_continuation(),
165                });
166            } else {
167                cells.push(TerminalCell::default());
168            }
169        }
170    }
171    let cursor = if screen.hide_cursor() {
172        None
173    } else {
174        Some(screen.cursor_position())
175    };
176    TerminalScreen {
177        rows,
178        cols,
179        cells,
180        cursor,
181        alt_screen: screen.alternate_screen(),
182    }
183}
184
185/// Parse a string containing ANSI escape sequences into a [TerminalScreen].
186///
187/// Used by the TUI to render ` ```ansi ` code blocks with real colors.
188/// cols is sized to the widest line (clamped 80..=256) so nothing wraps.
189///
190/// `\n` is upgraded to `\r\n` because vt100 treats bare LF as "line feed"
191/// (move down, keep column) — most ANSI output expects carriage return too.
192/// Trailing newlines are stripped so they don't trigger a scroll that
193/// pushes the first line off the visible screen.
194pub fn parse_ansi_to_screen(body: &str) -> TerminalScreen {
195    let body = body.strip_suffix('\n').unwrap_or(body);
196    let lines: Vec<&str> = body.split('\n').collect();
197    let rows = lines.len().max(1) as u16;
198    let cols = lines
199        .iter()
200        .map(|l| l.chars().count())
201        .max()
202        .unwrap_or(0)
203        .clamp(80, 256) as u16;
204    let mut parser = vt100::Parser::new(rows, cols, 0);
205    for (i, line) in lines.iter().enumerate() {
206        if i > 0 {
207            parser.process(b"\r\n");
208        }
209        parser.process(line.as_bytes());
210    }
211    snapshot_screen(&parser)
212}
213
214#[derive(Debug, Clone)]
215pub enum TermStreamEvent {
216    Chunk {
217        bytes: Vec<u8>,
218        screen: TerminalScreen,
219        state: TermState,
220    },
221    Exited {
222        exit_code: Option<i32>,
223    },
224}
225
226pub struct TermEntry {
227    pub handle: TermHandle,
228    pub session_id: String,
229    pub pty_size: portable_pty::PtySize,
230    pub parser: Arc<Mutex<vt100::Parser>>,
231    pub writer: Mutex<Box<dyn std::io::Write + Send>>,
232    pub state: Arc<Mutex<TermState>>,
233    pub stream_tx: broadcast::Sender<TermStreamEvent>,
234    pub log_path: PathBuf,
235    pub reader_task: Mutex<Option<JoinHandle<()>>>,
236    pub child: Mutex<Option<Box<dyn portable_pty::Child + Send + Sync>>>,
237    pub master: Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>,
238    pub started_at: Instant,
239    pub task_id: Mutex<Option<crate::task_registry::TaskId>>,
240}
241
242impl TermEntry {
243    pub fn snapshot(&self) -> TerminalScreen {
244        let parser = self.parser.lock().expect("parser poisoned");
245        snapshot_screen(&parser)
246    }
247
248    pub fn current_state(&self) -> TermState {
249        self.state.lock().expect("state poisoned").clone()
250    }
251
252    pub fn resize(&self, rows: u16, cols: u16) -> Result<(), RuntimeError> {
253        {
254            let mut parser = self.parser.lock().expect("parser poisoned");
255            parser.screen_mut().set_size(rows, cols);
256        }
257        let master = self.master.lock().expect("master poisoned");
258        if let Some(master) = master.as_ref() {
259            master
260                .resize(portable_pty::PtySize {
261                    rows,
262                    cols,
263                    pixel_width: 0,
264                    pixel_height: 0,
265                })
266                .map_err(|e| RuntimeError::ToolFailed(format!("term resize: pty resize: {e}")))?;
267        }
268        Ok(())
269    }
270}
271
272#[derive(Default)]
273pub struct TermRegistry {
274    entries: Mutex<HashMap<String, Arc<TermEntry>>>,
275    task_registry: Option<crate::task_registry::TaskRegistry>,
276}
277
278impl TermRegistry {
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    pub fn with_task_registry(mut self, tr: crate::task_registry::TaskRegistry) -> Self {
284        self.task_registry = Some(tr);
285        self
286    }
287
288    pub fn next_handle(&self, session_id: &str) -> TermHandle {
289        let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
290        TermHandle {
291            session_id: session_id.to_string(),
292            local_id,
293        }
294    }
295
296    pub fn insert(&self, entry: Arc<TermEntry>) {
297        let key = entry.handle.to_string();
298        self.entries
299            .lock()
300            .expect("entries poisoned")
301            .insert(key, entry);
302    }
303
304    pub fn get(&self, handle_str: &str) -> Option<Arc<TermEntry>> {
305        self.entries
306            .lock()
307            .expect("entries poisoned")
308            .get(handle_str)
309            .cloned()
310    }
311
312    pub fn kill_all(&self) {
313        let entries = self.entries.lock().expect("entries poisoned");
314        for (_, entry) in entries.iter() {
315            let mut child = entry.child.lock().expect("child poisoned");
316            if let Some(child) = child.as_mut() {
317                let _ = child.kill();
318            }
319        }
320    }
321
322    pub fn lookup(
323        &self,
324        handle_str: &str,
325        session_id: &str,
326    ) -> Result<Arc<TermEntry>, RuntimeError> {
327        let handle = TermHandle::parse(handle_str).ok_or_else(|| {
328            RuntimeError::ToolFailed(format!("term: invalid handle: {handle_str}"))
329        })?;
330        if handle.session_id != session_id {
331            return Err(RuntimeError::ToolFailed(format!(
332                "term: handle {handle_str} does not belong to session {session_id}"
333            )));
334        }
335        self.get(handle_str).ok_or_else(|| {
336            RuntimeError::ToolFailed(format!("term: handle not found: {handle_str}"))
337        })
338    }
339
340    pub fn list(&self, session_id: &str) -> Vec<(String, TermState)> {
341        self.entries
342            .lock()
343            .expect("entries poisoned")
344            .iter()
345            .filter(|(_, e)| e.session_id == session_id)
346            .map(|(k, e)| (k.clone(), e.current_state()))
347            .collect()
348    }
349}
350
351fn now_ms() -> u64 {
352    std::time::SystemTime::now()
353        .duration_since(std::time::UNIX_EPOCH)
354        .map(|d| d.as_millis() as u64)
355        .unwrap_or(0)
356}
357
358const READ_BUF_SIZE: usize = 4096;
359
360impl TermRegistry {
361    #[allow(clippy::too_many_arguments)]
362    pub fn spawn_entry(
363        self: &Arc<Self>,
364        rows: u16,
365        cols: u16,
366        session_id: String,
367        session_dir: PathBuf,
368        pty_result: crate::sandbox::PtySpawnResult,
369        tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
370        label: String,
371        cancel: tokio_util::sync::CancellationToken,
372        events: Option<crate::event::EventSink>,
373        flow_run_id: Option<String>,
374    ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError> {
375        let handle = self.next_handle(&session_id);
376        let handle_str = handle.to_string();
377
378        std::fs::create_dir_all(&session_dir).map_err(|e| {
379            RuntimeError::ToolFailed(format!("term.spawn: create session_dir: {e}"))
380        })?;
381        let log_path = session_dir.join(format!("term_{}.log", handle_str));
382
383        let parser = vt100::Parser::new(rows, cols, 0);
384        let parser = Arc::new(Mutex::new(parser));
385        let state = Arc::new(Mutex::new(TermState::Running {
386            pid: 0,
387            started_at: now_ms(),
388        }));
389        let (stream_tx, _stream_rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
390
391        let log_file = std::fs::OpenOptions::new()
392            .create(true)
393            .append(true)
394            .open(&log_path)
395            .map_err(|e| RuntimeError::ToolFailed(format!("term.spawn: open log: {e}")))?;
396
397        let entry = Arc::new(TermEntry {
398            handle: handle.clone(),
399            session_id: session_id.clone(),
400            pty_size: portable_pty::PtySize {
401                rows,
402                cols,
403                pixel_width: 0,
404                pixel_height: 0,
405            },
406            parser: parser.clone(),
407            writer: Mutex::new(pty_result.writer),
408            state: state.clone(),
409            stream_tx: stream_tx.clone(),
410            log_path: log_path.clone(),
411            reader_task: Mutex::new(None),
412            child: Mutex::new(Some(pty_result.child)),
413            master: Mutex::new(Some(pty_result.master)),
414            started_at: Instant::now(),
415            task_id: Mutex::new(None),
416        });
417
418        let kill_entry = entry.clone();
419        let task_id = self.task_registry.as_ref().map(|tr| {
420            let hook: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
421                let mut child = kill_entry.child.lock().expect("child poisoned");
422                if let Some(child) = child.as_mut() {
423                    let _ = child.kill();
424                }
425            });
426            tr.register_with_kill_hook(
427                crate::task_registry::TaskKind::Terminal,
428                label,
429                handle_str.clone(),
430                session_id.clone(),
431                cancel,
432                Some(hook),
433            )
434        });
435        *entry.task_id.lock().unwrap() = task_id.clone();
436
437        let reader = pty_result.reader;
438        let handle_for_loop = handle_str.clone();
439        let task_registry = self.task_registry.clone();
440        let join = tokio::task::spawn_blocking(move || {
441            run_reader_loop(
442                reader,
443                parser,
444                state,
445                stream_tx,
446                log_file,
447                tui_stream_tx,
448                handle_for_loop,
449                task_registry,
450                task_id,
451                events,
452                flow_run_id,
453            );
454        });
455        *entry.reader_task.lock().expect("reader_task poisoned") = Some(join);
456
457        self.insert(entry.clone());
458        Ok((handle, entry))
459    }
460}
461
462impl Drop for TermRegistry {
463    fn drop(&mut self) {
464        self.kill_all();
465    }
466}
467
468#[allow(clippy::too_many_arguments)]
469fn run_reader_loop(
470    mut reader: Box<dyn std::io::Read + Send>,
471    parser: Arc<Mutex<vt100::Parser>>,
472    state: Arc<Mutex<TermState>>,
473    stream_tx: broadcast::Sender<TermStreamEvent>,
474    mut log_file: std::fs::File,
475    tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
476    handle: String,
477    task_registry: Option<crate::task_registry::TaskRegistry>,
478    task_id: Option<crate::task_registry::TaskId>,
479    events_sink: Option<crate::event::EventSink>,
480    flow_run_id: Option<String>,
481) {
482    let mut buf = [0u8; READ_BUF_SIZE];
483    let mut last_screen: Option<TerminalScreen> = None;
484    loop {
485        match reader.read(&mut buf) {
486            Ok(0) => break,
487            Ok(n) => {
488                let chunk = &buf[..n];
489                let _ = log_file.write_all(chunk);
490                let screen = {
491                    let mut p = parser.lock().expect("parser poisoned");
492                    p.process(chunk);
493                    snapshot_screen(&p)
494                };
495                let screen_changed = last_screen.as_ref() != Some(&screen);
496                let st = state.lock().expect("state poisoned").clone();
497                let _ = stream_tx.send(TermStreamEvent::Chunk {
498                    bytes: chunk.to_vec(),
499                    screen: screen.clone(),
500                    state: st.clone(),
501                });
502                if let Some(tx) = &tui_stream_tx {
503                    let tui_screen = if screen_changed {
504                        last_screen = Some(screen.clone());
505                        Some(screen)
506                    } else {
507                        None
508                    };
509                    let _ = tx.send(crate::stream::StreamFrame::TerminalChunk {
510                        handle: handle.clone(),
511                        bytes: chunk.to_vec(),
512                        screen: tui_screen,
513                        state: st.to_snapshot(),
514                        run_id: flow_run_id.clone(),
515                    });
516                }
517            }
518            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
519            Err(_) => break,
520        }
521    }
522
523    let exit_code = None;
524    {
525        let mut s = state.lock().expect("state poisoned");
526        // Only transition to Exited if not already Killed — term.kill sets
527        // Killed before the reader loop observes the closed PTY.
528        if matches!(*s, TermState::Running { .. }) {
529            *s = TermState::Exited {
530                exit_code,
531                ended_at: now_ms(),
532            };
533        }
534    }
535
536    // Persist the last screen state before notifying exit — restore reads this
537    // to recover the full cell grid (with colors) that stream frames never wrote.
538    if let Some(sink) = &events_sink {
539        let (final_screen, final_state) = {
540            let p = parser.lock().expect("parser poisoned");
541            let screen = snapshot_screen(&p);
542            let st = state.lock().expect("state poisoned").to_snapshot();
543            (screen, st)
544        };
545        sink.emit(crate::event::Event::TerminalFinalState {
546            handle: handle.clone(),
547            screen: final_screen,
548            state: final_state,
549        });
550    }
551
552    let _ = stream_tx.send(TermStreamEvent::Exited { exit_code });
553    if let Some(tx) = &tui_stream_tx {
554        let _ = tx.send(crate::stream::StreamFrame::TerminalExited {
555            handle,
556            exit_code,
557            run_id: flow_run_id,
558        });
559    }
560
561    if let (Some(tr), Some(tid)) = (task_registry, task_id) {
562        tr.finish(&tid, crate::task_registry::TaskStatus::Ok);
563    }
564}
565
566use std::io::Write;
567
568use std::path::Path;
569
570use crate::sandbox::PtySpawnResult;
571use crate::tool::{ApprovalLevel, Tier, Tool};
572use crate::value::Value;
573
574fn extract_string(
575    args: &crate::tool::ToolArgs,
576    name: &str,
577    pos: usize,
578) -> Result<String, RuntimeError> {
579    if let Some(v) = args.named(name) {
580        if let Value::Str(s) = v {
581            return Ok(s.clone());
582        }
583        return Err(RuntimeError::ToolFailed(format!(
584            "term: arg {name} must be string"
585        )));
586    }
587    if let Ok(Value::Str(s)) = args.positional(pos) {
588        return Ok(s.clone());
589    }
590    Err(RuntimeError::MissingArg(format!("term: {name}")))
591}
592
593fn extract_optional_string(args: &crate::tool::ToolArgs, name: &str) -> Option<String> {
594    args.named(name).and_then(|v| {
595        if let Value::Str(s) = v {
596            Some(s.clone())
597        } else {
598            None
599        }
600    })
601}
602
603fn extract_optional_int(args: &crate::tool::ToolArgs, name: &str) -> Option<i64> {
604    args.named(name).and_then(|v| {
605        if let Value::Int(i) = v {
606            Some(*i)
607        } else {
608            None
609        }
610    })
611}
612
613pub struct TermSpawn;
614
615impl Tool for TermSpawn {
616    fn name(&self) -> &str {
617        "term.spawn"
618    }
619    fn tier(&self) -> Tier {
620        Tier::Four
621    }
622    fn description(&self) -> Option<&str> {
623        Some(
624            "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: \"...\")",
625        )
626    }
627    fn input_schema(&self) -> serde_json::Value {
628        serde_json::json!({
629            "type": "object",
630            "properties": {
631                "cmd": {"type": "string"},
632                "rows": {"type": "integer", "default": 24},
633                "cols": {"type": "integer", "default": 80},
634                "cwd": {"type": "string"},
635                "env": {"type": "object"}
636            }
637        })
638    }
639    fn call<'a>(
640        &'a self,
641        args: crate::tool::ToolArgs,
642        ctx: &'a crate::tool::ToolCtx,
643    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
644        Box::pin(async move { spawn_impl(args, ctx).await })
645    }
646}
647
648async fn spawn_impl(
649    args: crate::tool::ToolArgs,
650    ctx: &crate::tool::ToolCtx,
651) -> crate::tool::ToolResult {
652    let cmd_str = extract_optional_string(&args, "cmd");
653    let rows = extract_optional_int(&args, "rows")
654        .map(|v| v as u16)
655        .unwrap_or(DEFAULT_ROWS)
656        .max(1);
657    let cols = extract_optional_int(&args, "cols")
658        .map(|v| v as u16)
659        .unwrap_or(DEFAULT_COLS)
660        .max(2);
661    let cwd = extract_optional_string(&args, "cwd")
662        .map(std::path::PathBuf::from)
663        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
664    let env: Vec<(String, String)> = if let Some(Value::Struct(fields)) = args.named("env") {
665        fields
666            .iter()
667            .filter_map(|(k, v)| {
668                if let Value::Str(s) = v {
669                    Some((k.clone(), s.clone()))
670                } else {
671                    None
672                }
673            })
674            .collect()
675    } else {
676        Vec::new()
677    };
678
679    let registry = ctx
680        .term_registry
681        .clone()
682        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: registry not available".into()))?;
683    let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
684    let session_dir = ctx
685        .session_dir
686        .clone()
687        .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: session_dir not available".into()))?;
688
689    let pty_size = portable_pty::PtySize {
690        rows,
691        cols,
692        pixel_width: 0,
693        pixel_height: 0,
694    };
695    let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
696    let cmd_args: Vec<&str> = if let Some(ref c) = cmd_str {
697        vec!["sh", "-c", c.as_str()]
698    } else {
699        vec![default_shell.as_str()]
700    };
701    let env_refs: Vec<(String, String)> = env.clone();
702
703    let pty_result = if let Some(sandbox) = &ctx.sandbox {
704        match sandbox
705            .spawn_pty(&cmd_args, &env_refs, &cwd, pty_size)
706            .await
707        {
708            Ok(r) => r,
709            Err(e) => {
710                let msg = e.to_string();
711                if msg.contains("Operation not permitted") || msg.contains("denied") {
712                    let outcome = crate::approval::request_approval(
713                        ctx,
714                        "term.spawn",
715                        "term.spawn",
716                        &args,
717                        ApprovalLevel::Dangerous,
718                        None,
719                    )
720                    .await;
721                    match outcome {
722                        crate::approval::ApprovalOutcome::Approve => {
723                            sandbox
724                                .spawn_pty_relaxed(&cmd_args, &env_refs, &cwd, pty_size)
725                                .await?
726                        }
727                        crate::approval::ApprovalOutcome::Deny { reason } => {
728                            return Err(RuntimeError::ToolFailed(format!(
729                                "term.spawn denied: {reason}"
730                            )));
731                        }
732                    }
733                } else {
734                    return Err(e);
735                }
736            }
737        }
738    } else {
739        spawn_pty_direct(&cmd_args, &env_refs, &cwd, pty_size)?
740    };
741
742    let (handle, entry) = registry.spawn_entry(
743        rows,
744        cols,
745        session_id,
746        session_dir,
747        pty_result,
748        ctx.stream_tx.clone(),
749        cmd_str.unwrap_or_else(|| "terminal".into()),
750        {
751            let tc = ctx.cancel.clone();
752            tc.child_token()
753        },
754        ctx.events.clone(),
755        ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
756    )?;
757
758    let state = entry.current_state();
759    let text = {
760        let parser = entry.parser.lock().expect("parser poisoned");
761        parser.screen().contents()
762    };
763    Ok(Value::Struct(vec![
764        ("handle".into(), Value::Str(handle.to_string())),
765        ("state".into(), state_to_value(&state)),
766        ("rows".into(), Value::Int(rows as i64)),
767        ("cols".into(), Value::Int(cols as i64)),
768        ("text".into(), Value::Str(text)),
769    ]))
770}
771
772fn spawn_pty_direct(
773    cmd: &[&str],
774    env: &[(String, String)],
775    cwd: &Path,
776    pty_size: portable_pty::PtySize,
777) -> Result<PtySpawnResult, RuntimeError> {
778    let pty_system = portable_pty::native_pty_system();
779    let pair = pty_system
780        .openpty(pty_size)
781        .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
782    let mut builder = portable_pty::CommandBuilder::new(cmd[0]);
783    for arg in &cmd[1..] {
784        builder.arg(arg);
785    }
786    builder.cwd(cwd);
787    for (k, v) in env {
788        builder.env(k, v);
789    }
790    let child = pair
791        .slave
792        .spawn_command(builder)
793        .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
794    let reader = pair
795        .master
796        .try_clone_reader()
797        .map_err(|e| RuntimeError::ToolFailed(format!("pty reader: {e}")))?;
798    let writer = pair
799        .master
800        .take_writer()
801        .map_err(|e| RuntimeError::ToolFailed(format!("pty writer: {e}")))?;
802    Ok(PtySpawnResult {
803        child,
804        reader,
805        writer,
806        master: pair.master,
807    })
808}
809
810fn cell_text(screen: &TerminalScreen, row: u16, col: u16) -> String {
811    let idx = (row as usize) * (screen.cols as usize) + (col as usize);
812    match screen.cells.get(idx) {
813        Some(cell) if cell.wide_continuation => String::new(),
814        Some(cell) if cell.chars.is_empty() => " ".to_string(),
815        Some(cell) => cell.chars.clone(),
816        None => " ".to_string(),
817    }
818}
819
820fn find_pattern_on_screen(screen: &TerminalScreen, pattern: &str) -> Option<(u16, u16)> {
821    for r in 0..screen.rows {
822        let row_text: String = (0..screen.cols)
823            .map(|c| cell_text(screen, r, c))
824            .collect::<String>()
825            .trim_end()
826            .to_string();
827        if let Some(pos) = row_text.find(pattern) {
828            return Some((r, pos as u16));
829        }
830    }
831    None
832}
833
834impl crate::watch::Watchable for TermEntry {
835    fn watch_output(
836        self: std::sync::Arc<Self>,
837        pattern: String,
838        cancel: tokio_util::sync::CancellationToken,
839    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
840    {
841        let stream_tx = self.stream_tx.clone();
842        let state = self.state.clone();
843        let parser = self.parser.clone();
844        Box::pin(async move {
845            // Subscribe BEFORE checking state — closes the TOCTOU window where
846            // the terminal exits between the check and subscribe().
847            let mut rx = stream_tx.subscribe();
848            {
849                let st = state.lock().unwrap().clone();
850                if !st.is_running() {
851                    let p = parser.lock().unwrap();
852                    let screen = snapshot_screen(&p);
853                    if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
854                        return crate::watch::WatchResult::Matched {
855                            row: Some(r),
856                            col: Some(c),
857                            text: pattern,
858                        };
859                    }
860                    return crate::watch::WatchResult::SourceExited;
861                }
862            }
863            loop {
864                tokio::select! {
865                    _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
866                    result = rx.recv() => match result {
867                        Ok(crate::tools::term::TermStreamEvent::Chunk { screen, .. }) => {
868                            if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
869                                return crate::watch::WatchResult::Matched {
870                                    row: Some(r),
871                                    col: Some(c),
872                                    text: pattern,
873                                };
874                            }
875                        }
876                        _ => return crate::watch::WatchResult::SourceExited,
877                    }
878                }
879            }
880        })
881    }
882}
883
884fn screen_to_value(screen: &TerminalScreen) -> Value {
885    let cells: Vec<Value> = screen
886        .cells
887        .iter()
888        .map(|c| {
889            Value::Struct(vec![
890                ("chars".into(), Value::Str(c.chars.clone())),
891                ("fg".into(), color_to_value(c.fg)),
892                ("bg".into(), color_to_value(c.bg)),
893                ("bold".into(), Value::Bool(c.bold)),
894                ("italic".into(), Value::Bool(c.italic)),
895                ("underline".into(), Value::Bool(c.underline)),
896                ("inverse".into(), Value::Bool(c.inverse)),
897                ("dim".into(), Value::Bool(c.dim)),
898                ("wide".into(), Value::Bool(c.wide)),
899                ("wide_continuation".into(), Value::Bool(c.wide_continuation)),
900            ])
901        })
902        .collect();
903    Value::Struct(vec![
904        ("rows".into(), Value::Int(screen.rows as i64)),
905        ("cols".into(), Value::Int(screen.cols as i64)),
906        ("cells".into(), Value::List(cells)),
907        (
908            "cursor".into(),
909            match screen.cursor {
910                Some((r, c)) => Value::Struct(vec![
911                    ("row".into(), Value::Int(r as i64)),
912                    ("col".into(), Value::Int(c as i64)),
913                ]),
914                None => Value::Unit,
915            },
916        ),
917        ("alt_screen".into(), Value::Bool(screen.alt_screen)),
918    ])
919}
920
921fn color_to_value(c: TerminalColor) -> Value {
922    match c {
923        TerminalColor::Default => Value::Str("default".into()),
924        TerminalColor::Idx(i) => Value::Int(i as i64),
925        TerminalColor::Rgb(r, g, b) => Value::Struct(vec![
926            ("r".into(), Value::Int(r as i64)),
927            ("g".into(), Value::Int(g as i64)),
928            ("b".into(), Value::Int(b as i64)),
929        ]),
930    }
931}
932
933fn state_to_value(state: &TermState) -> Value {
934    match state {
935        TermState::Running { pid, started_at } => Value::Struct(vec![
936            ("kind".into(), Value::Str("running".into())),
937            ("pid".into(), Value::Int(*pid as i64)),
938            ("started_at".into(), Value::Int(*started_at as i64)),
939        ]),
940        TermState::Exited {
941            exit_code,
942            ended_at,
943        } => Value::Struct(vec![
944            ("kind".into(), Value::Str("exited".into())),
945            (
946                "exit_code".into(),
947                exit_code
948                    .map(|c| Value::Int(c as i64))
949                    .unwrap_or(Value::Unit),
950            ),
951            ("ended_at".into(), Value::Int(*ended_at as i64)),
952        ]),
953        TermState::Failed { error, ended_at } => Value::Struct(vec![
954            ("kind".into(), Value::Str("failed".into())),
955            ("error".into(), Value::Str(error.clone())),
956            ("ended_at".into(), Value::Int(*ended_at as i64)),
957        ]),
958        TermState::Killed { ended_at } => Value::Struct(vec![
959            ("kind".into(), Value::Str("killed".into())),
960            ("ended_at".into(), Value::Int(*ended_at as i64)),
961        ]),
962    }
963}
964
965pub struct TermInput;
966impl Tool for TermInput {
967    fn name(&self) -> &str {
968        "term.input"
969    }
970    fn tier(&self) -> Tier {
971        Tier::Four
972    }
973    fn description(&self) -> Option<&str> {
974        Some(
975            "Send input to a terminal's PTY. Use `text` for literal text, `key` for\nspecial keys (enter, tab, esc, backspace, up, down, left, right, ctrl+c,\nctrl+d, ctrl+z), or `mouse` for mouse events. Use key: \"enter\" to\nsubmit a command, not text: \"\\r\". Mouse actions: click, double_click,\nlong_press, drag, press, release, move, scroll_up, scroll_down.\nUse term.find to locate text on screen before clicking.",
976        )
977    }
978    fn input_schema(&self) -> serde_json::Value {
979        serde_json::json!({
980            "type": "object",
981            "properties": {
982                "handle": {"type": "string"},
983                "text": {"type": "string", "description": "Literal text to write. Do NOT use \\r or \\n here — use key:\"enter\" instead."},
984                "key": {"type": "string", "enum": ["enter", "tab", "esc", "backspace", "up", "down", "left", "right", "ctrl+c", "ctrl+d", "ctrl+z"]},
985                "mouse": {
986                    "type": "object",
987                    "properties": {
988                        "action": {
989                            "type": "string",
990                            "enum": ["click", "double_click", "long_press", "drag", "press", "release", "move", "scroll_up", "scroll_down"],
991                            "description": "click=press+release, double_click=two clicks, long_press=press+500ms+release, drag=press+move+release (needs x2,y2), move=hover, scroll_up/down=wheel"
992                        },
993                        "button": {"type": "string", "enum": ["left", "right", "middle"], "default": "left"},
994                        "x": {"type": "integer", "description": "Column (0-indexed, same as term.find col)"},
995                        "y": {"type": "integer", "description": "Row (0-indexed, same as term.find row)"},
996                        "x2": {"type": "integer", "description": "End column for drag"},
997                        "y2": {"type": "integer", "description": "End row for drag"}
998                    },
999                    "required": ["action", "x", "y"],
1000                    "description": "Mouse event. Coordinates are 0-indexed — pass term.find (col,row) directly."
1001                }
1002            },
1003            "required": ["handle"]
1004        })
1005    }
1006    fn call<'a>(
1007        &'a self,
1008        args: crate::tool::ToolArgs,
1009        ctx: &'a crate::tool::ToolCtx,
1010    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1011        Box::pin(async move {
1012            let handle = extract_string(&args, "handle", 0)?;
1013            let text = extract_optional_string(&args, "text").unwrap_or_default();
1014            let key = extract_optional_string(&args, "key");
1015            let mouse = args.named("mouse");
1016            let registry = ctx.term_registry.clone().ok_or_else(|| {
1017                RuntimeError::ToolFailed("term.input: registry not available".into())
1018            })?;
1019            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1020            let entry = registry.lookup(&handle, &session_id)?;
1021
1022            // Build steps: (bytes, delay_after).
1023            let mut steps: Vec<(Vec<u8>, std::time::Duration)> = Vec::new();
1024            let mut first = text.into_bytes();
1025            if let Some(k) = &key {
1026                first.extend_from_slice(&key_to_bytes(k));
1027            }
1028            if !first.is_empty() {
1029                steps.push((first, std::time::Duration::ZERO));
1030            }
1031            if let Some(m) = &mouse {
1032                steps.extend(mouse_to_steps(m)?);
1033            }
1034            if steps.is_empty() {
1035                return Err(RuntimeError::ToolFailed(
1036                    "term.input: provide at least one of `text`, `key`, or `mouse`".into(),
1037                ));
1038            }
1039
1040            let mut total = 0usize;
1041            for (payload, delay) in steps {
1042                if !payload.is_empty() {
1043                    let mut w = entry.writer.lock().expect("writer poisoned");
1044                    w.write_all(&payload)
1045                        .map_err(|e| RuntimeError::ToolFailed(format!("term.input write: {e}")))?;
1046                    total += payload.len();
1047                    drop(w);
1048                }
1049                if !delay.is_zero() {
1050                    tokio::time::sleep(delay).await;
1051                }
1052            }
1053            Ok(Value::Struct(vec![
1054                ("ok".into(), Value::Bool(true)),
1055                ("bytes_written".into(), Value::Int(total as i64)),
1056            ]))
1057        })
1058    }
1059}
1060
1061fn key_to_bytes(key: &str) -> Vec<u8> {
1062    match key {
1063        "enter" => vec![b'\r'],
1064        "tab" => vec![b'\t'],
1065        "esc" => vec![0x1b],
1066        "backspace" => vec![0x7f],
1067        "up" => vec![0x1b, b'[', b'A'],
1068        "down" => vec![0x1b, b'[', b'B'],
1069        "right" => vec![0x1b, b'[', b'C'],
1070        "left" => vec![0x1b, b'[', b'D'],
1071        "ctrl+c" => vec![0x03],
1072        "ctrl+d" => vec![0x04],
1073        "ctrl+z" => vec![0x1a],
1074        _ => Vec::new(),
1075    }
1076}
1077
1078/// SGR mouse bytes. Input coords are 0-indexed (term.find convention);
1079/// SGR protocol is 1-indexed so we +1 internally.
1080fn sgr(btn: u32, action: &str, col0: i64, row0: i64) -> Vec<u8> {
1081    let x = col0 + 1;
1082    let y = row0 + 1;
1083    match action {
1084        "press" => format!("\x1b[<{btn};{x};{y}M").into_bytes(),
1085        "release" => format!("\x1b[<{btn};{x};{y}m").into_bytes(),
1086        "move" => format!("\x1b[<{btn};{x};{y}M", btn = btn + 32).into_bytes(),
1087        _ => Vec::new(),
1088    }
1089}
1090
1091/// Build (bytes, delay) steps from a mouse parameter.
1092/// Coords are 0-indexed — pass term.find (col, row) directly as (x, y).
1093fn mouse_to_steps(val: &Value) -> Result<Vec<(Vec<u8>, std::time::Duration)>, RuntimeError> {
1094    let fields = match val {
1095        Value::Struct(f) => f,
1096        _ => {
1097            return Err(RuntimeError::ToolFailed(
1098                "term.input: mouse must be an object".into(),
1099            ));
1100        }
1101    };
1102    let get_str = |name: &str| -> Result<&str, RuntimeError> {
1103        fields
1104            .iter()
1105            .find(|(k, _)| k == name)
1106            .and_then(|(_, v)| {
1107                if let Value::Str(s) = v {
1108                    Some(s.as_str())
1109                } else {
1110                    None
1111                }
1112            })
1113            .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1114    };
1115    let get_int = |name: &str| -> Result<i64, RuntimeError> {
1116        fields
1117            .iter()
1118            .find(|(k, _)| k == name)
1119            .and_then(|(_, v)| {
1120                if let Value::Int(i) = v {
1121                    Some(*i)
1122                } else {
1123                    None
1124                }
1125            })
1126            .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1127    };
1128    let get_opt_int = |name: &str| -> Option<i64> {
1129        fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1130            if let Value::Int(i) = v {
1131                Some(*i)
1132            } else {
1133                None
1134            }
1135        })
1136    };
1137
1138    let action = get_str("action")?;
1139    let button_str = get_opt_str(fields, "button").unwrap_or("left");
1140    let x = get_int("x")?;
1141    let y = get_int("y")?;
1142    let btn: u32 = match button_str {
1143        "left" => 0,
1144        "middle" => 1,
1145        "right" => 2,
1146        _ => {
1147            return Err(RuntimeError::ToolFailed(format!(
1148                "term.input: mouse.button must be left/right/middle, got {button_str}"
1149            )));
1150        }
1151    };
1152
1153    let z = std::time::Duration::ZERO;
1154    let gap = std::time::Duration::from_millis(50);
1155    let long = std::time::Duration::from_millis(500);
1156
1157    match action {
1158        "press" => Ok(vec![(sgr(btn, "press", x, y), z)]),
1159        "release" => Ok(vec![(sgr(btn, "release", x, y), z)]),
1160        "click" => Ok(vec![
1161            (sgr(btn, "press", x, y), z),
1162            (sgr(btn, "release", x, y), z),
1163        ]),
1164        "double_click" => Ok(vec![
1165            (sgr(btn, "press", x, y), z),
1166            (sgr(btn, "release", x, y), gap),
1167            (sgr(btn, "press", x, y), z),
1168            (sgr(btn, "release", x, y), z),
1169        ]),
1170        "long_press" => Ok(vec![
1171            (sgr(btn, "press", x, y), long),
1172            (sgr(btn, "release", x, y), z),
1173        ]),
1174        "drag" => {
1175            let x2 = get_opt_int("x2").ok_or_else(|| {
1176                RuntimeError::ToolFailed("term.input: mouse.drag requires x2".into())
1177            })?;
1178            let y2 = get_opt_int("y2").ok_or_else(|| {
1179                RuntimeError::ToolFailed("term.input: mouse.drag requires y2".into())
1180            })?;
1181            let mut steps = vec![(sgr(btn, "press", x, y), z)];
1182            let dx = x2 - x;
1183            let dy = y2 - y;
1184            let n = dx.unsigned_abs().max(dy.unsigned_abs());
1185            for i in 1..=n {
1186                let cx = x + dx * i as i64 / n as i64;
1187                let cy = y + dy * i as i64 / n as i64;
1188                steps.push((sgr(btn, "move", cx, cy), z));
1189            }
1190            steps.push((sgr(btn, "release", x2, y2), z));
1191            Ok(steps)
1192        }
1193        "move" => Ok(vec![(sgr(0, "move", x, y), z)]),
1194        "scroll_up" => Ok(vec![(
1195            format!("\x1b[<64;{};{}M", x + 1, y + 1).into_bytes(),
1196            z,
1197        )]),
1198        "scroll_down" => Ok(vec![(
1199            format!("\x1b[<65;{};{}M", x + 1, y + 1).into_bytes(),
1200            z,
1201        )]),
1202        _ => Err(RuntimeError::ToolFailed(format!(
1203            "term.input: mouse.action must be click/double_click/long_press/drag/press/release/move/scroll_up/scroll_down, got {action}"
1204        ))),
1205    }
1206}
1207
1208fn get_opt_str<'a>(fields: &'a [(String, Value)], name: &'a str) -> Option<&'a str> {
1209    fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1210        if let Value::Str(s) = v {
1211            Some(s.as_str())
1212        } else {
1213            None
1214        }
1215    })
1216}
1217
1218pub struct TermCapture;
1219impl Tool for TermCapture {
1220    fn name(&self) -> &str {
1221        "term.capture"
1222    }
1223    fn tier(&self) -> Tier {
1224        Tier::Four
1225    }
1226    fn description(&self) -> Option<&str> {
1227        Some(
1228            "Read the terminal screen. Default returns plain text (format: \"text\").\n\
1229             Use start_row/end_row and start_col/end_col to read a rectangular region.\n\n\
1230             Best practices:\n\
1231             - After sending a command, capture to see the result.\n\
1232             - Use start_row/end_row to read only the relevant part (e.g. last 10 rows).\n\
1233             - Use start_col/end_col to read a column range (e.g. skip line numbers).\n\
1234             - format: \"screen\" returns full cell data with colors/styles.\n\
1235             - Default format: \"text\" is sufficient for most cases.",
1236        )
1237    }
1238    fn input_schema(&self) -> serde_json::Value {
1239        serde_json::json!({
1240            "type": "object",
1241            "properties": {
1242                "handle": {"type": "string"},
1243                "format": {"type": "string", "enum": ["text", "screen"], "default": "text"},
1244                "start_row": {"type": "integer", "default": 0, "description": "Start row (0-based). Default 0."},
1245                "end_row": {"type": "integer", "description": "End row (exclusive). Default: full height."},
1246                "start_col": {"type": "integer", "default": 0, "description": "Start column (0-based). Default 0."},
1247                "end_col": {"type": "integer", "description": "End column (exclusive). Default: full width."}
1248            },
1249            "required": ["handle"]
1250        })
1251    }
1252    fn call<'a>(
1253        &'a self,
1254        args: crate::tool::ToolArgs,
1255        ctx: &'a crate::tool::ToolCtx,
1256    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1257        Box::pin(async move {
1258            let handle = extract_string(&args, "handle", 0)?;
1259            let format = extract_optional_string(&args, "format").unwrap_or_else(|| "text".into());
1260            let registry = ctx.term_registry.clone().ok_or_else(|| {
1261                RuntimeError::ToolFailed("term.capture: registry not available".into())
1262            })?;
1263            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1264            let entry = registry.lookup(&handle, &session_id)?;
1265            let screen = entry.snapshot();
1266            let start_row = extract_optional_int(&args, "start_row")
1267                .unwrap_or(0)
1268                .clamp(0, screen.rows as i64) as u16;
1269            let end_row = extract_optional_int(&args, "end_row")
1270                .unwrap_or(screen.rows as i64)
1271                .clamp(start_row as i64, screen.rows as i64) as u16;
1272            let start_col = extract_optional_int(&args, "start_col")
1273                .unwrap_or(0)
1274                .clamp(0, screen.cols as i64) as u16;
1275            let end_col = extract_optional_int(&args, "end_col")
1276                .unwrap_or(screen.cols as i64)
1277                .clamp(start_col as i64, screen.cols as i64) as u16;
1278            let state = entry.current_state();
1279            let mut fields = vec![
1280                ("handle".into(), Value::Str(handle.clone())),
1281                ("state".into(), state_to_value(&state)),
1282                ("rows".into(), Value::Int((end_row - start_row) as i64)),
1283                ("cols".into(), Value::Int((end_col - start_col) as i64)),
1284            ];
1285            if format == "screen" {
1286                let sub_rows = end_row - start_row;
1287                let sub_cols = end_col - start_col;
1288                let sub_cells: Vec<TerminalCell> = (start_row..end_row)
1289                    .flat_map(|r| {
1290                        (start_col..end_col)
1291                            .map(|c| {
1292                                screen
1293                                    .cells
1294                                    .get((r as usize) * (screen.cols as usize) + (c as usize))
1295                                    .cloned()
1296                                    .unwrap_or_default()
1297                            })
1298                            .collect::<Vec<_>>()
1299                    })
1300                    .collect();
1301                let cursor = screen.cursor.and_then(|(row, col)| {
1302                    if (start_row..end_row).contains(&row) && (start_col..end_col).contains(&col) {
1303                        Some((row - start_row, col - start_col))
1304                    } else {
1305                        None
1306                    }
1307                });
1308                let partial_screen = TerminalScreen {
1309                    rows: sub_rows,
1310                    cols: sub_cols,
1311                    cells: sub_cells,
1312                    cursor,
1313                    alt_screen: screen.alt_screen,
1314                };
1315                fields.push(("screen".into(), screen_to_value(&partial_screen)));
1316            } else {
1317                let text = (start_row..end_row)
1318                    .map(|r| {
1319                        let row_text: String = (start_col..end_col)
1320                            .map(|c| cell_text(&screen, r, c))
1321                            .collect();
1322                        row_text.trim_end().to_string()
1323                    })
1324                    .collect::<Vec<_>>()
1325                    .join("\n");
1326                fields.push(("text".into(), Value::Str(text)));
1327            }
1328            Ok(Value::Struct(fields))
1329        })
1330    }
1331}
1332
1333pub struct TermFind;
1334impl Tool for TermFind {
1335    fn name(&self) -> &str {
1336        "term.find"
1337    }
1338    fn tier(&self) -> Tier {
1339        Tier::Four
1340    }
1341    fn description(&self) -> Option<&str> {
1342        Some(
1343            "Search terminal screen for text and/or style. Returns list of matches.\n\n\
1344             - pattern: substring to search for (case-sensitive). Omit for style-only search.\n\
1345             - style: optional filter. Only cells matching ALL specified style fields count.\n\
1346             - Combine both: find red 'error' text, bold prompts, etc.\n\n\
1347             Style fields: bold, italic, underline, inverse, dim, fg, bg.\n\
1348             Colors: name (\"red\") or {r,g,b} struct (discover via term.capture format=\"screen\").\n\n\
1349             Returns: { matches: [{row, col, text}], count: N }",
1350        )
1351    }
1352    fn input_schema(&self) -> serde_json::Value {
1353        serde_json::json!({
1354            "type": "object",
1355            "properties": {
1356                "handle": {"type": "string"},
1357                "pattern": {"type": "string", "description": "Substring to search for. Omit for style-only search."},
1358                "style": {
1359                    "type": "object",
1360                    "properties": {
1361                        "bold": {"type": "boolean"},
1362                        "italic": {"type": "boolean"},
1363                        "underline": {"type": "boolean"},
1364                        "inverse": {"type": "boolean"},
1365                        "dim": {"type": "boolean"},
1366                        "fg": {"type": "string", "description": "Color name or {r,g,b} struct"},
1367                        "bg": {"type": "string", "description": "Color name or {r,g,b} struct"}
1368                    },
1369                    "description": "Optional style filter. All specified fields must match."
1370                },
1371                "start_row": {"type": "integer", "default": 0},
1372                "end_row": {"type": "integer", "description": "Exclusive. Default: full height."}
1373            },
1374            "required": ["handle"]
1375        })
1376    }
1377    fn call<'a>(
1378        &'a self,
1379        args: crate::tool::ToolArgs,
1380        ctx: &'a crate::tool::ToolCtx,
1381    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1382        Box::pin(async move {
1383            let handle = extract_string(&args, "handle", 0)?;
1384            let pattern = extract_optional_string(&args, "pattern");
1385            let style_filter = parse_style_filter(&args)?;
1386            if pattern.is_none() && style_filter.is_none() {
1387                return Err(RuntimeError::ToolFailed(
1388                    "term.find: provide at least pattern or style".into(),
1389                ));
1390            }
1391            let registry = ctx.term_registry.clone().ok_or_else(|| {
1392                RuntimeError::ToolFailed("term.find: registry not available".into())
1393            })?;
1394            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1395            let entry = registry.lookup(&handle, &session_id)?;
1396            let screen = entry.snapshot();
1397            let start_row = extract_optional_int(&args, "start_row")
1398                .unwrap_or(0)
1399                .clamp(0, screen.rows as i64) as u16;
1400            let end_row = extract_optional_int(&args, "end_row")
1401                .unwrap_or(screen.rows as i64)
1402                .clamp(start_row as i64, screen.rows as i64) as u16;
1403
1404            let mut matches = Vec::new();
1405            for r in start_row..end_row {
1406                let row_cells: Vec<&TerminalCell> =
1407                    (0..screen.cols).map(|c| cell_ref(&screen, r, c)).collect();
1408                let row_text: String = row_cells
1409                    .iter()
1410                    .map(|c| {
1411                        if c.wide_continuation {
1412                            ""
1413                        } else {
1414                            c.chars.as_str()
1415                        }
1416                    })
1417                    .collect::<String>();
1418                let row_text_trimmed = row_text.trim_end();
1419
1420                if let Some(ref pat) = pattern {
1421                    let mut start = 0;
1422                    while let Some(pos) = row_text_trimmed[start..].find(pat) {
1423                        let abs_col = start + pos;
1424                        let style_ok = style_filter
1425                            .as_ref()
1426                            .map(|sf| sf.matches(row_cells.get(abs_col).copied()))
1427                            .unwrap_or(true);
1428                        if style_ok {
1429                            let matched_text = pat.clone();
1430                            matches.push(Value::Struct(vec![
1431                                ("row".into(), Value::Int(r as i64)),
1432                                ("col".into(), Value::Int(abs_col as i64)),
1433                                ("text".into(), Value::Str(matched_text)),
1434                            ]));
1435                        }
1436                        start += pos + pat.len();
1437                        if start >= row_text_trimmed.len() {
1438                            break;
1439                        }
1440                    }
1441                } else if let Some(ref sf) = style_filter {
1442                    let mut col = 0usize;
1443                    while col < screen.cols as usize {
1444                        if sf.matches(row_cells.get(col).copied())
1445                            && !row_cells[col].chars.is_empty()
1446                        {
1447                            let start_col = col;
1448                            let mut text = String::new();
1449                            while col < screen.cols as usize
1450                                && sf.matches(row_cells.get(col).copied())
1451                                && !row_cells[col].wide_continuation
1452                            {
1453                                text.push_str(&row_cells[col].chars);
1454                                col += 1;
1455                            }
1456                            if !text.trim().is_empty() {
1457                                matches.push(Value::Struct(vec![
1458                                    ("row".into(), Value::Int(r as i64)),
1459                                    ("col".into(), Value::Int(start_col as i64)),
1460                                    ("text".into(), Value::Str(text)),
1461                                ]));
1462                            }
1463                        } else {
1464                            col += 1;
1465                        }
1466                    }
1467                }
1468            }
1469            let count = matches.len() as i64;
1470            Ok(Value::Struct(vec![
1471                ("matches".into(), Value::List(matches)),
1472                ("count".into(), Value::Int(count)),
1473            ]))
1474        })
1475    }
1476}
1477
1478fn cell_ref(screen: &TerminalScreen, row: u16, col: u16) -> &TerminalCell {
1479    let idx = (row as usize) * (screen.cols as usize) + (col as usize);
1480    screen.cells.get(idx).unwrap_or(&DEFAULT_CELL)
1481}
1482
1483static DEFAULT_CELL: TerminalCell = TerminalCell {
1484    chars: String::new(),
1485    fg: TerminalColor::Default,
1486    bg: TerminalColor::Default,
1487    bold: false,
1488    italic: false,
1489    underline: false,
1490    inverse: false,
1491    dim: false,
1492    wide: false,
1493    wide_continuation: false,
1494};
1495
1496struct StyleFilter {
1497    bold: Option<bool>,
1498    italic: Option<bool>,
1499    underline: Option<bool>,
1500    inverse: Option<bool>,
1501    dim: Option<bool>,
1502    fg: Option<TerminalColor>,
1503    bg: Option<TerminalColor>,
1504}
1505
1506impl StyleFilter {
1507    fn matches(&self, cell: Option<&TerminalCell>) -> bool {
1508        let Some(cell) = cell else {
1509            return false;
1510        };
1511        if self.bold == Some(true) && !cell.bold {
1512            return false;
1513        }
1514        if self.bold == Some(false) && cell.bold {
1515            return false;
1516        }
1517        if self.italic == Some(true) && !cell.italic {
1518            return false;
1519        }
1520        if self.italic == Some(false) && cell.italic {
1521            return false;
1522        }
1523        if self.underline == Some(true) && !cell.underline {
1524            return false;
1525        }
1526        if self.underline == Some(false) && cell.underline {
1527            return false;
1528        }
1529        if self.inverse == Some(true) && !cell.inverse {
1530            return false;
1531        }
1532        if self.inverse == Some(false) && cell.inverse {
1533            return false;
1534        }
1535        if self.dim == Some(true) && !cell.dim {
1536            return false;
1537        }
1538        if self.dim == Some(false) && cell.dim {
1539            return false;
1540        }
1541        if let Some(fg) = &self.fg {
1542            if !color_matches(fg, &cell.fg) {
1543                return false;
1544            }
1545        }
1546        if let Some(bg) = &self.bg {
1547            if !color_matches(bg, &cell.bg) {
1548                return false;
1549            }
1550        }
1551        true
1552    }
1553}
1554
1555fn parse_style_filter(args: &crate::tool::ToolArgs) -> Result<Option<StyleFilter>, RuntimeError> {
1556    let style = match args.named("style") {
1557        Some(v) => v,
1558        None => return Ok(None),
1559    };
1560    Ok(Some(StyleFilter {
1561        bold: get_optional_bool(style, "bold"),
1562        italic: get_optional_bool(style, "italic"),
1563        underline: get_optional_bool(style, "underline"),
1564        inverse: get_optional_bool(style, "inverse"),
1565        dim: get_optional_bool(style, "dim"),
1566        fg: get_optional_color(style, "fg")?,
1567        bg: get_optional_color(style, "bg")?,
1568    }))
1569}
1570
1571fn get_optional_bool(style: &Value, field: &str) -> Option<bool> {
1572    match style.field(field) {
1573        Some(Value::Bool(b)) => Some(*b),
1574        _ => None,
1575    }
1576}
1577
1578fn get_optional_color(style: &Value, field: &str) -> Result<Option<TerminalColor>, RuntimeError> {
1579    match style.field(field) {
1580        Some(v) => parse_color(v).map(Some).ok_or_else(|| {
1581            RuntimeError::ToolFailed(format!(
1582                "term.find: invalid color for '{field}' — use name (\"red\") or {{r,g,b}} struct"
1583            ))
1584        }),
1585        None => Ok(None),
1586    }
1587}
1588
1589fn parse_color(val: &Value) -> Option<TerminalColor> {
1590    match val {
1591        Value::Str(name) => color_name_to_terminal(name),
1592        Value::Struct(fields) => {
1593            let r = fields
1594                .iter()
1595                .find(|(k, _)| k == "r")
1596                .and_then(|(_, v)| match v {
1597                    Value::Int(n) => Some(*n),
1598                    _ => None,
1599                })?;
1600            let g = fields
1601                .iter()
1602                .find(|(k, _)| k == "g")
1603                .and_then(|(_, v)| match v {
1604                    Value::Int(n) => Some(*n),
1605                    _ => None,
1606                })?;
1607            let b = fields
1608                .iter()
1609                .find(|(k, _)| k == "b")
1610                .and_then(|(_, v)| match v {
1611                    Value::Int(n) => Some(*n),
1612                    _ => None,
1613                })?;
1614            Some(TerminalColor::Rgb(r as u8, g as u8, b as u8))
1615        }
1616        _ => None,
1617    }
1618}
1619
1620fn color_name_to_terminal(name: &str) -> Option<TerminalColor> {
1621    let idx = match name {
1622        "default" => return Some(TerminalColor::Default),
1623        "black" => 0,
1624        "red" => 1,
1625        "green" => 2,
1626        "yellow" => 3,
1627        "blue" => 4,
1628        "magenta" => 5,
1629        "cyan" => 6,
1630        "white" => 7,
1631        _ => return None,
1632    };
1633    Some(TerminalColor::Idx(idx))
1634}
1635
1636fn color_matches(desired: &TerminalColor, actual: &TerminalColor) -> bool {
1637    match (desired, actual) {
1638        (TerminalColor::Default, TerminalColor::Default) => true,
1639        (TerminalColor::Idx(d), TerminalColor::Idx(a)) => *d == *a || (*d < 8 && *a == *d + 8),
1640        (TerminalColor::Rgb(dr, dg, db), TerminalColor::Rgb(ar, ag, ab)) => {
1641            dr == ar && dg == ag && db == ab
1642        }
1643        _ => false,
1644    }
1645}
1646
1647pub struct TermResize;
1648impl Tool for TermResize {
1649    fn name(&self) -> &str {
1650        "term.resize"
1651    }
1652    fn tier(&self) -> Tier {
1653        Tier::Four
1654    }
1655    fn description(&self) -> Option<&str> {
1656        Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1657    }
1658    fn input_schema(&self) -> serde_json::Value {
1659        serde_json::json!({
1660            "type": "object",
1661            "properties": {
1662                "handle": {"type": "string"},
1663                "rows": {"type": "integer"},
1664                "cols": {"type": "integer"}
1665            },
1666            "required": ["handle", "rows", "cols"]
1667        })
1668    }
1669    fn call<'a>(
1670        &'a self,
1671        args: crate::tool::ToolArgs,
1672        ctx: &'a crate::tool::ToolCtx,
1673    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1674        Box::pin(async move {
1675            let handle = extract_string(&args, "handle", 0)?;
1676            let rows = extract_optional_int(&args, "rows")
1677                .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1678                as u16;
1679            let cols = extract_optional_int(&args, "cols")
1680                .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1681                as u16;
1682            let registry = ctx.term_registry.clone().ok_or_else(|| {
1683                RuntimeError::ToolFailed("term.resize: registry not available".into())
1684            })?;
1685            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1686            let entry = registry.lookup(&handle, &session_id)?;
1687            entry.resize(rows, cols)?;
1688            Ok(Value::Struct(vec![
1689                ("ok".into(), Value::Bool(true)),
1690                ("rows".into(), Value::Int(rows as i64)),
1691                ("cols".into(), Value::Int(cols as i64)),
1692            ]))
1693        })
1694    }
1695}
1696
1697pub struct TermKill;
1698impl Tool for TermKill {
1699    fn name(&self) -> &str {
1700        "term.kill"
1701    }
1702    fn tier(&self) -> Tier {
1703        Tier::Four
1704    }
1705    fn description(&self) -> Option<&str> {
1706        Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1707    }
1708    fn input_schema(&self) -> serde_json::Value {
1709        serde_json::json!({
1710            "type": "object",
1711            "properties": {"handle": {"type": "string"}},
1712            "required": ["handle"]
1713        })
1714    }
1715    fn call<'a>(
1716        &'a self,
1717        args: crate::tool::ToolArgs,
1718        ctx: &'a crate::tool::ToolCtx,
1719    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1720        Box::pin(async move {
1721            let handle = extract_string(&args, "handle", 0)?;
1722            let registry = ctx.term_registry.clone().ok_or_else(|| {
1723                RuntimeError::ToolFailed("term.kill: registry not available".into())
1724            })?;
1725            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1726            let entry = registry.lookup(&handle, &session_id)?;
1727            {
1728                let mut child = entry.child.lock().expect("child poisoned");
1729                if let Some(child) = child.as_mut() {
1730                    let _ = child.kill();
1731                }
1732            }
1733            {
1734                let mut state = entry.state.lock().expect("state poisoned");
1735                *state = TermState::Killed { ended_at: now_ms() };
1736            }
1737            Ok(Value::Struct(vec![
1738                ("ok".into(), Value::Bool(true)),
1739                ("state".into(), Value::Str("killed".into())),
1740            ]))
1741        })
1742    }
1743}
1744
1745pub struct TermList;
1746impl Tool for TermList {
1747    fn name(&self) -> &str {
1748        "term.list"
1749    }
1750    fn tier(&self) -> Tier {
1751        Tier::Four
1752    }
1753    fn description(&self) -> Option<&str> {
1754        Some("List all terminal handles in the current session.")
1755    }
1756    fn input_schema(&self) -> serde_json::Value {
1757        serde_json::json!({
1758            "type": "object",
1759            "properties": {"all": {"type": "boolean", "default": false}}
1760        })
1761    }
1762    fn call<'a>(
1763        &'a self,
1764        args: crate::tool::ToolArgs,
1765        ctx: &'a crate::tool::ToolCtx,
1766    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1767        Box::pin(async move {
1768            let all = args
1769                .named("all")
1770                .and_then(|v| {
1771                    if let Value::Bool(b) = v {
1772                        Some(*b)
1773                    } else {
1774                        None
1775                    }
1776                })
1777                .unwrap_or(false);
1778            let registry = ctx.term_registry.clone().ok_or_else(|| {
1779                RuntimeError::ToolFailed("term.list: registry not available".into())
1780            })?;
1781            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1782            let _ = all;
1783            let list = registry.list(&session_id);
1784            let entries: Vec<Value> = list
1785                .iter()
1786                .map(|(h, st)| {
1787                    Value::Struct(vec![
1788                        ("handle".into(), Value::Str(h.clone())),
1789                        ("state".into(), state_to_value(st)),
1790                    ])
1791                })
1792                .collect();
1793            Ok(Value::Struct(vec![(
1794                "terminals".into(),
1795                Value::List(entries),
1796            )]))
1797        })
1798    }
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803    use super::*;
1804
1805    #[test]
1806    fn handle_parse_roundtrip() {
1807        let h = TermHandle {
1808            session_id: "abc".into(),
1809            local_id: 7,
1810        };
1811        assert_eq!(h.to_string(), "term_abc_7");
1812        let back = TermHandle::parse("term_abc_7").unwrap();
1813        assert_eq!(back, h);
1814    }
1815
1816    #[test]
1817    fn handle_parse_rejects_bad_format() {
1818        assert!(TermHandle::parse("not_term").is_none());
1819        assert!(TermHandle::parse("term_nosuffix").is_none());
1820        assert!(TermHandle::parse("term_x_notnum").is_none());
1821    }
1822
1823    #[test]
1824    fn snapshot_screen_captures_text() {
1825        let mut parser = vt100::Parser::new(3, 5, 0);
1826        parser.process(b"hello");
1827        let screen = snapshot_screen(&parser);
1828        assert_eq!(screen.rows, 3);
1829        assert_eq!(screen.cols, 5);
1830        assert_eq!(screen.cells.len(), 15);
1831        assert_eq!(screen.cells[0].chars, "h");
1832        assert_eq!(screen.cells[4].chars, "o");
1833    }
1834
1835    #[test]
1836    fn registry_lookup_rejects_cross_session() {
1837        let registry = Arc::new(TermRegistry::new());
1838        let h = registry.next_handle("session_a");
1839        let entry = Arc::new(TermEntry {
1840            handle: h.clone(),
1841            session_id: "session_a".into(),
1842            pty_size: portable_pty::PtySize {
1843                rows: 24,
1844                cols: 80,
1845                pixel_width: 0,
1846                pixel_height: 0,
1847            },
1848            parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
1849            writer: Mutex::new(Box::new(std::io::sink())),
1850            state: Arc::new(Mutex::new(TermState::Running {
1851                pid: 0,
1852                started_at: 0,
1853            })),
1854            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
1855            log_path: std::env::temp_dir().join("term_test_dummy.log"),
1856            reader_task: Mutex::new(None),
1857            child: Mutex::new(None),
1858            master: Mutex::new(None),
1859            started_at: Instant::now(),
1860            task_id: Mutex::new(None),
1861        });
1862        registry.insert(entry);
1863        assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
1864        assert!(registry.lookup(&h.to_string(), "session_b").is_err());
1865    }
1866
1867    fn make_screen(rows: u16, cols: u16, text: &str) -> TerminalScreen {
1868        let mut parser = vt100::Parser::new(rows, cols, 0);
1869        parser.process(text.as_bytes());
1870        snapshot_screen(&parser)
1871    }
1872
1873    #[test]
1874    fn cell_text_returns_chars_for_filled_cell() {
1875        let screen = make_screen(1, 5, "hello");
1876        assert_eq!(cell_text(&screen, 0, 0), "h");
1877        assert_eq!(cell_text(&screen, 0, 4), "o");
1878    }
1879
1880    #[test]
1881    fn cell_text_returns_space_for_empty_cell() {
1882        let screen = make_screen(1, 5, "hi");
1883        assert_eq!(cell_text(&screen, 0, 2), " ");
1884    }
1885
1886    #[test]
1887    fn find_pattern_returns_position() {
1888        let screen = make_screen(2, 10, "hello\r\nworld");
1889        let pos = find_pattern_on_screen(&screen, "world");
1890        assert_eq!(pos, Some((1, 0)));
1891    }
1892
1893    #[test]
1894    fn find_pattern_returns_none_when_absent() {
1895        let screen = make_screen(1, 5, "hello");
1896        assert!(find_pattern_on_screen(&screen, "xyz").is_none());
1897    }
1898
1899    #[test]
1900    fn color_name_maps_to_ansi_idx() {
1901        assert_eq!(color_name_to_terminal("red"), Some(TerminalColor::Idx(1)));
1902        assert_eq!(
1903            color_name_to_terminal("default"),
1904            Some(TerminalColor::Default)
1905        );
1906        assert_eq!(color_name_to_terminal("nonexistent"), None);
1907    }
1908
1909    #[test]
1910    fn color_matches_bright_variant_to_base_name() {
1911        let desired = TerminalColor::Idx(1);
1912        let actual_bright = TerminalColor::Idx(9);
1913        assert!(color_matches(&desired, &actual_bright));
1914        let actual_normal = TerminalColor::Idx(1);
1915        assert!(color_matches(&desired, &actual_normal));
1916    }
1917
1918    #[test]
1919    fn color_matches_rgb_exact() {
1920        let desired = TerminalColor::Rgb(255, 128, 0);
1921        let actual = TerminalColor::Rgb(255, 128, 0);
1922        assert!(color_matches(&desired, &actual));
1923        let wrong = TerminalColor::Rgb(255, 128, 1);
1924        assert!(!color_matches(&desired, &wrong));
1925    }
1926
1927    #[test]
1928    fn parse_color_accepts_name_and_rgb() {
1929        assert_eq!(
1930            parse_color(&Value::Str("blue".into())),
1931            Some(TerminalColor::Idx(4))
1932        );
1933        let rgb = Value::Struct(vec![
1934            ("r".into(), Value::Int(10)),
1935            ("g".into(), Value::Int(20)),
1936            ("b".into(), Value::Int(30)),
1937        ]);
1938        assert_eq!(parse_color(&rgb), Some(TerminalColor::Rgb(10, 20, 30)));
1939    }
1940}