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 requested_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)
1272                as u16;
1273            let start_col = extract_optional_int(&args, "start_col")
1274                .unwrap_or(0)
1275                .clamp(0, screen.cols as i64) as u16;
1276            let end_col = extract_optional_int(&args, "end_col")
1277                .unwrap_or(screen.cols as i64)
1278                .clamp(start_col as i64, screen.cols as i64) as u16;
1279            let width = usize::from(end_col.saturating_sub(start_col));
1280            let max_rows_by_bytes = ctx
1281                .tool_output_budget
1282                .max_bytes
1283                .checked_div(width)
1284                .unwrap_or(ctx.tool_output_budget.max_lines);
1285            let max_rows = ctx
1286                .tool_output_budget
1287                .max_lines
1288                .min(max_rows_by_bytes.max(1))
1289                .min(u16::MAX as usize) as u16;
1290            let end_row = if format == "screen" {
1291                requested_end_row.min(start_row.saturating_add(max_rows))
1292            } else {
1293                requested_end_row
1294            };
1295            let state = entry.current_state();
1296            let mut fields = vec![
1297                ("handle".into(), Value::Str(handle.clone())),
1298                ("state".into(), state_to_value(&state)),
1299                ("rows".into(), Value::Int((end_row - start_row) as i64)),
1300                ("cols".into(), Value::Int((end_col - start_col) as i64)),
1301            ];
1302            if format == "screen" {
1303                fields.push((
1304                    "continuation".into(),
1305                    Value::Struct(vec![
1306                        ("type".into(), Value::Str("TerminalArea".into())),
1307                        ("next_row".into(), Value::Int(end_row as i64)),
1308                        ("start_col".into(), Value::Int(start_col as i64)),
1309                        ("end_col".into(), Value::Int(end_col as i64)),
1310                        ("has_more".into(), Value::Bool(end_row < screen.rows)),
1311                    ]),
1312                ));
1313                let sub_rows = end_row - start_row;
1314                let sub_cols = end_col - start_col;
1315                let sub_cells: Vec<TerminalCell> = (start_row..end_row)
1316                    .flat_map(|r| {
1317                        (start_col..end_col)
1318                            .map(|c| {
1319                                screen
1320                                    .cells
1321                                    .get((r as usize) * (screen.cols as usize) + (c as usize))
1322                                    .cloned()
1323                                    .unwrap_or_default()
1324                            })
1325                            .collect::<Vec<_>>()
1326                    })
1327                    .collect();
1328                let cursor = screen.cursor.and_then(|(row, col)| {
1329                    if (start_row..end_row).contains(&row) && (start_col..end_col).contains(&col) {
1330                        Some((row - start_row, col - start_col))
1331                    } else {
1332                        None
1333                    }
1334                });
1335                let partial_screen = TerminalScreen {
1336                    rows: sub_rows,
1337                    cols: sub_cols,
1338                    cells: sub_cells,
1339                    cursor,
1340                    alt_screen: screen.alt_screen,
1341                };
1342                fields.push(("screen".into(), screen_to_value(&partial_screen)));
1343            } else {
1344                let text = (start_row..end_row)
1345                    .map(|r| {
1346                        let row_text: String = (start_col..end_col)
1347                            .map(|c| cell_text(&screen, r, c))
1348                            .collect();
1349                        row_text.trim_end().to_string()
1350                    })
1351                    .collect::<Vec<_>>()
1352                    .join("\n");
1353                let cut =
1354                    crate::tools::tool_output::bounded_text_prefix(&text, ctx.tool_output_budget);
1355                if cut == text.len() {
1356                    fields.push(("text".into(), Value::Str(text)));
1357                } else {
1358                    let output_id = ctx
1359                        .output_store
1360                        .as_deref()
1361                        .and_then(|store| store.register("term_capture", &text))
1362                        .ok_or_else(|| {
1363                            RuntimeError::ToolFailed(
1364                                "term.capture: output exceeded budget, but no output_id is available for output.read".into(),
1365                            )
1366                        })?;
1367                    let total_lines = text.split_inclusive('\n').count();
1368                    fields.push(("content".into(), Value::Str(text[..cut].to_string())));
1369                    fields.push(("output_id".into(), Value::Str(output_id)));
1370                    fields.push(("total_lines".into(), Value::Int(total_lines as i64)));
1371                    fields.push(("total_bytes".into(), Value::Int(text.len() as i64)));
1372                    fields.push((
1373                        "next".into(),
1374                        Value::Struct(vec![
1375                            ("mode".into(), Value::Str("bytes".into())),
1376                            ("offset".into(), Value::Int(cut as i64)),
1377                            ("has_more".into(), Value::Bool(cut < text.len())),
1378                        ]),
1379                    ));
1380                }
1381            }
1382            Ok(Value::Struct(fields))
1383        })
1384    }
1385}
1386
1387pub struct TermFind;
1388impl Tool for TermFind {
1389    fn name(&self) -> &str {
1390        "term.find"
1391    }
1392    fn tier(&self) -> Tier {
1393        Tier::Four
1394    }
1395    fn description(&self) -> Option<&str> {
1396        Some(
1397            "Search terminal screen for text and/or style. Returns list of matches.\n\n\
1398             - pattern: substring to search for (case-sensitive). Omit for style-only search.\n\
1399             - style: optional filter. Only cells matching ALL specified style fields count.\n\
1400             - Combine both: find red 'error' text, bold prompts, etc.\n\n\
1401             Style fields: bold, italic, underline, inverse, dim, fg, bg.\n\
1402             Colors: name (\"red\") or {r,g,b} struct (discover via term.capture format=\"screen\").\n\n\
1403             Returns: { matches: [{row, col, text}], count: N }",
1404        )
1405    }
1406    fn input_schema(&self) -> serde_json::Value {
1407        serde_json::json!({
1408            "type": "object",
1409            "properties": {
1410                "handle": {"type": "string"},
1411                "pattern": {"type": "string", "description": "Substring to search for. Omit for style-only search."},
1412                "style": {
1413                    "type": "object",
1414                    "properties": {
1415                        "bold": {"type": "boolean"},
1416                        "italic": {"type": "boolean"},
1417                        "underline": {"type": "boolean"},
1418                        "inverse": {"type": "boolean"},
1419                        "dim": {"type": "boolean"},
1420                        "fg": {"type": "string", "description": "Color name or {r,g,b} struct"},
1421                        "bg": {"type": "string", "description": "Color name or {r,g,b} struct"}
1422                    },
1423                    "description": "Optional style filter. All specified fields must match."
1424                },
1425                "start_row": {"type": "integer", "default": 0},
1426                "end_row": {"type": "integer", "description": "Exclusive. Default: full height."}
1427            },
1428            "required": ["handle"]
1429        })
1430    }
1431    fn call<'a>(
1432        &'a self,
1433        args: crate::tool::ToolArgs,
1434        ctx: &'a crate::tool::ToolCtx,
1435    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1436        Box::pin(async move {
1437            let handle = extract_string(&args, "handle", 0)?;
1438            let pattern = extract_optional_string(&args, "pattern");
1439            let style_filter = parse_style_filter(&args)?;
1440            if pattern.is_none() && style_filter.is_none() {
1441                return Err(RuntimeError::ToolFailed(
1442                    "term.find: provide at least pattern or style".into(),
1443                ));
1444            }
1445            let registry = ctx.term_registry.clone().ok_or_else(|| {
1446                RuntimeError::ToolFailed("term.find: registry not available".into())
1447            })?;
1448            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1449            let entry = registry.lookup(&handle, &session_id)?;
1450            let screen = entry.snapshot();
1451            let start_row = extract_optional_int(&args, "start_row")
1452                .unwrap_or(0)
1453                .clamp(0, screen.rows as i64) as u16;
1454            let end_row = extract_optional_int(&args, "end_row")
1455                .unwrap_or(screen.rows as i64)
1456                .clamp(start_row as i64, screen.rows as i64) as u16;
1457
1458            let mut matches = Vec::new();
1459            for r in start_row..end_row {
1460                let row_cells: Vec<&TerminalCell> =
1461                    (0..screen.cols).map(|c| cell_ref(&screen, r, c)).collect();
1462                let row_text: String = row_cells
1463                    .iter()
1464                    .map(|c| {
1465                        if c.wide_continuation {
1466                            ""
1467                        } else {
1468                            c.chars.as_str()
1469                        }
1470                    })
1471                    .collect::<String>();
1472                let row_text_trimmed = row_text.trim_end();
1473
1474                if let Some(ref pat) = pattern {
1475                    let mut start = 0;
1476                    while let Some(pos) = row_text_trimmed[start..].find(pat) {
1477                        let abs_col = start + pos;
1478                        let style_ok = style_filter
1479                            .as_ref()
1480                            .map(|sf| sf.matches(row_cells.get(abs_col).copied()))
1481                            .unwrap_or(true);
1482                        if style_ok {
1483                            let matched_text = pat.clone();
1484                            matches.push(Value::Struct(vec![
1485                                ("row".into(), Value::Int(r as i64)),
1486                                ("col".into(), Value::Int(abs_col as i64)),
1487                                ("text".into(), Value::Str(matched_text)),
1488                            ]));
1489                        }
1490                        start += pos + pat.len();
1491                        if start >= row_text_trimmed.len() {
1492                            break;
1493                        }
1494                    }
1495                } else if let Some(ref sf) = style_filter {
1496                    let mut col = 0usize;
1497                    while col < screen.cols as usize {
1498                        if sf.matches(row_cells.get(col).copied())
1499                            && !row_cells[col].chars.is_empty()
1500                        {
1501                            let start_col = col;
1502                            let mut text = String::new();
1503                            while col < screen.cols as usize
1504                                && sf.matches(row_cells.get(col).copied())
1505                                && !row_cells[col].wide_continuation
1506                            {
1507                                text.push_str(&row_cells[col].chars);
1508                                col += 1;
1509                            }
1510                            if !text.trim().is_empty() {
1511                                matches.push(Value::Struct(vec![
1512                                    ("row".into(), Value::Int(r as i64)),
1513                                    ("col".into(), Value::Int(start_col as i64)),
1514                                    ("text".into(), Value::Str(text)),
1515                                ]));
1516                            }
1517                        } else {
1518                            col += 1;
1519                        }
1520                    }
1521                }
1522            }
1523            let count = matches.len() as i64;
1524            Ok(Value::Struct(vec![
1525                ("matches".into(), Value::List(matches)),
1526                ("count".into(), Value::Int(count)),
1527            ]))
1528        })
1529    }
1530}
1531
1532fn cell_ref(screen: &TerminalScreen, row: u16, col: u16) -> &TerminalCell {
1533    let idx = (row as usize) * (screen.cols as usize) + (col as usize);
1534    screen.cells.get(idx).unwrap_or(&DEFAULT_CELL)
1535}
1536
1537static DEFAULT_CELL: TerminalCell = TerminalCell {
1538    chars: String::new(),
1539    fg: TerminalColor::Default,
1540    bg: TerminalColor::Default,
1541    bold: false,
1542    italic: false,
1543    underline: false,
1544    inverse: false,
1545    dim: false,
1546    wide: false,
1547    wide_continuation: false,
1548};
1549
1550struct StyleFilter {
1551    bold: Option<bool>,
1552    italic: Option<bool>,
1553    underline: Option<bool>,
1554    inverse: Option<bool>,
1555    dim: Option<bool>,
1556    fg: Option<TerminalColor>,
1557    bg: Option<TerminalColor>,
1558}
1559
1560impl StyleFilter {
1561    fn matches(&self, cell: Option<&TerminalCell>) -> bool {
1562        let Some(cell) = cell else {
1563            return false;
1564        };
1565        if self.bold == Some(true) && !cell.bold {
1566            return false;
1567        }
1568        if self.bold == Some(false) && cell.bold {
1569            return false;
1570        }
1571        if self.italic == Some(true) && !cell.italic {
1572            return false;
1573        }
1574        if self.italic == Some(false) && cell.italic {
1575            return false;
1576        }
1577        if self.underline == Some(true) && !cell.underline {
1578            return false;
1579        }
1580        if self.underline == Some(false) && cell.underline {
1581            return false;
1582        }
1583        if self.inverse == Some(true) && !cell.inverse {
1584            return false;
1585        }
1586        if self.inverse == Some(false) && cell.inverse {
1587            return false;
1588        }
1589        if self.dim == Some(true) && !cell.dim {
1590            return false;
1591        }
1592        if self.dim == Some(false) && cell.dim {
1593            return false;
1594        }
1595        if let Some(fg) = &self.fg {
1596            if !color_matches(fg, &cell.fg) {
1597                return false;
1598            }
1599        }
1600        if let Some(bg) = &self.bg {
1601            if !color_matches(bg, &cell.bg) {
1602                return false;
1603            }
1604        }
1605        true
1606    }
1607}
1608
1609fn parse_style_filter(args: &crate::tool::ToolArgs) -> Result<Option<StyleFilter>, RuntimeError> {
1610    let style = match args.named("style") {
1611        Some(v) => v,
1612        None => return Ok(None),
1613    };
1614    Ok(Some(StyleFilter {
1615        bold: get_optional_bool(style, "bold"),
1616        italic: get_optional_bool(style, "italic"),
1617        underline: get_optional_bool(style, "underline"),
1618        inverse: get_optional_bool(style, "inverse"),
1619        dim: get_optional_bool(style, "dim"),
1620        fg: get_optional_color(style, "fg")?,
1621        bg: get_optional_color(style, "bg")?,
1622    }))
1623}
1624
1625fn get_optional_bool(style: &Value, field: &str) -> Option<bool> {
1626    match style.field(field) {
1627        Some(Value::Bool(b)) => Some(*b),
1628        _ => None,
1629    }
1630}
1631
1632fn get_optional_color(style: &Value, field: &str) -> Result<Option<TerminalColor>, RuntimeError> {
1633    match style.field(field) {
1634        Some(v) => parse_color(v).map(Some).ok_or_else(|| {
1635            RuntimeError::ToolFailed(format!(
1636                "term.find: invalid color for '{field}' — use name (\"red\") or {{r,g,b}} struct"
1637            ))
1638        }),
1639        None => Ok(None),
1640    }
1641}
1642
1643fn parse_color(val: &Value) -> Option<TerminalColor> {
1644    match val {
1645        Value::Str(name) => color_name_to_terminal(name),
1646        Value::Struct(fields) => {
1647            let r = fields
1648                .iter()
1649                .find(|(k, _)| k == "r")
1650                .and_then(|(_, v)| match v {
1651                    Value::Int(n) => Some(*n),
1652                    _ => None,
1653                })?;
1654            let g = fields
1655                .iter()
1656                .find(|(k, _)| k == "g")
1657                .and_then(|(_, v)| match v {
1658                    Value::Int(n) => Some(*n),
1659                    _ => None,
1660                })?;
1661            let b = fields
1662                .iter()
1663                .find(|(k, _)| k == "b")
1664                .and_then(|(_, v)| match v {
1665                    Value::Int(n) => Some(*n),
1666                    _ => None,
1667                })?;
1668            Some(TerminalColor::Rgb(r as u8, g as u8, b as u8))
1669        }
1670        _ => None,
1671    }
1672}
1673
1674fn color_name_to_terminal(name: &str) -> Option<TerminalColor> {
1675    let idx = match name {
1676        "default" => return Some(TerminalColor::Default),
1677        "black" => 0,
1678        "red" => 1,
1679        "green" => 2,
1680        "yellow" => 3,
1681        "blue" => 4,
1682        "magenta" => 5,
1683        "cyan" => 6,
1684        "white" => 7,
1685        _ => return None,
1686    };
1687    Some(TerminalColor::Idx(idx))
1688}
1689
1690fn color_matches(desired: &TerminalColor, actual: &TerminalColor) -> bool {
1691    match (desired, actual) {
1692        (TerminalColor::Default, TerminalColor::Default) => true,
1693        (TerminalColor::Idx(d), TerminalColor::Idx(a)) => *d == *a || (*d < 8 && *a == *d + 8),
1694        (TerminalColor::Rgb(dr, dg, db), TerminalColor::Rgb(ar, ag, ab)) => {
1695            dr == ar && dg == ag && db == ab
1696        }
1697        _ => false,
1698    }
1699}
1700
1701pub struct TermResize;
1702impl Tool for TermResize {
1703    fn name(&self) -> &str {
1704        "term.resize"
1705    }
1706    fn tier(&self) -> Tier {
1707        Tier::Four
1708    }
1709    fn description(&self) -> Option<&str> {
1710        Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1711    }
1712    fn input_schema(&self) -> serde_json::Value {
1713        serde_json::json!({
1714            "type": "object",
1715            "properties": {
1716                "handle": {"type": "string"},
1717                "rows": {"type": "integer"},
1718                "cols": {"type": "integer"}
1719            },
1720            "required": ["handle", "rows", "cols"]
1721        })
1722    }
1723    fn call<'a>(
1724        &'a self,
1725        args: crate::tool::ToolArgs,
1726        ctx: &'a crate::tool::ToolCtx,
1727    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1728        Box::pin(async move {
1729            let handle = extract_string(&args, "handle", 0)?;
1730            let rows = extract_optional_int(&args, "rows")
1731                .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1732                as u16;
1733            let cols = extract_optional_int(&args, "cols")
1734                .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1735                as u16;
1736            let registry = ctx.term_registry.clone().ok_or_else(|| {
1737                RuntimeError::ToolFailed("term.resize: registry not available".into())
1738            })?;
1739            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1740            let entry = registry.lookup(&handle, &session_id)?;
1741            entry.resize(rows, cols)?;
1742            Ok(Value::Struct(vec![
1743                ("ok".into(), Value::Bool(true)),
1744                ("rows".into(), Value::Int(rows as i64)),
1745                ("cols".into(), Value::Int(cols as i64)),
1746            ]))
1747        })
1748    }
1749}
1750
1751pub struct TermKill;
1752impl Tool for TermKill {
1753    fn name(&self) -> &str {
1754        "term.kill"
1755    }
1756    fn tier(&self) -> Tier {
1757        Tier::Four
1758    }
1759    fn description(&self) -> Option<&str> {
1760        Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1761    }
1762    fn input_schema(&self) -> serde_json::Value {
1763        serde_json::json!({
1764            "type": "object",
1765            "properties": {"handle": {"type": "string"}},
1766            "required": ["handle"]
1767        })
1768    }
1769    fn call<'a>(
1770        &'a self,
1771        args: crate::tool::ToolArgs,
1772        ctx: &'a crate::tool::ToolCtx,
1773    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1774        Box::pin(async move {
1775            let handle = extract_string(&args, "handle", 0)?;
1776            let registry = ctx.term_registry.clone().ok_or_else(|| {
1777                RuntimeError::ToolFailed("term.kill: registry not available".into())
1778            })?;
1779            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1780            let entry = registry.lookup(&handle, &session_id)?;
1781            {
1782                let mut child = entry.child.lock().expect("child poisoned");
1783                if let Some(child) = child.as_mut() {
1784                    let _ = child.kill();
1785                }
1786            }
1787            {
1788                let mut state = entry.state.lock().expect("state poisoned");
1789                *state = TermState::Killed { ended_at: now_ms() };
1790            }
1791            Ok(Value::Struct(vec![
1792                ("ok".into(), Value::Bool(true)),
1793                ("state".into(), Value::Str("killed".into())),
1794            ]))
1795        })
1796    }
1797}
1798
1799pub struct TermList;
1800impl Tool for TermList {
1801    fn name(&self) -> &str {
1802        "term.list"
1803    }
1804    fn tier(&self) -> Tier {
1805        Tier::Four
1806    }
1807    fn description(&self) -> Option<&str> {
1808        Some("List all terminal handles in the current session.")
1809    }
1810    fn input_schema(&self) -> serde_json::Value {
1811        serde_json::json!({
1812            "type": "object",
1813            "properties": {"all": {"type": "boolean", "default": false}}
1814        })
1815    }
1816    fn call<'a>(
1817        &'a self,
1818        args: crate::tool::ToolArgs,
1819        ctx: &'a crate::tool::ToolCtx,
1820    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1821        Box::pin(async move {
1822            let all = args
1823                .named("all")
1824                .and_then(|v| {
1825                    if let Value::Bool(b) = v {
1826                        Some(*b)
1827                    } else {
1828                        None
1829                    }
1830                })
1831                .unwrap_or(false);
1832            let registry = ctx.term_registry.clone().ok_or_else(|| {
1833                RuntimeError::ToolFailed("term.list: registry not available".into())
1834            })?;
1835            let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1836            let _ = all;
1837            let list = registry.list(&session_id);
1838            let entries: Vec<Value> = list
1839                .iter()
1840                .map(|(h, st)| {
1841                    Value::Struct(vec![
1842                        ("handle".into(), Value::Str(h.clone())),
1843                        ("state".into(), state_to_value(st)),
1844                    ])
1845                })
1846                .collect();
1847            Ok(Value::Struct(vec![(
1848                "terminals".into(),
1849                Value::List(entries),
1850            )]))
1851        })
1852    }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858
1859    #[test]
1860    fn handle_parse_roundtrip() {
1861        let h = TermHandle {
1862            session_id: "abc".into(),
1863            local_id: 7,
1864        };
1865        assert_eq!(h.to_string(), "term_abc_7");
1866        let back = TermHandle::parse("term_abc_7").unwrap();
1867        assert_eq!(back, h);
1868    }
1869
1870    #[test]
1871    fn handle_parse_rejects_bad_format() {
1872        assert!(TermHandle::parse("not_term").is_none());
1873        assert!(TermHandle::parse("term_nosuffix").is_none());
1874        assert!(TermHandle::parse("term_x_notnum").is_none());
1875    }
1876
1877    #[test]
1878    fn snapshot_screen_captures_text() {
1879        let mut parser = vt100::Parser::new(3, 5, 0);
1880        parser.process(b"hello");
1881        let screen = snapshot_screen(&parser);
1882        assert_eq!(screen.rows, 3);
1883        assert_eq!(screen.cols, 5);
1884        assert_eq!(screen.cells.len(), 15);
1885        assert_eq!(screen.cells[0].chars, "h");
1886        assert_eq!(screen.cells[4].chars, "o");
1887    }
1888
1889    #[tokio::test]
1890    async fn capture_spills_text_to_output_store_with_byte_continuation() {
1891        const ROWS: u16 = 4096;
1892        const COLS: u16 = 256;
1893        const CONTENT_COLS: usize = 255;
1894        const EXPECTED_TEXT_BYTES: usize = ROWS as usize * CONTENT_COLS + (ROWS as usize - 1);
1895
1896        let registry = Arc::new(TermRegistry::new());
1897        let handle = registry.next_handle("session_a");
1898        let row = "a".repeat(CONTENT_COLS);
1899        let expected_text = vec![row.as_str(); ROWS as usize].join("\n");
1900        assert_eq!(expected_text.len(), EXPECTED_TEXT_BYTES);
1901        assert_eq!(EXPECTED_TEXT_BYTES, 1_048_575);
1902        let terminal_input = expected_text.replace('\n', "\r\n");
1903        let mut parser = vt100::Parser::new(ROWS, COLS, 0);
1904        parser.process(terminal_input.as_bytes());
1905        let entry = Arc::new(TermEntry {
1906            handle: handle.clone(),
1907            session_id: "session_a".into(),
1908            pty_size: portable_pty::PtySize {
1909                rows: ROWS,
1910                cols: COLS,
1911                pixel_width: 0,
1912                pixel_height: 0,
1913            },
1914            parser: Arc::new(Mutex::new(parser)),
1915            writer: Mutex::new(Box::new(std::io::sink())),
1916            state: Arc::new(Mutex::new(TermState::Running {
1917                pid: 0,
1918                started_at: 0,
1919            })),
1920            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
1921            log_path: std::env::temp_dir().join("term_capture_budget.log"),
1922            reader_task: Mutex::new(None),
1923            child: Mutex::new(None),
1924            master: Mutex::new(None),
1925            started_at: Instant::now(),
1926            task_id: Mutex::new(None),
1927        });
1928        registry.insert(entry);
1929        let output_dir = tempfile::tempdir().unwrap();
1930        let mut ctx = crate::tool::ToolCtx::new()
1931            .with_term_registry(registry)
1932            .with_session_dir(output_dir.path().to_path_buf());
1933        ctx.session_id = Some("session_a".into());
1934        ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
1935            max_lines: 32,
1936            max_bytes: 64 * 1024,
1937            max_line_bytes: 64 * 1024,
1938        };
1939
1940        let first = TermCapture
1941            .call(
1942                crate::tool::ToolArgs {
1943                    positional: Vec::new(),
1944                    named: vec![("handle".into(), Value::Str(handle.to_string()))],
1945                },
1946                &ctx,
1947            )
1948            .await
1949            .unwrap();
1950        let Value::Struct(fields) = first else {
1951            panic!("expected capture fields");
1952        };
1953        assert!(matches!(
1954            fields.iter().find(|(name, _)| name == "rows"),
1955            Some((_, Value::Int(rows))) if *rows == i64::from(ROWS)
1956        ));
1957        assert!(matches!(
1958            fields.iter().find(|(name, _)| name == "total_lines"),
1959            Some((_, Value::Int(lines))) if *lines == i64::from(ROWS)
1960        ));
1961        assert!(matches!(
1962            fields.iter().find(|(name, _)| name == "total_bytes"),
1963            Some((_, Value::Int(bytes))) if *bytes == EXPECTED_TEXT_BYTES as i64
1964        ));
1965        assert!(!fields.iter().any(|(name, _)| name == "continuation"));
1966        assert!(!fields.iter().any(|(name, _)| name == "TerminalArea"));
1967        assert!(!fields.iter().any(|(name, _)| name == "next_row"));
1968        assert!(!fields.iter().any(|(name, _)| name == "next_col"));
1969        assert!(!fields.iter().any(|(name, _)| name == "text"));
1970        let content = fields.iter().find_map(|(name, value)| {
1971            (name == "content").then(|| match value {
1972                Value::Str(text) => text.as_str(),
1973                _ => panic!("expected content string"),
1974            })
1975        });
1976        let Value::Str(output_id) = fields
1977            .iter()
1978            .find(|(name, _)| name == "output_id")
1979            .map(|(_, value)| value)
1980            .unwrap()
1981        else {
1982            panic!("expected output_id");
1983        };
1984        let Value::Struct(next) = fields
1985            .iter()
1986            .find(|(name, _)| name == "next")
1987            .map(|(_, value)| value)
1988            .unwrap()
1989        else {
1990            panic!("expected byte continuation");
1991        };
1992        let offset = next
1993            .iter()
1994            .find_map(|(name, value)| (name == "offset").then_some(value))
1995            .and_then(|value| match value {
1996                Value::Int(offset) => Some(*offset as usize),
1997                _ => None,
1998            })
1999            .unwrap();
2000        assert!(matches!(
2001            next.iter().find(|(name, _)| name == "mode"),
2002            Some((_, Value::Str(mode))) if mode == "bytes"
2003        ));
2004        let store = ctx.output_store.as_deref().unwrap();
2005        let mut reconstructed = content.unwrap().to_string();
2006        let mut page_offset = offset;
2007        loop {
2008            let page = store
2009                .read_bytes(output_id, page_offset, usize::MAX, ctx.tool_output_budget)
2010                .unwrap();
2011            assert_eq!(page.offset, page_offset);
2012            reconstructed.push_str(&page.content);
2013            if !page.has_more {
2014                break;
2015            }
2016            assert!(page.next_offset > page_offset);
2017            page_offset = page.next_offset;
2018        }
2019        assert_eq!(reconstructed.len(), EXPECTED_TEXT_BYTES);
2020        assert_eq!(reconstructed, expected_text);
2021
2022        let untruncated = TermCapture
2023            .call(
2024                crate::tool::ToolArgs {
2025                    positional: Vec::new(),
2026                    named: vec![
2027                        ("handle".into(), Value::Str(handle.to_string())),
2028                        ("end_row".into(), Value::Int(1)),
2029                    ],
2030                },
2031                &ctx,
2032            )
2033            .await
2034            .unwrap();
2035        let Value::Struct(fields) = untruncated else {
2036            panic!("expected capture fields");
2037        };
2038        assert!(matches!(
2039            fields.iter().find(|(name, _)| name == "text"),
2040            Some((_, Value::Str(text))) if text == &row
2041        ));
2042        assert!(!fields.iter().any(|(name, _)| name == "continuation"));
2043        assert!(!fields.iter().any(|(name, _)| name == "output_id"));
2044        assert!(!fields.iter().any(|(name, _)| name == "next"));
2045
2046        let screen_capture = TermCapture
2047            .call(
2048                crate::tool::ToolArgs {
2049                    positional: Vec::new(),
2050                    named: vec![
2051                        ("handle".into(), Value::Str(handle.to_string())),
2052                        ("format".into(), Value::Str("screen".into())),
2053                    ],
2054                },
2055                &ctx,
2056            )
2057            .await
2058            .unwrap();
2059        let Value::Struct(fields) = screen_capture else {
2060            panic!("expected capture fields");
2061        };
2062        let Value::Struct(continuation) = fields
2063            .iter()
2064            .find(|(name, _)| name == "continuation")
2065            .map(|(_, value)| value)
2066            .unwrap()
2067        else {
2068            panic!("expected screen continuation");
2069        };
2070        assert!(matches!(
2071            continuation.iter().find(|(name, _)| name == "type"),
2072            Some((_, Value::Str(kind))) if kind == "TerminalArea"
2073        ));
2074        assert!(continuation.iter().any(|(name, _)| name == "next_row"));
2075        assert!(fields.iter().any(|(name, _)| name == "screen"));
2076    }
2077
2078    #[test]
2079    fn registry_lookup_rejects_cross_session() {
2080        let registry = Arc::new(TermRegistry::new());
2081        let h = registry.next_handle("session_a");
2082        let entry = Arc::new(TermEntry {
2083            handle: h.clone(),
2084            session_id: "session_a".into(),
2085            pty_size: portable_pty::PtySize {
2086                rows: 24,
2087                cols: 80,
2088                pixel_width: 0,
2089                pixel_height: 0,
2090            },
2091            parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
2092            writer: Mutex::new(Box::new(std::io::sink())),
2093            state: Arc::new(Mutex::new(TermState::Running {
2094                pid: 0,
2095                started_at: 0,
2096            })),
2097            stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
2098            log_path: std::env::temp_dir().join("term_test_dummy.log"),
2099            reader_task: Mutex::new(None),
2100            child: Mutex::new(None),
2101            master: Mutex::new(None),
2102            started_at: Instant::now(),
2103            task_id: Mutex::new(None),
2104        });
2105        registry.insert(entry);
2106        assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
2107        assert!(registry.lookup(&h.to_string(), "session_b").is_err());
2108    }
2109
2110    fn make_screen(rows: u16, cols: u16, text: &str) -> TerminalScreen {
2111        let mut parser = vt100::Parser::new(rows, cols, 0);
2112        parser.process(text.as_bytes());
2113        snapshot_screen(&parser)
2114    }
2115
2116    #[test]
2117    fn cell_text_returns_chars_for_filled_cell() {
2118        let screen = make_screen(1, 5, "hello");
2119        assert_eq!(cell_text(&screen, 0, 0), "h");
2120        assert_eq!(cell_text(&screen, 0, 4), "o");
2121    }
2122
2123    #[test]
2124    fn cell_text_returns_space_for_empty_cell() {
2125        let screen = make_screen(1, 5, "hi");
2126        assert_eq!(cell_text(&screen, 0, 2), " ");
2127    }
2128
2129    #[test]
2130    fn find_pattern_returns_position() {
2131        let screen = make_screen(2, 10, "hello\r\nworld");
2132        let pos = find_pattern_on_screen(&screen, "world");
2133        assert_eq!(pos, Some((1, 0)));
2134    }
2135
2136    #[test]
2137    fn find_pattern_returns_none_when_absent() {
2138        let screen = make_screen(1, 5, "hello");
2139        assert!(find_pattern_on_screen(&screen, "xyz").is_none());
2140    }
2141
2142    #[test]
2143    fn color_name_maps_to_ansi_idx() {
2144        assert_eq!(color_name_to_terminal("red"), Some(TerminalColor::Idx(1)));
2145        assert_eq!(
2146            color_name_to_terminal("default"),
2147            Some(TerminalColor::Default)
2148        );
2149        assert_eq!(color_name_to_terminal("nonexistent"), None);
2150    }
2151
2152    #[test]
2153    fn color_matches_bright_variant_to_base_name() {
2154        let desired = TerminalColor::Idx(1);
2155        let actual_bright = TerminalColor::Idx(9);
2156        assert!(color_matches(&desired, &actual_bright));
2157        let actual_normal = TerminalColor::Idx(1);
2158        assert!(color_matches(&desired, &actual_normal));
2159    }
2160
2161    #[test]
2162    fn color_matches_rgb_exact() {
2163        let desired = TerminalColor::Rgb(255, 128, 0);
2164        let actual = TerminalColor::Rgb(255, 128, 0);
2165        assert!(color_matches(&desired, &actual));
2166        let wrong = TerminalColor::Rgb(255, 128, 1);
2167        assert!(!color_matches(&desired, &wrong));
2168    }
2169
2170    #[test]
2171    fn parse_color_accepts_name_and_rgb() {
2172        assert_eq!(
2173            parse_color(&Value::Str("blue".into())),
2174            Some(TerminalColor::Idx(4))
2175        );
2176        let rgb = Value::Struct(vec![
2177            ("r".into(), Value::Int(10)),
2178            ("g".into(), Value::Int(20)),
2179            ("b".into(), Value::Int(30)),
2180        ]);
2181        assert_eq!(parse_color(&rgb), Some(TerminalColor::Rgb(10, 20, 30)));
2182    }
2183}