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 end_row = extract_optional_int(&args, "end_row")
1270 .unwrap_or(screen.rows as i64)
1271 .clamp(start_row as i64, screen.rows as i64) as u16;
1272 let start_col = extract_optional_int(&args, "start_col")
1273 .unwrap_or(0)
1274 .clamp(0, screen.cols as i64) as u16;
1275 let end_col = extract_optional_int(&args, "end_col")
1276 .unwrap_or(screen.cols as i64)
1277 .clamp(start_col as i64, screen.cols as i64) as u16;
1278 let state = entry.current_state();
1279 let mut fields = vec![
1280 ("handle".into(), Value::Str(handle.clone())),
1281 ("state".into(), state_to_value(&state)),
1282 ("rows".into(), Value::Int((end_row - start_row) as i64)),
1283 ("cols".into(), Value::Int((end_col - start_col) as i64)),
1284 ];
1285 if format == "screen" {
1286 let sub_rows = end_row - start_row;
1287 let sub_cols = end_col - start_col;
1288 let sub_cells: Vec<TerminalCell> = (start_row..end_row)
1289 .flat_map(|r| {
1290 (start_col..end_col)
1291 .map(|c| {
1292 screen
1293 .cells
1294 .get((r as usize) * (screen.cols as usize) + (c as usize))
1295 .cloned()
1296 .unwrap_or_default()
1297 })
1298 .collect::<Vec<_>>()
1299 })
1300 .collect();
1301 let cursor = screen.cursor.and_then(|(row, col)| {
1302 if (start_row..end_row).contains(&row) && (start_col..end_col).contains(&col) {
1303 Some((row - start_row, col - start_col))
1304 } else {
1305 None
1306 }
1307 });
1308 let partial_screen = TerminalScreen {
1309 rows: sub_rows,
1310 cols: sub_cols,
1311 cells: sub_cells,
1312 cursor,
1313 alt_screen: screen.alt_screen,
1314 };
1315 fields.push(("screen".into(), screen_to_value(&partial_screen)));
1316 } else {
1317 let text = (start_row..end_row)
1318 .map(|r| {
1319 let row_text: String = (start_col..end_col)
1320 .map(|c| cell_text(&screen, r, c))
1321 .collect();
1322 row_text.trim_end().to_string()
1323 })
1324 .collect::<Vec<_>>()
1325 .join("\n");
1326 fields.push(("text".into(), Value::Str(text)));
1327 }
1328 Ok(Value::Struct(fields))
1329 })
1330 }
1331}
1332
1333pub struct TermFind;
1334impl Tool for TermFind {
1335 fn name(&self) -> &str {
1336 "term.find"
1337 }
1338 fn tier(&self) -> Tier {
1339 Tier::Four
1340 }
1341 fn description(&self) -> Option<&str> {
1342 Some(
1343 "Search terminal screen for text and/or style. Returns list of matches.\n\n\
1344 - pattern: substring to search for (case-sensitive). Omit for style-only search.\n\
1345 - style: optional filter. Only cells matching ALL specified style fields count.\n\
1346 - Combine both: find red 'error' text, bold prompts, etc.\n\n\
1347 Style fields: bold, italic, underline, inverse, dim, fg, bg.\n\
1348 Colors: name (\"red\") or {r,g,b} struct (discover via term.capture format=\"screen\").\n\n\
1349 Returns: { matches: [{row, col, text}], count: N }",
1350 )
1351 }
1352 fn input_schema(&self) -> serde_json::Value {
1353 serde_json::json!({
1354 "type": "object",
1355 "properties": {
1356 "handle": {"type": "string"},
1357 "pattern": {"type": "string", "description": "Substring to search for. Omit for style-only search."},
1358 "style": {
1359 "type": "object",
1360 "properties": {
1361 "bold": {"type": "boolean"},
1362 "italic": {"type": "boolean"},
1363 "underline": {"type": "boolean"},
1364 "inverse": {"type": "boolean"},
1365 "dim": {"type": "boolean"},
1366 "fg": {"type": "string", "description": "Color name or {r,g,b} struct"},
1367 "bg": {"type": "string", "description": "Color name or {r,g,b} struct"}
1368 },
1369 "description": "Optional style filter. All specified fields must match."
1370 },
1371 "start_row": {"type": "integer", "default": 0},
1372 "end_row": {"type": "integer", "description": "Exclusive. Default: full height."}
1373 },
1374 "required": ["handle"]
1375 })
1376 }
1377 fn call<'a>(
1378 &'a self,
1379 args: crate::tool::ToolArgs,
1380 ctx: &'a crate::tool::ToolCtx,
1381 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1382 Box::pin(async move {
1383 let handle = extract_string(&args, "handle", 0)?;
1384 let pattern = extract_optional_string(&args, "pattern");
1385 let style_filter = parse_style_filter(&args)?;
1386 if pattern.is_none() && style_filter.is_none() {
1387 return Err(RuntimeError::ToolFailed(
1388 "term.find: provide at least pattern or style".into(),
1389 ));
1390 }
1391 let registry = ctx.term_registry.clone().ok_or_else(|| {
1392 RuntimeError::ToolFailed("term.find: registry not available".into())
1393 })?;
1394 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1395 let entry = registry.lookup(&handle, &session_id)?;
1396 let screen = entry.snapshot();
1397 let start_row = extract_optional_int(&args, "start_row")
1398 .unwrap_or(0)
1399 .clamp(0, screen.rows as i64) as u16;
1400 let end_row = extract_optional_int(&args, "end_row")
1401 .unwrap_or(screen.rows as i64)
1402 .clamp(start_row as i64, screen.rows as i64) as u16;
1403
1404 let mut matches = Vec::new();
1405 for r in start_row..end_row {
1406 let row_cells: Vec<&TerminalCell> =
1407 (0..screen.cols).map(|c| cell_ref(&screen, r, c)).collect();
1408 let row_text: String = row_cells
1409 .iter()
1410 .map(|c| {
1411 if c.wide_continuation {
1412 ""
1413 } else {
1414 c.chars.as_str()
1415 }
1416 })
1417 .collect::<String>();
1418 let row_text_trimmed = row_text.trim_end();
1419
1420 if let Some(ref pat) = pattern {
1421 let mut start = 0;
1422 while let Some(pos) = row_text_trimmed[start..].find(pat) {
1423 let abs_col = start + pos;
1424 let style_ok = style_filter
1425 .as_ref()
1426 .map(|sf| sf.matches(row_cells.get(abs_col).copied()))
1427 .unwrap_or(true);
1428 if style_ok {
1429 let matched_text = pat.clone();
1430 matches.push(Value::Struct(vec![
1431 ("row".into(), Value::Int(r as i64)),
1432 ("col".into(), Value::Int(abs_col as i64)),
1433 ("text".into(), Value::Str(matched_text)),
1434 ]));
1435 }
1436 start += pos + pat.len();
1437 if start >= row_text_trimmed.len() {
1438 break;
1439 }
1440 }
1441 } else if let Some(ref sf) = style_filter {
1442 let mut col = 0usize;
1443 while col < screen.cols as usize {
1444 if sf.matches(row_cells.get(col).copied())
1445 && !row_cells[col].chars.is_empty()
1446 {
1447 let start_col = col;
1448 let mut text = String::new();
1449 while col < screen.cols as usize
1450 && sf.matches(row_cells.get(col).copied())
1451 && !row_cells[col].wide_continuation
1452 {
1453 text.push_str(&row_cells[col].chars);
1454 col += 1;
1455 }
1456 if !text.trim().is_empty() {
1457 matches.push(Value::Struct(vec![
1458 ("row".into(), Value::Int(r as i64)),
1459 ("col".into(), Value::Int(start_col as i64)),
1460 ("text".into(), Value::Str(text)),
1461 ]));
1462 }
1463 } else {
1464 col += 1;
1465 }
1466 }
1467 }
1468 }
1469 let count = matches.len() as i64;
1470 Ok(Value::Struct(vec![
1471 ("matches".into(), Value::List(matches)),
1472 ("count".into(), Value::Int(count)),
1473 ]))
1474 })
1475 }
1476}
1477
1478fn cell_ref(screen: &TerminalScreen, row: u16, col: u16) -> &TerminalCell {
1479 let idx = (row as usize) * (screen.cols as usize) + (col as usize);
1480 screen.cells.get(idx).unwrap_or(&DEFAULT_CELL)
1481}
1482
1483static DEFAULT_CELL: TerminalCell = TerminalCell {
1484 chars: String::new(),
1485 fg: TerminalColor::Default,
1486 bg: TerminalColor::Default,
1487 bold: false,
1488 italic: false,
1489 underline: false,
1490 inverse: false,
1491 dim: false,
1492 wide: false,
1493 wide_continuation: false,
1494};
1495
1496struct StyleFilter {
1497 bold: Option<bool>,
1498 italic: Option<bool>,
1499 underline: Option<bool>,
1500 inverse: Option<bool>,
1501 dim: Option<bool>,
1502 fg: Option<TerminalColor>,
1503 bg: Option<TerminalColor>,
1504}
1505
1506impl StyleFilter {
1507 fn matches(&self, cell: Option<&TerminalCell>) -> bool {
1508 let Some(cell) = cell else {
1509 return false;
1510 };
1511 if self.bold == Some(true) && !cell.bold {
1512 return false;
1513 }
1514 if self.bold == Some(false) && cell.bold {
1515 return false;
1516 }
1517 if self.italic == Some(true) && !cell.italic {
1518 return false;
1519 }
1520 if self.italic == Some(false) && cell.italic {
1521 return false;
1522 }
1523 if self.underline == Some(true) && !cell.underline {
1524 return false;
1525 }
1526 if self.underline == Some(false) && cell.underline {
1527 return false;
1528 }
1529 if self.inverse == Some(true) && !cell.inverse {
1530 return false;
1531 }
1532 if self.inverse == Some(false) && cell.inverse {
1533 return false;
1534 }
1535 if self.dim == Some(true) && !cell.dim {
1536 return false;
1537 }
1538 if self.dim == Some(false) && cell.dim {
1539 return false;
1540 }
1541 if let Some(fg) = &self.fg {
1542 if !color_matches(fg, &cell.fg) {
1543 return false;
1544 }
1545 }
1546 if let Some(bg) = &self.bg {
1547 if !color_matches(bg, &cell.bg) {
1548 return false;
1549 }
1550 }
1551 true
1552 }
1553}
1554
1555fn parse_style_filter(args: &crate::tool::ToolArgs) -> Result<Option<StyleFilter>, RuntimeError> {
1556 let style = match args.named("style") {
1557 Some(v) => v,
1558 None => return Ok(None),
1559 };
1560 Ok(Some(StyleFilter {
1561 bold: get_optional_bool(style, "bold"),
1562 italic: get_optional_bool(style, "italic"),
1563 underline: get_optional_bool(style, "underline"),
1564 inverse: get_optional_bool(style, "inverse"),
1565 dim: get_optional_bool(style, "dim"),
1566 fg: get_optional_color(style, "fg")?,
1567 bg: get_optional_color(style, "bg")?,
1568 }))
1569}
1570
1571fn get_optional_bool(style: &Value, field: &str) -> Option<bool> {
1572 match style.field(field) {
1573 Some(Value::Bool(b)) => Some(*b),
1574 _ => None,
1575 }
1576}
1577
1578fn get_optional_color(style: &Value, field: &str) -> Result<Option<TerminalColor>, RuntimeError> {
1579 match style.field(field) {
1580 Some(v) => parse_color(v).map(Some).ok_or_else(|| {
1581 RuntimeError::ToolFailed(format!(
1582 "term.find: invalid color for '{field}' — use name (\"red\") or {{r,g,b}} struct"
1583 ))
1584 }),
1585 None => Ok(None),
1586 }
1587}
1588
1589fn parse_color(val: &Value) -> Option<TerminalColor> {
1590 match val {
1591 Value::Str(name) => color_name_to_terminal(name),
1592 Value::Struct(fields) => {
1593 let r = fields
1594 .iter()
1595 .find(|(k, _)| k == "r")
1596 .and_then(|(_, v)| match v {
1597 Value::Int(n) => Some(*n),
1598 _ => None,
1599 })?;
1600 let g = fields
1601 .iter()
1602 .find(|(k, _)| k == "g")
1603 .and_then(|(_, v)| match v {
1604 Value::Int(n) => Some(*n),
1605 _ => None,
1606 })?;
1607 let b = fields
1608 .iter()
1609 .find(|(k, _)| k == "b")
1610 .and_then(|(_, v)| match v {
1611 Value::Int(n) => Some(*n),
1612 _ => None,
1613 })?;
1614 Some(TerminalColor::Rgb(r as u8, g as u8, b as u8))
1615 }
1616 _ => None,
1617 }
1618}
1619
1620fn color_name_to_terminal(name: &str) -> Option<TerminalColor> {
1621 let idx = match name {
1622 "default" => return Some(TerminalColor::Default),
1623 "black" => 0,
1624 "red" => 1,
1625 "green" => 2,
1626 "yellow" => 3,
1627 "blue" => 4,
1628 "magenta" => 5,
1629 "cyan" => 6,
1630 "white" => 7,
1631 _ => return None,
1632 };
1633 Some(TerminalColor::Idx(idx))
1634}
1635
1636fn color_matches(desired: &TerminalColor, actual: &TerminalColor) -> bool {
1637 match (desired, actual) {
1638 (TerminalColor::Default, TerminalColor::Default) => true,
1639 (TerminalColor::Idx(d), TerminalColor::Idx(a)) => *d == *a || (*d < 8 && *a == *d + 8),
1640 (TerminalColor::Rgb(dr, dg, db), TerminalColor::Rgb(ar, ag, ab)) => {
1641 dr == ar && dg == ag && db == ab
1642 }
1643 _ => false,
1644 }
1645}
1646
1647pub struct TermResize;
1648impl Tool for TermResize {
1649 fn name(&self) -> &str {
1650 "term.resize"
1651 }
1652 fn tier(&self) -> Tier {
1653 Tier::Four
1654 }
1655 fn description(&self) -> Option<&str> {
1656 Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1657 }
1658 fn input_schema(&self) -> serde_json::Value {
1659 serde_json::json!({
1660 "type": "object",
1661 "properties": {
1662 "handle": {"type": "string"},
1663 "rows": {"type": "integer"},
1664 "cols": {"type": "integer"}
1665 },
1666 "required": ["handle", "rows", "cols"]
1667 })
1668 }
1669 fn call<'a>(
1670 &'a self,
1671 args: crate::tool::ToolArgs,
1672 ctx: &'a crate::tool::ToolCtx,
1673 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1674 Box::pin(async move {
1675 let handle = extract_string(&args, "handle", 0)?;
1676 let rows = extract_optional_int(&args, "rows")
1677 .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1678 as u16;
1679 let cols = extract_optional_int(&args, "cols")
1680 .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1681 as u16;
1682 let registry = ctx.term_registry.clone().ok_or_else(|| {
1683 RuntimeError::ToolFailed("term.resize: registry not available".into())
1684 })?;
1685 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1686 let entry = registry.lookup(&handle, &session_id)?;
1687 entry.resize(rows, cols)?;
1688 Ok(Value::Struct(vec![
1689 ("ok".into(), Value::Bool(true)),
1690 ("rows".into(), Value::Int(rows as i64)),
1691 ("cols".into(), Value::Int(cols as i64)),
1692 ]))
1693 })
1694 }
1695}
1696
1697pub struct TermKill;
1698impl Tool for TermKill {
1699 fn name(&self) -> &str {
1700 "term.kill"
1701 }
1702 fn tier(&self) -> Tier {
1703 Tier::Four
1704 }
1705 fn description(&self) -> Option<&str> {
1706 Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1707 }
1708 fn input_schema(&self) -> serde_json::Value {
1709 serde_json::json!({
1710 "type": "object",
1711 "properties": {"handle": {"type": "string"}},
1712 "required": ["handle"]
1713 })
1714 }
1715 fn call<'a>(
1716 &'a self,
1717 args: crate::tool::ToolArgs,
1718 ctx: &'a crate::tool::ToolCtx,
1719 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1720 Box::pin(async move {
1721 let handle = extract_string(&args, "handle", 0)?;
1722 let registry = ctx.term_registry.clone().ok_or_else(|| {
1723 RuntimeError::ToolFailed("term.kill: registry not available".into())
1724 })?;
1725 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1726 let entry = registry.lookup(&handle, &session_id)?;
1727 {
1728 let mut child = entry.child.lock().expect("child poisoned");
1729 if let Some(child) = child.as_mut() {
1730 let _ = child.kill();
1731 }
1732 }
1733 {
1734 let mut state = entry.state.lock().expect("state poisoned");
1735 *state = TermState::Killed { ended_at: now_ms() };
1736 }
1737 Ok(Value::Struct(vec![
1738 ("ok".into(), Value::Bool(true)),
1739 ("state".into(), Value::Str("killed".into())),
1740 ]))
1741 })
1742 }
1743}
1744
1745pub struct TermList;
1746impl Tool for TermList {
1747 fn name(&self) -> &str {
1748 "term.list"
1749 }
1750 fn tier(&self) -> Tier {
1751 Tier::Four
1752 }
1753 fn description(&self) -> Option<&str> {
1754 Some("List all terminal handles in the current session.")
1755 }
1756 fn input_schema(&self) -> serde_json::Value {
1757 serde_json::json!({
1758 "type": "object",
1759 "properties": {"all": {"type": "boolean", "default": false}}
1760 })
1761 }
1762 fn call<'a>(
1763 &'a self,
1764 args: crate::tool::ToolArgs,
1765 ctx: &'a crate::tool::ToolCtx,
1766 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1767 Box::pin(async move {
1768 let all = args
1769 .named("all")
1770 .and_then(|v| {
1771 if let Value::Bool(b) = v {
1772 Some(*b)
1773 } else {
1774 None
1775 }
1776 })
1777 .unwrap_or(false);
1778 let registry = ctx.term_registry.clone().ok_or_else(|| {
1779 RuntimeError::ToolFailed("term.list: registry not available".into())
1780 })?;
1781 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1782 let _ = all;
1783 let list = registry.list(&session_id);
1784 let entries: Vec<Value> = list
1785 .iter()
1786 .map(|(h, st)| {
1787 Value::Struct(vec![
1788 ("handle".into(), Value::Str(h.clone())),
1789 ("state".into(), state_to_value(st)),
1790 ])
1791 })
1792 .collect();
1793 Ok(Value::Struct(vec![(
1794 "terminals".into(),
1795 Value::List(entries),
1796 )]))
1797 })
1798 }
1799}
1800
1801#[cfg(test)]
1802mod tests {
1803 use super::*;
1804
1805 #[test]
1806 fn handle_parse_roundtrip() {
1807 let h = TermHandle {
1808 session_id: "abc".into(),
1809 local_id: 7,
1810 };
1811 assert_eq!(h.to_string(), "term_abc_7");
1812 let back = TermHandle::parse("term_abc_7").unwrap();
1813 assert_eq!(back, h);
1814 }
1815
1816 #[test]
1817 fn handle_parse_rejects_bad_format() {
1818 assert!(TermHandle::parse("not_term").is_none());
1819 assert!(TermHandle::parse("term_nosuffix").is_none());
1820 assert!(TermHandle::parse("term_x_notnum").is_none());
1821 }
1822
1823 #[test]
1824 fn snapshot_screen_captures_text() {
1825 let mut parser = vt100::Parser::new(3, 5, 0);
1826 parser.process(b"hello");
1827 let screen = snapshot_screen(&parser);
1828 assert_eq!(screen.rows, 3);
1829 assert_eq!(screen.cols, 5);
1830 assert_eq!(screen.cells.len(), 15);
1831 assert_eq!(screen.cells[0].chars, "h");
1832 assert_eq!(screen.cells[4].chars, "o");
1833 }
1834
1835 #[test]
1836 fn registry_lookup_rejects_cross_session() {
1837 let registry = Arc::new(TermRegistry::new());
1838 let h = registry.next_handle("session_a");
1839 let entry = Arc::new(TermEntry {
1840 handle: h.clone(),
1841 session_id: "session_a".into(),
1842 pty_size: portable_pty::PtySize {
1843 rows: 24,
1844 cols: 80,
1845 pixel_width: 0,
1846 pixel_height: 0,
1847 },
1848 parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
1849 writer: Mutex::new(Box::new(std::io::sink())),
1850 state: Arc::new(Mutex::new(TermState::Running {
1851 pid: 0,
1852 started_at: 0,
1853 })),
1854 stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
1855 log_path: std::env::temp_dir().join("term_test_dummy.log"),
1856 reader_task: Mutex::new(None),
1857 child: Mutex::new(None),
1858 master: Mutex::new(None),
1859 started_at: Instant::now(),
1860 task_id: Mutex::new(None),
1861 });
1862 registry.insert(entry);
1863 assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
1864 assert!(registry.lookup(&h.to_string(), "session_b").is_err());
1865 }
1866
1867 fn make_screen(rows: u16, cols: u16, text: &str) -> TerminalScreen {
1868 let mut parser = vt100::Parser::new(rows, cols, 0);
1869 parser.process(text.as_bytes());
1870 snapshot_screen(&parser)
1871 }
1872
1873 #[test]
1874 fn cell_text_returns_chars_for_filled_cell() {
1875 let screen = make_screen(1, 5, "hello");
1876 assert_eq!(cell_text(&screen, 0, 0), "h");
1877 assert_eq!(cell_text(&screen, 0, 4), "o");
1878 }
1879
1880 #[test]
1881 fn cell_text_returns_space_for_empty_cell() {
1882 let screen = make_screen(1, 5, "hi");
1883 assert_eq!(cell_text(&screen, 0, 2), " ");
1884 }
1885
1886 #[test]
1887 fn find_pattern_returns_position() {
1888 let screen = make_screen(2, 10, "hello\r\nworld");
1889 let pos = find_pattern_on_screen(&screen, "world");
1890 assert_eq!(pos, Some((1, 0)));
1891 }
1892
1893 #[test]
1894 fn find_pattern_returns_none_when_absent() {
1895 let screen = make_screen(1, 5, "hello");
1896 assert!(find_pattern_on_screen(&screen, "xyz").is_none());
1897 }
1898
1899 #[test]
1900 fn color_name_maps_to_ansi_idx() {
1901 assert_eq!(color_name_to_terminal("red"), Some(TerminalColor::Idx(1)));
1902 assert_eq!(
1903 color_name_to_terminal("default"),
1904 Some(TerminalColor::Default)
1905 );
1906 assert_eq!(color_name_to_terminal("nonexistent"), None);
1907 }
1908
1909 #[test]
1910 fn color_matches_bright_variant_to_base_name() {
1911 let desired = TerminalColor::Idx(1);
1912 let actual_bright = TerminalColor::Idx(9);
1913 assert!(color_matches(&desired, &actual_bright));
1914 let actual_normal = TerminalColor::Idx(1);
1915 assert!(color_matches(&desired, &actual_normal));
1916 }
1917
1918 #[test]
1919 fn color_matches_rgb_exact() {
1920 let desired = TerminalColor::Rgb(255, 128, 0);
1921 let actual = TerminalColor::Rgb(255, 128, 0);
1922 assert!(color_matches(&desired, &actual));
1923 let wrong = TerminalColor::Rgb(255, 128, 1);
1924 assert!(!color_matches(&desired, &wrong));
1925 }
1926
1927 #[test]
1928 fn parse_color_accepts_name_and_rgb() {
1929 assert_eq!(
1930 parse_color(&Value::Str("blue".into())),
1931 Some(TerminalColor::Idx(4))
1932 );
1933 let rgb = Value::Struct(vec![
1934 ("r".into(), Value::Int(10)),
1935 ("g".into(), Value::Int(20)),
1936 ("b".into(), Value::Int(30)),
1937 ]);
1938 assert_eq!(parse_color(&rgb), Some(TerminalColor::Rgb(10, 20, 30)));
1939 }
1940}