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
185pub 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 if matches!(*s, TermState::Running { .. }) {
529 *s = TermState::Exited {
530 exit_code,
531 ended_at: now_ms(),
532 };
533 }
534 }
535
536 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 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 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
1078fn 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
1091fn 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}