1use crossterm::{
7 cursor::{Hide, Show},
8 event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
9 execute,
10 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
11};
12use ratatui::{
13 Frame, Terminal,
14 backend::CrosstermBackend,
15 layout::{Constraint, Direction, Layout, Rect},
16 style::{Color, Modifier, Style},
17 text::Line,
18 widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
19};
20use std::{
21 collections::{BTreeMap, VecDeque},
22 future::Future,
23 io,
24 path::PathBuf,
25 sync::{
26 Arc,
27 atomic::{AtomicBool, Ordering},
28 },
29 thread,
30 time::Duration,
31};
32use tokio::{
33 sync::{mpsc, watch},
34 task::{JoinHandle, LocalSet},
35 time::{self, MissedTickBehavior},
36};
37
38use crate::browser::policy::BrowserPolicy;
39use crate::browser::profile::ProfileManager;
40use crate::browser::session::{
41 ActionOutcome, BrowserResult, BrowserSession, KnowledgeStore, PageContext, PageInfo,
42 SemanticIntentExecutionRequest, SemanticIntentExecutionResult, SemanticIntentRequest,
43 SemanticIntentResult, SemanticObservation, SemanticObservationLevel, SessionOptions,
44 WorkflowDefinition, default_knowledge_store_path,
45};
46use crate::capabilities::GlassCapabilityManifest;
47use crate::cli::args::Cli;
48
49const INPUT_CHANNEL_CAPACITY: usize = 64;
50const BROWSER_COMMAND_CHANNEL_CAPACITY: usize = 8;
51const BROWSER_EVENT_CHANNEL_CAPACITY: usize = 8;
52const ACTIVITY_LIMIT: usize = 100;
53const TUI_PAGE_MAX_BYTES: usize = 24 * 1024;
54const TUI_HEADER_MAX_BYTES: usize = 512;
55const TUI_ACTIVITY_MAX_BYTES: usize = 512;
56const TUI_INPUT_MAX_BYTES: usize = 4 * 1024;
57const BUSY_TICK: Duration = Duration::from_millis(120);
58const INPUT_POLL: Duration = Duration::from_millis(50);
59const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
60
61pub struct App {
62 url: String,
63 title: String,
64 activity: VecDeque<String>,
65 page_content: String,
66 page_scroll: u16,
67 input: String,
68 cursor_pos: usize,
69 should_quit: bool,
70 error_msg: Option<String>,
71 status: String,
72 capability_summary: String,
73 intent_request: Option<SemanticIntentRequest>,
74 intent_result: Option<SemanticIntentResult>,
75 intent_selection: usize,
76 knowledge_path: PathBuf,
77 browser_state: BrowserState,
78 busy: Option<BusyState>,
79 next_operation_id: u64,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum BrowserState {
84 Connecting,
85 Ready,
86 Unavailable,
87 Stopped,
88}
89
90#[derive(Debug, Clone)]
91struct BusyState {
92 id: u64,
93 label: String,
94 cancelling: bool,
95 spinner: usize,
96}
97
98#[derive(Debug, PartialEq, Eq)]
99enum UiIntent {
100 None,
101 Submit(String),
102 Cancel(u64),
103 Quit,
104}
105
106impl App {
107 fn new() -> Self {
108 let mut activity = VecDeque::new();
109 activity.push_back("Glass started.".to_string());
110 activity.push_back("Connecting to Chrome…".to_string());
111 Self {
112 url: String::new(),
113 title: "Glass — Browser Agent".to_string(),
114 activity,
115 page_content: "No page loaded.".to_string(),
116 page_scroll: 0,
117 input: String::new(),
118 cursor_pos: 0,
119 should_quit: false,
120 error_msg: None,
121 status: "Connecting to Chrome…".to_string(),
122 capability_summary: "Capabilities: loading".to_string(),
123 intent_request: None,
124 intent_result: None,
125 intent_selection: 0,
126 knowledge_path: default_knowledge_store_path("default"),
127 browser_state: BrowserState::Connecting,
128 busy: None,
129 next_operation_id: 1,
130 }
131 }
132
133 fn add_activity(&mut self, message: impl Into<String>) {
134 let message = bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES);
135 if self.activity.len() == ACTIVITY_LIMIT {
136 self.activity.pop_front();
137 }
138 self.activity.push_back(message);
139 }
140
141 fn set_error(&mut self, message: impl Into<String>) {
142 self.error_msg = Some(bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES));
143 }
144
145 fn report_error(&mut self, message: impl Into<String>) {
146 let message = bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES);
147 self.set_error(message.clone());
148 self.add_activity(format!("Error: {message}"));
149 }
150
151 fn clear_error(&mut self) {
152 self.error_msg = None;
153 }
154
155 fn set_status(&mut self, status: impl Into<String>) {
156 self.status = bounded_text(&status.into(), TUI_ACTIVITY_MAX_BYTES);
157 }
158
159 fn cursor_byte_index(&self) -> usize {
160 self.input
161 .char_indices()
162 .nth(self.cursor_pos)
163 .map(|(index, _)| index)
164 .unwrap_or(self.input.len())
165 }
166
167 fn insert_char(&mut self, character: char) -> bool {
168 if self.input.len().saturating_add(character.len_utf8()) > TUI_INPUT_MAX_BYTES {
169 return false;
170 }
171 let index = self.cursor_byte_index();
172 self.input.insert(index, character);
173 self.cursor_pos += 1;
174 true
175 }
176
177 fn remove_before_cursor(&mut self) {
178 if self.cursor_pos == 0 {
179 return;
180 }
181 let end = self.cursor_byte_index();
182 let start = self
183 .input
184 .char_indices()
185 .nth(self.cursor_pos - 1)
186 .map(|(index, _)| index)
187 .unwrap_or(0);
188 self.input.drain(start..end);
189 self.cursor_pos -= 1;
190 }
191
192 fn remove_at_cursor(&mut self) {
193 let start = self.cursor_byte_index();
194 let end = self
195 .input
196 .char_indices()
197 .nth(self.cursor_pos + 1)
198 .map(|(index, _)| index)
199 .unwrap_or(self.input.len());
200 if start < end {
201 self.input.drain(start..end);
202 }
203 }
204
205 fn reduce_key(&mut self, key: KeyEvent) -> UiIntent {
206 if key.kind != KeyEventKind::Press {
207 return UiIntent::None;
208 }
209
210 match key.code {
211 KeyCode::Char('q' | 'c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
212 UiIntent::Quit
213 }
214 KeyCode::Char('q') if self.input.is_empty() => UiIntent::Quit,
215 KeyCode::Esc => {
216 let cancellation = self.busy.as_mut().and_then(|busy| {
217 if busy.cancelling {
218 None
219 } else {
220 busy.cancelling = true;
221 Some((busy.id, busy.label.clone()))
222 }
223 });
224 if let Some((id, label)) = cancellation {
225 self.set_status(format!("Cancelling: {label}"));
226 self.add_activity(format!("Cancellation requested: {label}"));
227 UiIntent::Cancel(id)
228 } else if self.busy.is_some() {
229 UiIntent::None
230 } else if self.error_msg.is_some() {
231 self.clear_error();
232 UiIntent::None
233 } else {
234 UiIntent::Quit
235 }
236 }
237 KeyCode::Enter if !self.input.trim().is_empty() => {
238 let command = std::mem::take(&mut self.input);
239 self.cursor_pos = 0;
240 UiIntent::Submit(command)
241 }
242 KeyCode::Backspace => {
243 self.remove_before_cursor();
244 UiIntent::None
245 }
246 KeyCode::Delete => {
247 self.remove_at_cursor();
248 UiIntent::None
249 }
250 KeyCode::Left => {
251 self.cursor_pos = self.cursor_pos.saturating_sub(1);
252 UiIntent::None
253 }
254 KeyCode::Right => {
255 self.cursor_pos = (self.cursor_pos + 1).min(self.input.chars().count());
256 UiIntent::None
257 }
258 KeyCode::Home => {
259 self.cursor_pos = 0;
260 UiIntent::None
261 }
262 KeyCode::End => {
263 self.cursor_pos = self.input.chars().count();
264 UiIntent::None
265 }
266 KeyCode::PageUp => {
267 self.page_scroll = self.page_scroll.saturating_sub(10);
268 UiIntent::None
269 }
270 KeyCode::PageDown => {
271 self.page_scroll = self.page_scroll.saturating_add(10);
272 UiIntent::None
273 }
274 KeyCode::Up if self.input.is_empty() => {
275 self.move_intent_selection(-1);
276 UiIntent::None
277 }
278 KeyCode::Down if self.input.is_empty() => {
279 self.move_intent_selection(1);
280 UiIntent::None
281 }
282 KeyCode::Char(character) => {
283 if !self.insert_char(character) {
284 self.report_error(format!(
285 "Command input is limited to {TUI_INPUT_MAX_BYTES} bytes."
286 ));
287 }
288 UiIntent::None
289 }
290 _ => UiIntent::None,
291 }
292 }
293
294 fn browser_ready(&self) -> bool {
295 self.browser_state == BrowserState::Ready
296 }
297
298 fn is_busy(&self) -> bool {
299 self.busy.is_some()
300 }
301
302 fn allocate_operation_id(&mut self) -> u64 {
303 let id = self.next_operation_id;
304 self.next_operation_id = self.next_operation_id.checked_add(1).unwrap_or(1);
305 id
306 }
307
308 fn begin_operation(&mut self, id: u64, label: impl Into<String>) {
309 let label = bounded_text(&label.into(), TUI_ACTIVITY_MAX_BYTES);
310 self.busy = Some(BusyState {
311 id,
312 label: label.clone(),
313 cancelling: false,
314 spinner: 0,
315 });
316 self.set_status(format!("Queued: {label}"));
317 self.add_activity(format!("Queued: {label}"));
318 }
319
320 fn finish_operation(&mut self, id: u64) {
321 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
322 self.busy = None;
323 if self.browser_ready() {
324 self.set_status("Ready");
325 }
326 }
327 }
328
329 fn cancellation_enqueue_failed(&mut self, id: u64) {
330 let label = self.busy.as_mut().filter(|busy| busy.id == id).map(|busy| {
331 busy.cancelling = false;
332 busy.label.clone()
333 });
334 if let Some(label) = label {
335 self.set_status(format!("Working: {label}"));
336 }
337 }
338
339 fn tick_busy(&mut self) {
340 let status = self.busy.as_mut().map(|busy| {
341 busy.spinner = busy.spinner.wrapping_add(1);
342 if busy.cancelling {
343 format!("Cancelling: {}", busy.label)
344 } else {
345 let frame = ['|', '/', '-', '\\'][busy.spinner % 4];
346 format!("{frame} Working: {}", busy.label)
347 }
348 });
349 if let Some(status) = status {
350 self.set_status(status);
351 }
352 }
353
354 fn apply_browser_event(
355 &mut self,
356 event: BrowserEvent,
357 ) -> BrowserResult<Option<BrowserOperation>> {
358 match event {
359 BrowserEvent::Connecting => {
360 self.browser_state = BrowserState::Connecting;
361 self.set_status("Connecting to Chrome…");
362 self.add_activity("Browser worker is connecting.");
363 }
364 BrowserEvent::Ready { port } => {
365 self.browser_state = BrowserState::Ready;
366 self.set_status(format!("Connected on port {port}"));
367 self.add_activity("Connected to Chrome.");
368 return Ok(Some(BrowserOperation::Observe { fresh: false }));
369 }
370 BrowserEvent::StartupFailed { message } => {
371 self.browser_state = BrowserState::Unavailable;
372 self.busy = None;
373 self.set_status("Browser unavailable");
374 self.report_error(message);
375 }
376 BrowserEvent::OperationStarted { id, label } => {
377 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
378 self.set_status(format!("Working: {label}"));
379 self.add_activity(format!("Started: {label}"));
380 }
381 }
382 BrowserEvent::OperationFinished { id, result } => {
383 self.finish_operation(id);
384 if let Some(update) = result.update {
385 self.apply_page_update(update)?;
386 }
387 self.add_activity(result.activity);
388 }
389 BrowserEvent::OperationFailed { id, message } => {
390 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
391 self.finish_operation(id);
392 self.report_error(message);
393 } else {
394 self.add_activity(format!("Rejected operation {id}: {message}"));
395 }
396 }
397 BrowserEvent::OperationCancelled { id } => {
398 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
399 self.finish_operation(id);
400 self.add_activity(format!("Cancelled operation {id}."));
401 }
402 }
403 BrowserEvent::WorkerFailed { message } => {
404 self.browser_state = BrowserState::Unavailable;
405 self.busy = None;
406 self.set_status("Browser worker failed");
407 self.report_error(message);
408 }
409 BrowserEvent::WorkerStopped => {
410 self.browser_state = BrowserState::Stopped;
411 self.busy = None;
412 self.set_status("Browser worker stopped");
413 self.add_activity("Browser worker stopped.");
414 }
415 }
416 Ok(None)
417 }
418
419 fn apply_page_update(&mut self, update: PageUpdate) -> BrowserResult<()> {
420 match update {
421 PageUpdate::Context(context) => self.apply_context(&context),
422 PageUpdate::Semantic(observation) => self.apply_semantic(&observation),
423 PageUpdate::IntentResolution { request, result } => {
424 self.apply_intent_resolution(*request, *result)
425 }
426 PageUpdate::Text { page, text } => {
427 self.apply_page_header(&page);
428 self.set_page_content(text);
429 Ok(())
430 }
431 }
432 }
433
434 fn apply_context(&mut self, context: &PageContext) -> BrowserResult<()> {
435 if context.screenshot.is_some() {
436 return Err("TUI worker must not retain screenshot data".into());
437 }
438 self.apply_page_header(&context.page);
439 self.set_page_content(serde_json::to_string_pretty(context)?);
440 Ok(())
441 }
442
443 fn apply_semantic(&mut self, observation: &SemanticObservation) -> BrowserResult<()> {
444 self.url = bounded_text(&observation.page.url, TUI_HEADER_MAX_BYTES);
445 self.title = bounded_text(
446 &format!("Glass — {}", observation.page.title),
447 TUI_HEADER_MAX_BYTES,
448 );
449 self.set_page_content(serde_json::to_string_pretty(observation)?);
450 Ok(())
451 }
452
453 fn apply_intent_resolution(
454 &mut self,
455 request: SemanticIntentRequest,
456 result: SemanticIntentResult,
457 ) -> BrowserResult<()> {
458 self.intent_request = Some(request);
459 self.intent_selection = 0;
460 self.intent_result = Some(result.clone());
461 self.url = result
462 .route
463 .as_ref()
464 .map(|route| bounded_text(&route.url, TUI_HEADER_MAX_BYTES))
465 .unwrap_or_default();
466 self.title = "Glass — Intent resolution".into();
467 self.set_page_content(format_intent_debug(&result, self.intent_selection));
468 Ok(())
469 }
470
471 fn move_intent_selection(&mut self, delta: isize) {
472 let Some(result) = self.intent_result.as_ref() else {
473 return;
474 };
475 if result.candidates.is_empty() {
476 return;
477 }
478 let maximum = result.candidates.len() - 1;
479 self.intent_selection = if delta.is_negative() {
480 self.intent_selection.saturating_sub(delta.unsigned_abs())
481 } else {
482 self.intent_selection
483 .saturating_add(delta as usize)
484 .min(maximum)
485 };
486 let content = format_intent_debug(result, self.intent_selection);
487 let candidate_id = result.candidates[self.intent_selection].id.clone();
488 self.set_page_content(content);
489 self.set_status(format!(
490 "Selected {} — submit: intent execute.",
491 candidate_id
492 ));
493 }
494
495 fn apply_page_header(&mut self, page: &PageInfo) {
496 self.url = bounded_text(&page.url, TUI_HEADER_MAX_BYTES);
497 self.title = bounded_text(&format!("Glass — {}", page.title), TUI_HEADER_MAX_BYTES);
498 }
499
500 fn set_page_content(&mut self, content: impl Into<String>) {
501 self.page_content = bounded_text(&content.into(), TUI_PAGE_MAX_BYTES);
502 self.page_scroll = 0;
503 }
504}
505
506#[derive(Debug)]
507enum InputEvent {
508 Key(KeyEvent),
509 Redraw,
510 Error(String),
511}
512
513struct InputWorker {
514 shutdown: Arc<AtomicBool>,
515 join: Option<thread::JoinHandle<()>>,
516}
517
518impl InputWorker {
519 fn spawn(events: mpsc::Sender<InputEvent>) -> Self {
520 let shutdown = Arc::new(AtomicBool::new(false));
521 let worker_shutdown = Arc::clone(&shutdown);
522 let join = thread::spawn(move || {
523 while !worker_shutdown.load(Ordering::Relaxed) {
524 match event::poll(INPUT_POLL) {
525 Ok(false) => {}
526 Ok(true) => match event::read() {
527 Ok(Event::Key(key)) => {
528 if events.blocking_send(InputEvent::Key(key)).is_err() {
529 break;
530 }
531 }
532 Ok(_) => {
533 if events.blocking_send(InputEvent::Redraw).is_err() {
534 break;
535 }
536 }
537 Err(error) => {
538 let _ = events.blocking_send(InputEvent::Error(error.to_string()));
539 break;
540 }
541 },
542 Err(error) => {
543 let _ = events.blocking_send(InputEvent::Error(error.to_string()));
544 break;
545 }
546 }
547 }
548 });
549 Self {
550 shutdown,
551 join: Some(join),
552 }
553 }
554
555 fn stop(&mut self) -> BrowserResult<()> {
556 self.shutdown.store(true, Ordering::Relaxed);
557 if self.join.take().is_some_and(|join| join.join().is_err()) {
558 return Err("TUI input worker panicked".into());
559 }
560 Ok(())
561 }
562}
563
564impl Drop for InputWorker {
565 fn drop(&mut self) {
566 let _ = self.stop();
567 }
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
571enum LocalCommand {
572 Help,
573 Profiles,
574 Knowledge(Option<String>),
575 Daemon(DaemonView),
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579enum DaemonView {
580 Status,
581 Doctor,
582 Logs,
583 Recovery,
584}
585
586#[derive(Debug, Clone, PartialEq)]
587enum BrowserOperation {
588 Navigate(String),
589 Screenshot(String),
590 Text,
591 Dom,
592 Observe {
593 fresh: bool,
594 },
595 Semantic {
596 level: SemanticObservationLevel,
597 region: Option<String>,
598 },
599 Click(String),
600 DoubleClick(String),
601 Hover(String),
602 Clear(String),
603 Check(String),
604 Uncheck(String),
605 Select {
606 target: String,
607 value: String,
608 },
609 Type(String),
610 KeyPress(String),
611 Shortcut(String),
612 Scroll {
613 dx: f64,
614 dy: f64,
615 },
616 AcceptDialog,
617 DismissDialog,
618 DismissConsent,
619 Evaluate(String),
620 Workflow(String),
621 ResolveIntent(String),
622 ExecuteIntent(Box<SemanticIntentExecutionRequest>),
623}
624
625impl BrowserOperation {
626 fn label(&self) -> &'static str {
627 match self {
628 Self::Navigate(_) => "Navigate",
629 Self::Screenshot(_) => "Screenshot",
630 Self::Text => "Text",
631 Self::Dom => "Compact DOM",
632 Self::Observe { .. } => "Observe",
633 Self::Semantic { .. } => "Semantic observe",
634 Self::Click(_) => "Click",
635 Self::DoubleClick(_) => "Double-click",
636 Self::Hover(_) => "Hover",
637 Self::Clear(_) => "Clear",
638 Self::Check(_) => "Check",
639 Self::Uncheck(_) => "Uncheck",
640 Self::Select { .. } => "Select",
641 Self::Type(_) => "Type",
642 Self::KeyPress(_) => "Key press",
643 Self::Shortcut(_) => "Shortcut",
644 Self::Scroll { .. } => "Scroll",
645 Self::AcceptDialog => "Accept dialog",
646 Self::DismissDialog => "Dismiss dialog",
647 Self::DismissConsent => "Dismiss consent",
648 Self::Evaluate(_) => "Evaluate",
649 Self::Workflow(_) => "Workflow",
650 Self::ResolveIntent(_) => "Resolve intent",
651 Self::ExecuteIntent(_) => "Execute intent",
652 }
653 }
654}
655
656#[derive(Debug, Clone, PartialEq)]
657enum ParsedCommand {
658 Local(LocalCommand),
659 Browser(BrowserOperation),
660}
661
662fn parse_command(input: &str) -> Result<ParsedCommand, String> {
663 let command = input.trim();
664 if command.is_empty() {
665 return Err("command cannot be empty".to_string());
666 }
667 if command.eq_ignore_ascii_case("help") {
668 return Ok(ParsedCommand::Local(LocalCommand::Help));
669 }
670 if command.eq_ignore_ascii_case("profiles") {
671 return Ok(ParsedCommand::Local(LocalCommand::Profiles));
672 }
673 if command.eq_ignore_ascii_case("knowledge") {
674 return Ok(ParsedCommand::Local(LocalCommand::Knowledge(None)));
675 }
676 if let Some(record_id) = strip_ascii_prefix(command, "knowledge show ") {
677 return required_command_argument(record_id, "knowledge record ID")
678 .map(|record_id| ParsedCommand::Local(LocalCommand::Knowledge(Some(record_id))));
679 }
680 if command.eq_ignore_ascii_case("daemon") || command.eq_ignore_ascii_case("daemon doctor") {
681 return Ok(ParsedCommand::Local(LocalCommand::Daemon(
682 DaemonView::Doctor,
683 )));
684 }
685 if command.eq_ignore_ascii_case("daemon status") {
686 return Ok(ParsedCommand::Local(LocalCommand::Daemon(
687 DaemonView::Status,
688 )));
689 }
690 if command.eq_ignore_ascii_case("daemon logs") {
691 return Ok(ParsedCommand::Local(LocalCommand::Daemon(DaemonView::Logs)));
692 }
693 if command.eq_ignore_ascii_case("daemon recovery") {
694 return Ok(ParsedCommand::Local(LocalCommand::Daemon(
695 DaemonView::Recovery,
696 )));
697 }
698 for prefix in ["navigate ", "go to ", "go "] {
699 if let Some(url) = strip_ascii_prefix(command, prefix) {
700 return required_command_argument(url, "URL")
701 .map(BrowserOperation::Navigate)
702 .map(ParsedCommand::Browser);
703 }
704 }
705 if let Some(target) = strip_ascii_prefix(command, "double click ") {
706 return required_command_argument(target, "double-click target")
707 .map(BrowserOperation::DoubleClick)
708 .map(ParsedCommand::Browser);
709 }
710 if let Some(target) = strip_ascii_prefix(command, "click ") {
711 return required_command_argument(target, "click target")
712 .map(BrowserOperation::Click)
713 .map(ParsedCommand::Browser);
714 }
715 if let Some(target) = strip_ascii_prefix(command, "hover ") {
716 return required_command_argument(target, "hover target")
717 .map(BrowserOperation::Hover)
718 .map(ParsedCommand::Browser);
719 }
720 for (prefix, operation, name) in [
721 (
722 "clear ",
723 BrowserOperation::Clear as fn(String) -> BrowserOperation,
724 "clear target",
725 ),
726 (
727 "check ",
728 BrowserOperation::Check as fn(String) -> BrowserOperation,
729 "check target",
730 ),
731 (
732 "uncheck ",
733 BrowserOperation::Uncheck as fn(String) -> BrowserOperation,
734 "uncheck target",
735 ),
736 ] {
737 if let Some(target) = strip_ascii_prefix(command, prefix) {
738 return required_command_argument(target, name)
739 .map(operation)
740 .map(ParsedCommand::Browser);
741 }
742 }
743 if let Some(values) = strip_ascii_prefix(command, "select ") {
744 return parse_target_value(values, "select target and value").map(|(target, value)| {
745 ParsedCommand::Browser(BrowserOperation::Select { target, value })
746 });
747 }
748 if let Some(text) = strip_ascii_prefix(command, "type ") {
749 return required_command_argument(text, "text")
750 .map(BrowserOperation::Type)
751 .map(ParsedCommand::Browser);
752 }
753 if let Some(key) = strip_ascii_prefix(command, "press ") {
754 return required_command_argument(key, "key")
755 .map(BrowserOperation::KeyPress)
756 .map(ParsedCommand::Browser);
757 }
758 if let Some(shortcut) = strip_ascii_prefix(command, "shortcut ") {
759 return required_command_argument(shortcut, "shortcut")
760 .map(BrowserOperation::Shortcut)
761 .map(ParsedCommand::Browser);
762 }
763 if let Some(path) = strip_ascii_prefix(command, "workflow ") {
764 return required_command_argument(path, "workflow JSON path")
765 .map(BrowserOperation::Workflow)
766 .map(ParsedCommand::Browser);
767 }
768 if let Some(path) = strip_ascii_prefix(command, "resolve-intent ") {
769 return required_command_argument(path, "intent JSON path")
770 .map(BrowserOperation::ResolveIntent)
771 .map(ParsedCommand::Browser);
772 }
773 if command.eq_ignore_ascii_case("screenshot") {
774 return Ok(ParsedCommand::Browser(BrowserOperation::Screenshot(
775 "screenshot.png".to_string(),
776 )));
777 }
778 if let Some(output) = strip_ascii_prefix(command, "screenshot ") {
779 let output = output.trim();
780 return Ok(ParsedCommand::Browser(BrowserOperation::Screenshot(
781 if output.is_empty() {
782 "screenshot.png".to_string()
783 } else {
784 output.to_string()
785 },
786 )));
787 }
788 if ["text", "content", "get text", "page text"]
789 .iter()
790 .any(|candidate| command.eq_ignore_ascii_case(candidate))
791 {
792 return Ok(ParsedCommand::Browser(BrowserOperation::Text));
793 }
794 if ["dom", "snapshot", "get dom"]
795 .iter()
796 .any(|candidate| command.eq_ignore_ascii_case(candidate))
797 {
798 return Ok(ParsedCommand::Browser(BrowserOperation::Dom));
799 }
800 if ["observe", "context"]
801 .iter()
802 .any(|candidate| command.eq_ignore_ascii_case(candidate))
803 {
804 return Ok(ParsedCommand::Browser(BrowserOperation::Observe {
805 fresh: false,
806 }));
807 }
808 if command.eq_ignore_ascii_case("semantic") {
809 return Ok(ParsedCommand::Browser(BrowserOperation::Semantic {
810 level: SemanticObservationLevel::Summary,
811 region: None,
812 }));
813 }
814 if let Some(values) = strip_ascii_prefix(command, "semantic ") {
815 return parse_semantic_observation(values).map(ParsedCommand::Browser);
816 }
817 if command.eq_ignore_ascii_case("scroll") {
818 return Ok(ParsedCommand::Browser(BrowserOperation::Scroll {
819 dx: 0.0,
820 dy: 600.0,
821 }));
822 }
823 if let Some(values) = strip_ascii_prefix(command, "scroll ") {
824 return parse_scroll(values).map(ParsedCommand::Browser);
825 }
826 if command.eq_ignore_ascii_case("accept-dialog") {
827 return Ok(ParsedCommand::Browser(BrowserOperation::AcceptDialog));
828 }
829 if command.eq_ignore_ascii_case("dismiss-dialog") {
830 return Ok(ParsedCommand::Browser(BrowserOperation::DismissDialog));
831 }
832 if command.eq_ignore_ascii_case("dismiss-consent") {
833 return Ok(ParsedCommand::Browser(BrowserOperation::DismissConsent));
834 }
835 Ok(ParsedCommand::Browser(BrowserOperation::Evaluate(
836 command.to_string(),
837 )))
838}
839
840fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
841 value
842 .get(..prefix.len())
843 .filter(|head| head.eq_ignore_ascii_case(prefix))?;
844 Some(&value[prefix.len()..])
845}
846
847fn required_command_argument(value: &str, name: &str) -> Result<String, String> {
848 let value = value.trim();
849 if value.is_empty() {
850 Err(format!("{name} cannot be empty"))
851 } else {
852 Ok(value.to_string())
853 }
854}
855
856fn parse_scroll(values: &str) -> Result<BrowserOperation, String> {
857 let mut values = values.split_whitespace();
858 let dx = values
859 .next()
860 .map(|value| {
861 value
862 .parse::<f64>()
863 .map_err(|_| "scroll dx must be a number")
864 })
865 .transpose()?
866 .unwrap_or(0.0);
867 let dy = values
868 .next()
869 .map(|value| {
870 value
871 .parse::<f64>()
872 .map_err(|_| "scroll dy must be a number")
873 })
874 .transpose()?
875 .unwrap_or(600.0);
876 if values.next().is_some() {
877 return Err("scroll accepts at most dx and dy".to_string());
878 }
879 Ok(BrowserOperation::Scroll { dx, dy })
880}
881
882fn parse_target_value(values: &str, name: &str) -> Result<(String, String), String> {
883 let mut values = values.trim().splitn(2, char::is_whitespace);
884 let target = values.next().unwrap_or_default().trim();
885 let value = values.next().unwrap_or_default().trim();
886 if target.is_empty() || value.is_empty() {
887 return Err(format!("{name} requires two non-empty arguments"));
888 }
889 Ok((target.to_string(), value.to_string()))
890}
891
892fn parse_semantic_observation(values: &str) -> Result<BrowserOperation, String> {
893 let mut values = values.split_whitespace();
894 let level = match values
895 .next()
896 .unwrap_or("summary")
897 .to_ascii_lowercase()
898 .as_str()
899 {
900 "summary" => SemanticObservationLevel::Summary,
901 "interactive" => SemanticObservationLevel::Interactive,
902 "structured" => SemanticObservationLevel::Structured,
903 "detailed" => SemanticObservationLevel::Detailed,
904 "raw" => SemanticObservationLevel::Raw,
905 _ => {
906 return Err(
907 "semantic level must be summary, interactive, structured, detailed, or raw".into(),
908 );
909 }
910 };
911 let region = values.next().map(str::to_string);
912 if values.next().is_some() {
913 return Err("semantic accepts a level and optional region ID".into());
914 }
915 Ok(BrowserOperation::Semantic { level, region })
916}
917
918fn format_intent_activity(result: &SemanticIntentResult) -> String {
919 format!(
920 "Intent {:?}: {} (policy={:?}, candidates={}, revision={}).",
921 result.resolution,
922 result.normalized_intent,
923 result.policy_decision,
924 result.candidates.len(),
925 result
926 .revision
927 .map(|revision| revision.to_string())
928 .unwrap_or_else(|| "unknown".into())
929 )
930}
931
932fn format_intent_execution_activity(result: &SemanticIntentExecutionResult) -> String {
933 match result.status {
934 crate::browser::session::SemanticIntentExecutionStatus::Executed => format!(
935 "Intent executed: candidate={} resolution={} execution={}.",
936 result.candidate_id,
937 result.resolution_id,
938 result.execution_id.as_deref().unwrap_or("unknown")
939 ),
940 crate::browser::session::SemanticIntentExecutionStatus::NotExecuted => format!(
941 "Intent not executed: candidate={} resolution={:?}; {}.",
942 result.candidate_id,
943 result.resolution.resolution,
944 result
945 .reason
946 .as_deref()
947 .unwrap_or("policy did not authorize dispatch")
948 ),
949 }
950}
951
952fn format_intent_debug(result: &SemanticIntentResult, selected: usize) -> String {
953 let mut output = vec![
954 format!("Normalized intent: {}", result.normalized_intent),
955 format!("Resolution: {:?}", result.resolution),
956 format!("Policy: {:?}", result.policy_decision),
957 format!(
958 "Revision: {}",
959 result
960 .revision
961 .map(|revision| revision.to_string())
962 .unwrap_or_else(|| "unknown".into())
963 ),
964 String::new(),
965 "Candidates:".into(),
966 ];
967 if result.candidates.is_empty() {
968 output.push(" (none)".into());
969 } else {
970 for (index, candidate) in result.candidates.iter().enumerate() {
971 let evidence = candidate
972 .evidence
973 .iter()
974 .map(|item| format!("{:?}: {}", item.category, item.detail))
975 .collect::<Vec<_>>()
976 .join("; ");
977 output.push(format!(
978 " {}{} [{}] {} — {:?}",
979 if index == selected { "> " } else { " " },
980 candidate.id,
981 candidate.role,
982 candidate.name,
983 candidate.confidence
984 ));
985 if !evidence.is_empty() {
986 output.push(format!(" evidence: {evidence}"));
987 }
988 }
989 }
990 if !result.excluded_candidates.is_empty() {
991 output.push(format!(
992 "Excluded candidates: {}",
993 result.excluded_candidates.len()
994 ));
995 for candidate in &result.excluded_candidates {
996 output.push(format!(
997 " {} — {:?}: {}",
998 candidate.id, candidate.reason.category, candidate.reason.detail
999 ));
1000 }
1001 }
1002 if let Some(reason) = &result.reason {
1003 output.push(format!("Reason: {reason}"));
1004 }
1005 output.join("\n")
1006}
1007
1008#[derive(Debug)]
1009enum BrowserCommand {
1010 Execute {
1011 id: u64,
1012 operation: BrowserOperation,
1013 },
1014 Cancel {
1015 id: u64,
1016 },
1017 Shutdown,
1018}
1019
1020#[derive(Debug)]
1021enum BrowserEvent {
1022 Connecting,
1023 Ready {
1024 port: u16,
1025 },
1026 StartupFailed {
1027 message: String,
1028 },
1029 OperationStarted {
1030 id: u64,
1031 label: String,
1032 },
1033 OperationFinished {
1034 id: u64,
1035 result: Box<OperationResult>,
1036 },
1037 OperationFailed {
1038 id: u64,
1039 message: String,
1040 },
1041 OperationCancelled {
1042 id: u64,
1043 },
1044 WorkerFailed {
1045 message: String,
1046 },
1047 WorkerStopped,
1048}
1049
1050#[derive(Debug)]
1051enum PageUpdate {
1052 Context(Box<PageContext>),
1053 Semantic(Box<SemanticObservation>),
1054 IntentResolution {
1055 request: Box<SemanticIntentRequest>,
1056 result: Box<SemanticIntentResult>,
1057 },
1058 Text {
1059 page: PageInfo,
1060 text: String,
1061 },
1062}
1063
1064#[derive(Debug)]
1065struct OperationResult {
1066 activity: String,
1067 update: Option<PageUpdate>,
1068}
1069
1070enum ActiveOperationState {
1071 Completed(BrowserResult<Box<OperationResult>>),
1072 Cancelled,
1073 Shutdown,
1074}
1075
1076async fn browser_worker(
1077 options: SessionOptions,
1078 policy: BrowserPolicy,
1079 mut commands: mpsc::Receiver<BrowserCommand>,
1080 events: mpsc::Sender<BrowserEvent>,
1081 mut shutdown: watch::Receiver<bool>,
1082) {
1083 if !send_browser_event(&events, BrowserEvent::Connecting).await {
1084 return;
1085 }
1086
1087 let Some(session) =
1088 start_browser_session(&options, policy, &mut commands, &events, &mut shutdown).await
1089 else {
1090 return;
1091 };
1092 if !send_browser_event(&events, BrowserEvent::Ready { port: options.port }).await {
1093 let _ = session.close().await;
1094 return;
1095 }
1096
1097 worker_loop(session, &mut commands, &events, &mut shutdown).await;
1098}
1099
1100async fn start_browser_session(
1101 options: &SessionOptions,
1102 policy: BrowserPolicy,
1103 commands: &mut mpsc::Receiver<BrowserCommand>,
1104 events: &mpsc::Sender<BrowserEvent>,
1105 shutdown: &mut watch::Receiver<bool>,
1106) -> Option<BrowserSession> {
1107 let start = BrowserSession::start_with_policy(options, policy);
1108 tokio::pin!(start);
1109
1110 loop {
1111 if *shutdown.borrow() {
1112 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
1113 return None;
1114 }
1115 tokio::select! {
1116 biased;
1117 changed = shutdown.changed() => {
1118 if changed.is_err() || *shutdown.borrow() {
1119 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
1120 return None;
1121 }
1122 }
1123 command = commands.recv() => match command {
1124 Some(BrowserCommand::Shutdown) | None => {
1125 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
1126 return None;
1127 }
1128 Some(BrowserCommand::Execute { id, .. }) => {
1129 if !send_browser_event(events, BrowserEvent::OperationFailed {
1130 id,
1131 message: "browser is still starting".to_string(),
1132 }).await {
1133 return None;
1134 }
1135 }
1136 Some(BrowserCommand::Cancel { id }) => {
1137 if !send_browser_event(events, BrowserEvent::OperationCancelled { id }).await {
1138 return None;
1139 }
1140 }
1141 },
1142 result = &mut start => match result {
1143 Ok(session) => return Some(session),
1144 Err(error) => {
1145 let message = error.to_string();
1146 drop(error);
1147 let _ = send_browser_event(events, BrowserEvent::StartupFailed {
1148 message,
1149 }).await;
1150 return None;
1151 }
1152 },
1153 }
1154 }
1155}
1156
1157async fn worker_loop(
1158 session: BrowserSession,
1159 commands: &mut mpsc::Receiver<BrowserCommand>,
1160 events: &mpsc::Sender<BrowserEvent>,
1161 shutdown: &mut watch::Receiver<bool>,
1162) {
1163 loop {
1164 if *shutdown.borrow() {
1165 break;
1166 }
1167 tokio::select! {
1168 biased;
1169 changed = shutdown.changed() => {
1170 if changed.is_err() || *shutdown.borrow() {
1171 break;
1172 }
1173 }
1174 command = commands.recv() => match command {
1175 Some(BrowserCommand::Shutdown) | None => break,
1176 Some(BrowserCommand::Cancel { .. }) => {}
1177 Some(BrowserCommand::Execute { id, operation }) => {
1178 let label = operation.label().to_string();
1179 if !send_browser_event(events, BrowserEvent::OperationStarted { id, label }).await {
1180 break;
1181 }
1182 match await_active_operation(
1183 execute_browser_operation(&session, operation),
1184 id,
1185 commands,
1186 shutdown,
1187 events,
1188 ).await {
1189 ActiveOperationState::Completed(Ok(result)) => {
1190 if !send_browser_event(events, BrowserEvent::OperationFinished { id, result }).await {
1191 break;
1192 }
1193 }
1194 ActiveOperationState::Completed(Err(error)) => {
1195 let message = error.to_string();
1196 drop(error);
1197 if !send_browser_event(events, BrowserEvent::OperationFailed {
1198 id,
1199 message,
1200 }).await {
1201 break;
1202 }
1203 }
1204 ActiveOperationState::Cancelled => {
1205 if !send_browser_event(events, BrowserEvent::OperationCancelled { id }).await {
1206 break;
1207 }
1208 }
1209 ActiveOperationState::Shutdown => break,
1210 }
1211 }
1212 },
1213 }
1214 }
1215
1216 let close_error = session.close().await.err().map(|error| error.to_string());
1217 if let Some(message) = close_error {
1218 let _ = send_browser_event(events, BrowserEvent::WorkerFailed { message }).await;
1219 }
1220 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
1221}
1222
1223async fn await_active_operation<F>(
1224 operation: F,
1225 id: u64,
1226 commands: &mut mpsc::Receiver<BrowserCommand>,
1227 shutdown: &mut watch::Receiver<bool>,
1228 events: &mpsc::Sender<BrowserEvent>,
1229) -> ActiveOperationState
1230where
1231 F: Future<Output = BrowserResult<Box<OperationResult>>>,
1232{
1233 tokio::pin!(operation);
1234 loop {
1235 if *shutdown.borrow() {
1236 return ActiveOperationState::Shutdown;
1237 }
1238 tokio::select! {
1239 biased;
1240 changed = shutdown.changed() => {
1241 if changed.is_err() || *shutdown.borrow() {
1242 return ActiveOperationState::Shutdown;
1243 }
1244 }
1245 command = commands.recv() => match command {
1246 Some(BrowserCommand::Shutdown) | None => return ActiveOperationState::Shutdown,
1247 Some(BrowserCommand::Cancel { id: cancel_id }) if cancel_id == id => {
1248 return ActiveOperationState::Cancelled;
1249 }
1250 Some(BrowserCommand::Execute { id: queued_id, .. }) => {
1251 if !send_browser_event(events, BrowserEvent::OperationFailed {
1252 id: queued_id,
1253 message: "browser worker is already executing an operation".to_string(),
1254 }).await {
1255 return ActiveOperationState::Shutdown;
1256 }
1257 }
1258 Some(BrowserCommand::Cancel { .. }) => {}
1259 },
1260 result = &mut operation => return ActiveOperationState::Completed(result),
1261 }
1262 }
1263}
1264
1265async fn execute_browser_operation(
1266 session: &BrowserSession,
1267 operation: BrowserOperation,
1268) -> BrowserResult<Box<OperationResult>> {
1269 match operation {
1270 BrowserOperation::Navigate(url) => {
1271 let page = session.navigate(&url).await?;
1272 let context = session.observe_fresh().await?;
1273 Ok(Box::new(OperationResult {
1274 activity: format!("Page loaded: {}", page.title),
1275 update: Some(PageUpdate::Context(Box::new(context))),
1276 }))
1277 }
1278 BrowserOperation::Screenshot(output) => {
1279 let output = session
1280 .policy()
1281 .require_output_path(std::path::Path::new(&output))?;
1282 tokio::fs::write(&output, session.screenshot_png().await?).await?;
1283 Ok(Box::new(OperationResult {
1284 activity: format!("Screenshot saved to {}", output.display()),
1285 update: None,
1286 }))
1287 }
1288 BrowserOperation::Text => {
1289 let context = session.observe().await?;
1290 Ok(Box::new(OperationResult {
1291 activity: "Page text refreshed.".to_string(),
1292 update: Some(PageUpdate::Text {
1293 page: context.page,
1294 text: context.text,
1295 }),
1296 }))
1297 }
1298 BrowserOperation::Dom => {
1299 let context = session.observe_fresh().await?;
1300 Ok(Box::new(OperationResult {
1301 activity: "Compact DOM and accessibility context refreshed.".to_string(),
1302 update: Some(PageUpdate::Context(Box::new(context))),
1303 }))
1304 }
1305 BrowserOperation::Observe { fresh } => {
1306 let context = if fresh {
1307 session.observe_fresh().await?
1308 } else {
1309 session.observe().await?
1310 };
1311 Ok(Box::new(OperationResult {
1312 activity: "Compact observation refreshed.".to_string(),
1313 update: Some(PageUpdate::Context(Box::new(context))),
1314 }))
1315 }
1316 BrowserOperation::Semantic { level, region } => {
1317 let observation = session.semantic_observe(level).await?;
1318 let observation = if let Some(region_id) = region {
1319 session
1320 .semantic_expand_region(®ion_id, observation.revision, level)
1321 .await?
1322 } else {
1323 observation
1324 };
1325 Ok(Box::new(OperationResult {
1326 activity: format!(
1327 "Semantic {} observation refreshed (revision {}).",
1328 serde_json::to_value(level)?.as_str().unwrap_or("unknown"),
1329 observation.revision
1330 ),
1331 update: Some(PageUpdate::Semantic(Box::new(observation))),
1332 }))
1333 }
1334 BrowserOperation::Click(target) => {
1335 let outcome = session.click(&target).await?;
1336 let context = session.observe_fresh().await?;
1337 Ok(Box::new(OperationResult {
1338 activity: action_activity("Clicked", &outcome),
1339 update: Some(PageUpdate::Context(Box::new(context))),
1340 }))
1341 }
1342 BrowserOperation::DoubleClick(target) => {
1343 let outcome = session.double_click(&target).await?;
1344 let context = session.observe_fresh().await?;
1345 Ok(Box::new(OperationResult {
1346 activity: action_activity("Double-clicked", &outcome),
1347 update: Some(PageUpdate::Context(Box::new(context))),
1348 }))
1349 }
1350 BrowserOperation::Hover(target) => {
1351 let outcome = session.hover(&target).await?;
1352 let context = session.observe_fresh().await?;
1353 Ok(Box::new(OperationResult {
1354 activity: action_activity("Hovered", &outcome),
1355 update: Some(PageUpdate::Context(Box::new(context))),
1356 }))
1357 }
1358 BrowserOperation::Clear(target) => {
1359 let outcome = session.clear(&target).await?;
1360 let context = session.observe_fresh().await?;
1361 Ok(Box::new(OperationResult {
1362 activity: action_activity("Cleared", &outcome),
1363 update: Some(PageUpdate::Context(Box::new(context))),
1364 }))
1365 }
1366 BrowserOperation::Check(target) => {
1367 let outcome = session.check(&target).await?;
1368 let context = session.observe_fresh().await?;
1369 Ok(Box::new(OperationResult {
1370 activity: action_activity("Checked", &outcome),
1371 update: Some(PageUpdate::Context(Box::new(context))),
1372 }))
1373 }
1374 BrowserOperation::Uncheck(target) => {
1375 let outcome = session.uncheck(&target).await?;
1376 let context = session.observe_fresh().await?;
1377 Ok(Box::new(OperationResult {
1378 activity: action_activity("Unchecked", &outcome),
1379 update: Some(PageUpdate::Context(Box::new(context))),
1380 }))
1381 }
1382 BrowserOperation::Select { target, value } => {
1383 let outcome = session.select_option(&target, &value).await?;
1384 let context = session.observe_fresh().await?;
1385 Ok(Box::new(OperationResult {
1386 activity: action_activity("Selected", &outcome),
1387 update: Some(PageUpdate::Context(Box::new(context))),
1388 }))
1389 }
1390 BrowserOperation::Type(text) => {
1391 let character_count = text.chars().count();
1392 let outcome = session.type_text(&text, None).await?;
1393 let context = session.observe_fresh().await?;
1394 Ok(Box::new(OperationResult {
1395 activity: format!(
1396 "Typed {character_count} characters (revision {}).",
1397 outcome.revision
1398 ),
1399 update: Some(PageUpdate::Context(Box::new(context))),
1400 }))
1401 }
1402 BrowserOperation::KeyPress(key) => {
1403 let outcome = session.key_press(&key).await?;
1404 let context = session.observe_fresh().await?;
1405 Ok(Box::new(OperationResult {
1406 activity: action_activity("Pressed", &outcome),
1407 update: Some(PageUpdate::Context(Box::new(context))),
1408 }))
1409 }
1410 BrowserOperation::Shortcut(shortcut) => {
1411 let outcome = session.shortcut(&shortcut).await?;
1412 let context = session.observe_fresh().await?;
1413 Ok(Box::new(OperationResult {
1414 activity: action_activity("Ran shortcut", &outcome),
1415 update: Some(PageUpdate::Context(Box::new(context))),
1416 }))
1417 }
1418 BrowserOperation::Scroll { dx, dy } => {
1419 let outcome = session.scroll(dx, dy).await?;
1420 let context = session.observe_fresh().await?;
1421 Ok(Box::new(OperationResult {
1422 activity: format!("Scrolled (revision {}).", outcome.revision),
1423 update: Some(PageUpdate::Context(Box::new(context))),
1424 }))
1425 }
1426 BrowserOperation::AcceptDialog => {
1427 session.accept_dialog().await?;
1428 let context = session.observe_fresh().await?;
1429 Ok(Box::new(OperationResult {
1430 activity: "Accepted the JavaScript dialog.".into(),
1431 update: Some(PageUpdate::Context(Box::new(context))),
1432 }))
1433 }
1434 BrowserOperation::DismissDialog => {
1435 session.dismiss_dialog().await?;
1436 let context = session.observe_fresh().await?;
1437 Ok(Box::new(OperationResult {
1438 activity: "Dismissed the JavaScript dialog.".into(),
1439 update: Some(PageUpdate::Context(Box::new(context))),
1440 }))
1441 }
1442 BrowserOperation::DismissConsent => {
1443 let outcome = session.dismiss_consent().await?;
1444 let context = session.observe_fresh().await?;
1445 Ok(Box::new(OperationResult {
1446 activity: format!("Consent dismissal: {outcome:?}."),
1447 update: Some(PageUpdate::Context(Box::new(context))),
1448 }))
1449 }
1450 BrowserOperation::Evaluate(expression) => {
1451 let result = session.evaluate(&expression).await?;
1452 let context = session.observe_fresh().await?;
1453 Ok(Box::new(OperationResult {
1454 activity: format!(
1455 "Result: {}",
1456 bounded_text(&result.to_string(), TUI_ACTIVITY_MAX_BYTES)
1457 ),
1458 update: Some(PageUpdate::Context(Box::new(context))),
1459 }))
1460 }
1461 BrowserOperation::Workflow(path) => {
1462 let payload = tokio::fs::read_to_string(&path).await?;
1463 let payload: serde_json::Value = serde_json::from_str(&payload)?;
1464 let workflow_value = payload
1465 .get("workflow")
1466 .cloned()
1467 .unwrap_or_else(|| payload.clone());
1468 let inputs_value = payload
1469 .get("inputs")
1470 .cloned()
1471 .unwrap_or_else(|| serde_json::json!({}));
1472 let workflow = WorkflowDefinition::from_value(workflow_value)?;
1473 let inputs: BTreeMap<String, serde_json::Value> = serde_json::from_value(inputs_value)?;
1474 let result = session.run_workflow(&workflow, &inputs).await?;
1475 let step_summary = result
1476 .steps
1477 .iter()
1478 .map(|step| format!("{}={:?}", step.id, step.state))
1479 .collect::<Vec<_>>()
1480 .join(", ");
1481 let context = session.observe_fresh().await?;
1482 Ok(Box::new(OperationResult {
1483 activity: bounded_text(
1484 &format!(
1485 "Workflow {} {:?}; trace={} [{}].",
1486 result.name,
1487 result.status,
1488 result.trace.events.len(),
1489 step_summary
1490 ),
1491 TUI_ACTIVITY_MAX_BYTES,
1492 ),
1493 update: Some(PageUpdate::Context(Box::new(context))),
1494 }))
1495 }
1496 BrowserOperation::ResolveIntent(path) => {
1497 let payload = tokio::fs::read_to_string(&path).await?;
1498 let request = SemanticIntentRequest::from_json(&payload)?;
1499 let result = session.resolve_intent(&request).await?;
1500 Ok(Box::new(OperationResult {
1501 activity: format_intent_activity(&result),
1502 update: Some(PageUpdate::IntentResolution {
1503 request: Box::new(request),
1504 result: Box::new(result),
1505 }),
1506 }))
1507 }
1508 BrowserOperation::ExecuteIntent(execution) => {
1509 let result = session.execute_intent(&execution).await?;
1510 let context = session.observe_fresh().await?;
1511 Ok(Box::new(OperationResult {
1512 activity: format_intent_execution_activity(&result),
1513 update: Some(PageUpdate::Context(Box::new(context))),
1514 }))
1515 }
1516 }
1517}
1518
1519fn action_activity(verb: &str, outcome: &ActionOutcome) -> String {
1520 let target = outcome
1521 .target
1522 .as_ref()
1523 .map(|target| target.label.as_str())
1524 .unwrap_or("page");
1525 let mut effects = Vec::new();
1526 if outcome.verification.url_changed {
1527 effects.push("url");
1528 }
1529 if outcome.verification.title_changed {
1530 effects.push("title");
1531 }
1532 if outcome.verification.popup_opened {
1533 effects.push("popup");
1534 }
1535 if outcome.verification.dialog_open {
1536 effects.push("dialog");
1537 }
1538 if outcome.verification.download_started {
1539 effects.push("download");
1540 }
1541 let effect_text = if effects.is_empty() {
1542 String::new()
1543 } else {
1544 format!(" effects={}", effects.join(","))
1545 };
1546 format!(
1547 "{verb} {target} ({}; revision {}{}).",
1548 outcome.execution_id, outcome.revision, effect_text
1549 )
1550}
1551
1552async fn send_browser_event(events: &mpsc::Sender<BrowserEvent>, event: BrowserEvent) -> bool {
1553 events.send(event).await.is_ok()
1554}
1555
1556fn dispatch_ui_intent(
1557 app: &mut App,
1558 commands: &mpsc::Sender<BrowserCommand>,
1559 policy: &BrowserPolicy,
1560 intent: UiIntent,
1561) {
1562 match intent {
1563 UiIntent::None => {}
1564 UiIntent::Submit(command) => handle_submission(app, commands, policy, command),
1565 UiIntent::Cancel(id) => {
1566 if commands.try_send(BrowserCommand::Cancel { id }).is_err() {
1567 app.cancellation_enqueue_failed(id);
1568 app.report_error(
1569 "Browser worker is unavailable; cancellation could not be queued.",
1570 );
1571 }
1572 }
1573 UiIntent::Quit => app.should_quit = true,
1574 }
1575}
1576
1577fn handle_submission(
1578 app: &mut App,
1579 commands: &mpsc::Sender<BrowserCommand>,
1580 policy: &BrowserPolicy,
1581 command: String,
1582) {
1583 app.add_activity(format!("> {command}"));
1584 if command.eq_ignore_ascii_case("intent execute")
1585 || strip_ascii_prefix(&command, "intent execute ").is_some()
1586 {
1587 let value = strip_ascii_prefix(&command, "intent execute ")
1588 .map(str::trim)
1589 .filter(|value| !value.is_empty())
1590 .map(str::to_string);
1591 let Some(request) = app.intent_request.clone() else {
1592 app.report_error("Resolve an intent before executing a selected candidate.");
1593 return;
1594 };
1595 let Some(result) = app.intent_result.as_ref() else {
1596 app.report_error("No intent resolution is available for execution.");
1597 return;
1598 };
1599 let Some(candidate) = result.candidates.get(app.intent_selection) else {
1600 app.report_error("No candidate is selected.");
1601 return;
1602 };
1603 let execution = SemanticIntentExecutionRequest {
1604 request: SemanticIntentRequest {
1605 expected_revision: result.revision,
1606 ..request
1607 },
1608 candidate_id: candidate.id.clone(),
1609 value,
1610 };
1611 if let Err(error) = execution.validate() {
1612 app.report_error(error.to_string());
1613 return;
1614 }
1615 queue_browser_operation(
1616 app,
1617 commands,
1618 BrowserOperation::ExecuteIntent(Box::new(execution)),
1619 );
1620 return;
1621 }
1622 match parse_command(&command) {
1623 Ok(ParsedCommand::Local(LocalCommand::Help)) => {
1624 app.add_activity(
1625 "navigate URL | click TARGET | double click TARGET | hover TARGET | type TEXT | clear TARGET | check TARGET | uncheck TARGET | select TARGET VALUE",
1626 );
1627 app.add_activity(
1628 "press KEY | shortcut MOD+KEY | scroll [DX [DY]] | accept-dialog | dismiss-dialog | dismiss-consent | workflow FILE | resolve-intent FILE | Up/Down select candidate | intent execute [VALUE] | observe | semantic [LEVEL [REGION_ID]] | text | dom | screenshot [FILE] | profiles | knowledge [show RECORD_ID] | daemon [status|doctor|logs|recovery] | JavaScript",
1629 );
1630 }
1631 Ok(ParsedCommand::Local(LocalCommand::Profiles)) => {
1632 if let Err(error) =
1633 policy.require(crate::browser::policy::PolicyCapability::PersistentProfile)
1634 {
1635 app.report_error(error.to_string());
1636 return;
1637 }
1638 match ProfileManager::new().list_profiles() {
1639 Ok(profiles) if profiles.is_empty() => app.add_activity("No saved profiles."),
1640 Ok(profiles) => {
1641 for profile in profiles {
1642 app.add_activity(format!(" - {profile}"));
1643 }
1644 }
1645 Err(error) => app.report_error(error.to_string()),
1646 }
1647 }
1648 Ok(ParsedCommand::Local(LocalCommand::Knowledge(record_id))) => {
1649 if let Err(error) =
1650 policy.require(crate::browser::policy::PolicyCapability::PersistentProfile)
1651 {
1652 app.report_error(error.to_string());
1653 return;
1654 }
1655 match KnowledgeStore::open(&app.knowledge_path) {
1656 Ok(store) => {
1657 let content = match record_id {
1658 Some(record_id) => store
1659 .get(&record_id)
1660 .map(|record| {
1661 serde_json::to_string_pretty(record)
1662 .map_err(|error| error.to_string())
1663 })
1664 .unwrap_or_else(|| {
1665 Err(format!("knowledge record not found: {record_id}"))
1666 }),
1667 None => match store.stats() {
1668 Ok(stats) => serde_json::to_string_pretty(&serde_json::json!({
1669 "path": store.path().display().to_string(),
1670 "stats": stats,
1671 "records": store.records().iter().map(|record| serde_json::json!({
1672 "recordId": &record.record_id,
1673 "kind": record.kind,
1674 "confidence": record.confidence,
1675 "origin": &record.scope.origin,
1676 "pathPattern": &record.scope.path_pattern,
1677 })).collect::<Vec<_>>(),
1678 }))
1679 .map_err(|error| error.to_string()),
1680 Err(error) => Err(error.to_string()),
1681 },
1682 };
1683 match content {
1684 Ok(content) => {
1685 app.title = "Glass — Knowledge inspector".into();
1686 app.set_page_content(content);
1687 app.set_status("Knowledge inspector");
1688 app.add_activity("Knowledge store inspected without browser startup.");
1689 }
1690 Err(error) => app.report_error(error.to_string()),
1691 }
1692 }
1693 Err(error) => app.report_error(error.to_string()),
1694 }
1695 }
1696 Ok(ParsedCommand::Local(LocalCommand::Daemon(view))) => {
1697 let (socket, status) = crate::daemon::default_paths();
1698 let result: BrowserResult<serde_json::Value> = match view {
1699 DaemonView::Status => crate::daemon::status(Some(&socket), Some(&status))
1700 .and_then(|value| serde_json::to_value(value).map_err(Into::into)),
1701 DaemonView::Doctor => crate::daemon::doctor(Some(&socket), Some(&status)),
1702 DaemonView::Logs => crate::daemon::logs(Some(&status)),
1703 DaemonView::Recovery => crate::daemon::recovery(Some(&status)),
1704 };
1705 match result {
1706 Ok(value) => {
1707 app.title = "Glass — Daemon inspector".into();
1708 match serde_json::to_string_pretty(&value) {
1709 Ok(content) => app.set_page_content(content),
1710 Err(error) => app.report_error(error.to_string()),
1711 }
1712 app.set_status("Daemon inspector");
1713 app.add_activity(
1714 "Daemon state inspected without starting a browser operation.",
1715 );
1716 }
1717 Err(error) => app.report_error(error.to_string()),
1718 }
1719 }
1720 Ok(ParsedCommand::Browser(operation)) => queue_browser_operation(app, commands, operation),
1721 Err(error) => app.report_error(error),
1722 }
1723}
1724
1725fn queue_browser_operation(
1726 app: &mut App,
1727 commands: &mpsc::Sender<BrowserCommand>,
1728 operation: BrowserOperation,
1729) {
1730 if !app.browser_ready() {
1731 app.report_error("Browser is not ready yet.");
1732 return;
1733 }
1734 if app.is_busy() {
1735 app.report_error("A browser operation is already running; press Esc to cancel it.");
1736 return;
1737 }
1738
1739 let id = app.allocate_operation_id();
1740 let label = operation.label().to_string();
1741 match commands.try_send(BrowserCommand::Execute { id, operation }) {
1742 Ok(()) => app.begin_operation(id, label),
1743 Err(mpsc::error::TrySendError::Full(_)) => {
1744 app.report_error("Browser command queue is full.");
1745 }
1746 Err(mpsc::error::TrySendError::Closed(_)) => {
1747 app.browser_state = BrowserState::Unavailable;
1748 app.report_error("Browser worker is unavailable.");
1749 }
1750 }
1751}
1752
1753fn draw(frame: &mut Frame, app: &App) {
1754 let chunks = Layout::default()
1755 .direction(Direction::Vertical)
1756 .constraints([
1757 Constraint::Length(3),
1758 Constraint::Min(10),
1759 Constraint::Length(3),
1760 Constraint::Length(1),
1761 ])
1762 .split(frame.area());
1763
1764 let header = Layout::default()
1765 .direction(Direction::Horizontal)
1766 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1767 .split(chunks[0]);
1768
1769 let title = Paragraph::new(app.title.as_str())
1770 .block(Block::default().borders(Borders::ALL).title("Glass"))
1771 .style(
1772 Style::default()
1773 .fg(Color::Cyan)
1774 .add_modifier(Modifier::BOLD),
1775 );
1776 frame.render_widget(title, header[0]);
1777
1778 let url = Paragraph::new(app.url.as_str())
1779 .block(Block::default().borders(Borders::ALL).title("URL"))
1780 .style(Style::default().fg(Color::Yellow));
1781 frame.render_widget(url, header[1]);
1782
1783 let content = Layout::default()
1784 .direction(Direction::Horizontal)
1785 .constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
1786 .split(chunks[1]);
1787
1788 let activity = app
1789 .activity
1790 .iter()
1791 .map(|entry| ListItem::new(Line::from(entry.as_str())))
1792 .collect::<Vec<_>>();
1793 frame.render_widget(
1794 List::new(activity)
1795 .block(Block::default().borders(Borders::ALL).title("Activity"))
1796 .style(Style::default().fg(Color::Green)),
1797 content[0],
1798 );
1799
1800 frame.render_widget(
1801 Paragraph::new(app.page_content.as_str())
1802 .block(
1803 Block::default()
1804 .borders(Borders::ALL)
1805 .title("Structured Observation"),
1806 )
1807 .scroll((app.page_scroll, 0))
1808 .wrap(Wrap { trim: true }),
1809 content[1],
1810 );
1811
1812 frame.render_widget(
1813 Paragraph::new(app.input.as_str())
1814 .block(Block::default().borders(Borders::ALL).title("Command")),
1815 chunks[2],
1816 );
1817 let escape_hint = if app.is_busy() {
1818 "Esc: cancel"
1819 } else {
1820 "Esc: close error/quit"
1821 };
1822 frame.render_widget(
1823 Paragraph::new(format!(
1824 " {} {} PgUp/PgDn: observation q/Ctrl-C: quit Enter: execute {escape_hint} {}",
1825 app.status,
1826 app.capability_summary,
1827 app.input.chars().count()
1828 ))
1829 .style(Style::default().fg(Color::DarkGray)),
1830 chunks[3],
1831 );
1832
1833 if let Some(error) = &app.error_msg {
1834 let popup = centered_popup(frame.area());
1835 frame.render_widget(Clear, popup);
1836 frame.render_widget(
1837 Paragraph::new(error.as_str())
1838 .block(Block::default().borders(Borders::ALL).title("Error"))
1839 .style(Style::default().fg(Color::Red))
1840 .wrap(Wrap { trim: true }),
1841 popup,
1842 );
1843 }
1844}
1845
1846fn centered_popup(area: Rect) -> Rect {
1847 let width = (area.width.saturating_mul(2) / 3).max(1).min(area.width);
1848 let height = 5.min(area.height);
1849 Rect {
1850 x: area.width.saturating_sub(width) / 2,
1851 y: area.height.saturating_sub(height) / 2,
1852 width,
1853 height,
1854 }
1855}
1856
1857struct TerminalGuard {
1858 active: bool,
1859}
1860
1861impl TerminalGuard {
1862 fn enter() -> io::Result<Self> {
1863 enable_raw_mode()?;
1864 let mut stdout = io::stdout();
1865 if let Err(error) = execute!(stdout, EnterAlternateScreen, Hide) {
1866 let _ = disable_raw_mode();
1867 return Err(error);
1868 }
1869 Ok(Self { active: true })
1870 }
1871
1872 fn restore(&mut self) -> io::Result<()> {
1873 if !self.active {
1874 return Ok(());
1875 }
1876 self.active = false;
1877 let raw_result = disable_raw_mode();
1878 let mut stdout = io::stdout();
1879 let screen_result = execute!(stdout, LeaveAlternateScreen, Show);
1880 match (raw_result, screen_result) {
1881 (Err(error), _) | (_, Err(error)) => Err(error),
1882 (Ok(()), Ok(())) => Ok(()),
1883 }
1884 }
1885}
1886
1887impl Drop for TerminalGuard {
1888 fn drop(&mut self) {
1889 let _ = self.restore();
1890 }
1891}
1892
1893pub async fn run_tui(cli: &Cli) -> BrowserResult<()> {
1894 let mut terminal_guard = TerminalGuard::enter()?;
1895 let stdout = io::stdout();
1896 let backend = CrosstermBackend::new(stdout);
1897 let mut terminal = Terminal::new(backend)?;
1898
1899 let (input_tx, mut input_events) = mpsc::channel(INPUT_CHANNEL_CAPACITY);
1900 let (browser_commands, browser_command_rx) = mpsc::channel(BROWSER_COMMAND_CHANNEL_CAPACITY);
1901 let (browser_event_tx, mut browser_events) = mpsc::channel(BROWSER_EVENT_CHANNEL_CAPACITY);
1902 let (shutdown_tx, shutdown_rx) = watch::channel(false);
1903
1904 let options = SessionOptions {
1905 port: cli.port,
1906 chrome_path: cli.chrome_path.clone(),
1907 profile: cli.profile.clone(),
1908 incognito: cli.incognito,
1909 attach: cli.attach,
1910 target_id: cli.target_id.clone(),
1911 frame_id: cli.frame_id.clone(),
1912 headed: cli.headed,
1913 interaction_mode: cli.interaction,
1914 audit: cli.audit,
1915 policy: None,
1916 };
1917 let policy = crate::cli::runner::policy_from_cli(cli)?;
1918 let local = LocalSet::new();
1919 let browser_worker = local.spawn_local(browser_worker(
1920 options,
1921 policy.clone(),
1922 browser_command_rx,
1923 browser_event_tx,
1924 shutdown_rx,
1925 ));
1926 let mut input_worker = InputWorker::spawn(input_tx);
1927 let mut app = App::new();
1928 let manifest = GlassCapabilityManifest::for_policy_with_experimental_extensions(
1929 &policy,
1930 cli.experimental_extensions,
1931 );
1932 app.capability_summary = format!(
1933 "Capabilities: {} schemas, daemon {}",
1934 manifest.schemas.len(),
1935 if manifest.capabilities.get("localDaemon") == Some(&true) {
1936 "on"
1937 } else {
1938 "off"
1939 }
1940 );
1941 app.knowledge_path = cli
1942 .knowledge_store
1943 .clone()
1944 .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
1945
1946 let loop_result = local
1947 .run_until(run_tui_loop(
1948 &mut terminal,
1949 &mut app,
1950 &browser_commands,
1951 &mut input_events,
1952 &mut browser_events,
1953 &policy,
1954 ))
1955 .await;
1956
1957 drop(input_events);
1958 drop(browser_events);
1959 let _ = shutdown_tx.send(true);
1960 let _ = browser_commands.try_send(BrowserCommand::Shutdown);
1961 drop(browser_commands);
1962 let input_result = input_worker.stop();
1963 let cursor_result = terminal.show_cursor();
1964 let terminal_result = terminal_guard.restore();
1965 let worker_result = local.run_until(finish_browser_worker(browser_worker)).await;
1966
1967 loop_result?;
1968 input_result?;
1969 cursor_result?;
1970 terminal_result?;
1971 worker_result
1972}
1973
1974async fn run_tui_loop(
1975 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1976 app: &mut App,
1977 commands: &mpsc::Sender<BrowserCommand>,
1978 input_events: &mut mpsc::Receiver<InputEvent>,
1979 browser_events: &mut mpsc::Receiver<BrowserEvent>,
1980 policy: &BrowserPolicy,
1981) -> BrowserResult<()> {
1982 let mut redraw = true;
1983 let mut browser_events_open = true;
1984 let mut busy_tick = time::interval(BUSY_TICK);
1985 busy_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
1986
1987 while !app.should_quit {
1988 if redraw {
1989 terminal.draw(|frame| draw(frame, app))?;
1990 }
1991
1992 redraw = tokio::select! {
1993 biased;
1994 input = input_events.recv() => match input {
1995 Some(InputEvent::Key(key)) => {
1996 let intent = app.reduce_key(key);
1997 dispatch_ui_intent(app, commands, policy, intent);
1998 true
1999 }
2000 Some(InputEvent::Redraw) => true,
2001 Some(InputEvent::Error(error)) => return Err(error.into()),
2002 None => return Err("TUI input worker stopped".into()),
2003 },
2004 event = browser_events.recv(), if browser_events_open => match event {
2005 Some(event) => {
2006 if let Some(operation) = app.apply_browser_event(event)? {
2007 queue_browser_operation(app, commands, operation);
2008 }
2009 true
2010 }
2011 None => {
2012 browser_events_open = false;
2013 app.busy = None;
2014 if !matches!(app.browser_state, BrowserState::Unavailable | BrowserState::Stopped) {
2015 app.browser_state = BrowserState::Unavailable;
2016 app.set_status("Browser worker unavailable");
2017 app.report_error("Browser worker stopped unexpectedly.");
2018 }
2019 true
2020 }
2021 },
2022 _ = busy_tick.tick(), if app.is_busy() => {
2023 app.tick_busy();
2024 true
2025 },
2026 };
2027 }
2028 Ok(())
2029}
2030
2031async fn finish_browser_worker(mut worker: JoinHandle<()>) -> BrowserResult<()> {
2032 match time::timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await {
2033 Ok(Ok(())) => Ok(()),
2034 Ok(Err(error)) => Err(format!("browser worker failed: {error}").into()),
2035 Err(_) => {
2036 worker.abort();
2037 let _ = worker.await;
2038 Err("timed out waiting for browser worker shutdown".into())
2039 }
2040 }
2041}
2042
2043fn bounded_text(value: &str, max_bytes: usize) -> String {
2044 if value.len() <= max_bytes {
2045 return value.to_string();
2046 }
2047 if max_bytes == 0 {
2048 return String::new();
2049 }
2050
2051 const MARKER: &str = "\n[truncated]";
2052 if max_bytes <= MARKER.len() {
2053 let mut end = max_bytes;
2054 while end > 0 && !value.is_char_boundary(end) {
2055 end -= 1;
2056 }
2057 return value[..end].to_string();
2058 }
2059
2060 let mut end = max_bytes - MARKER.len();
2061 while end > 0 && !value.is_char_boundary(end) {
2062 end -= 1;
2063 }
2064 let mut truncated = value[..end].to_string();
2065 truncated.push_str(MARKER);
2066 truncated
2067}
2068
2069#[cfg(test)]
2070mod tests {
2071 use super::*;
2072
2073 fn key(code: KeyCode) -> KeyEvent {
2074 KeyEvent::new(code, KeyModifiers::NONE)
2075 }
2076
2077 #[test]
2078 fn command_parser_preserves_browser_actions_and_rejects_bad_scroll() {
2079 assert!(matches!(
2080 parse_command("double click r7:b42"),
2081 Ok(ParsedCommand::Browser(BrowserOperation::DoubleClick(target))) if target == "r7:b42"
2082 ));
2083 assert!(matches!(
2084 parse_command("select ref=r7:b42 premium"),
2085 Ok(ParsedCommand::Browser(BrowserOperation::Select { target, value }))
2086 if target == "ref=r7:b42" && value == "premium"
2087 ));
2088 assert!(matches!(
2089 parse_command("shortcut Ctrl+K"),
2090 Ok(ParsedCommand::Browser(BrowserOperation::Shortcut(shortcut)))
2091 if shortcut == "Ctrl+K"
2092 ));
2093 assert!(matches!(
2094 parse_command("dismiss-consent"),
2095 Ok(ParsedCommand::Browser(BrowserOperation::DismissConsent))
2096 ));
2097 assert!(parse_command("select target").is_err());
2098 assert!(matches!(
2099 parse_command("scroll -4 120"),
2100 Ok(ParsedCommand::Browser(BrowserOperation::Scroll { dx, dy })) if dx == -4.0 && dy == 120.0
2101 ));
2102 assert!(parse_command("scroll nope").is_err());
2103 assert!(matches!(
2104 parse_command("workflow workflow.json"),
2105 Ok(ParsedCommand::Browser(BrowserOperation::Workflow(path))) if path == "workflow.json"
2106 ));
2107 assert!(matches!(
2108 parse_command("resolve-intent intent.json"),
2109 Ok(ParsedCommand::Browser(BrowserOperation::ResolveIntent(path))) if path == "intent.json"
2110 ));
2111 assert!(matches!(
2112 parse_command("semantic interactive region_search_1"),
2113 Ok(ParsedCommand::Browser(BrowserOperation::Semantic {
2114 level: SemanticObservationLevel::Interactive,
2115 region: Some(region),
2116 })) if region == "region_search_1"
2117 ));
2118 assert!(parse_command("semantic verbose").is_err());
2119 assert!(matches!(
2120 parse_command("profiles"),
2121 Ok(ParsedCommand::Local(LocalCommand::Profiles))
2122 ));
2123 assert!(matches!(
2124 parse_command("knowledge"),
2125 Ok(ParsedCommand::Local(LocalCommand::Knowledge(None)))
2126 ));
2127 assert!(matches!(
2128 parse_command("knowledge show record-1"),
2129 Ok(ParsedCommand::Local(LocalCommand::Knowledge(Some(record))))
2130 if record == "record-1"
2131 ));
2132 assert!(matches!(
2133 parse_command("daemon status"),
2134 Ok(ParsedCommand::Local(LocalCommand::Daemon(
2135 DaemonView::Status
2136 )))
2137 ));
2138 assert!(matches!(
2139 parse_command("daemon recovery"),
2140 Ok(ParsedCommand::Local(LocalCommand::Daemon(
2141 DaemonView::Recovery
2142 )))
2143 ));
2144 }
2145
2146 #[test]
2147 fn reducer_edits_unicode_and_requests_matching_cancellation() {
2148 let mut app = App::new();
2149 assert_eq!(app.reduce_key(key(KeyCode::Char('日'))), UiIntent::None);
2150 assert_eq!(app.reduce_key(key(KeyCode::Char('本'))), UiIntent::None);
2151 app.reduce_key(key(KeyCode::Backspace));
2152 assert_eq!(app.input, "日");
2153
2154 app.browser_state = BrowserState::Ready;
2155 app.begin_operation(4, "Observe");
2156 assert_eq!(app.reduce_key(key(KeyCode::Esc)), UiIntent::Cancel(4));
2157 assert!(app.busy.as_ref().unwrap().cancelling);
2158 assert_eq!(app.reduce_key(key(KeyCode::Esc)), UiIntent::None);
2159 }
2160
2161 #[test]
2162 fn app_bounds_retained_page_state() {
2163 let mut app = App::new();
2164 app.set_page_content("界".repeat(TUI_PAGE_MAX_BYTES));
2165
2166 assert!(app.page_content.len() <= TUI_PAGE_MAX_BYTES);
2167 assert!(app.page_content.contains("[truncated]"));
2168 }
2169
2170 #[tokio::test]
2171 async fn matching_cancel_interrupts_an_active_operation() {
2172 let (command_tx, mut command_rx) = mpsc::channel(2);
2173 let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
2174 let (event_tx, _event_rx) = mpsc::channel(2);
2175 command_tx
2176 .send(BrowserCommand::Cancel { id: 9 })
2177 .await
2178 .unwrap();
2179
2180 let result = await_active_operation(
2181 std::future::pending::<BrowserResult<Box<OperationResult>>>(),
2182 9,
2183 &mut command_rx,
2184 &mut shutdown_rx,
2185 &event_tx,
2186 )
2187 .await;
2188
2189 assert!(matches!(result, ActiveOperationState::Cancelled));
2190 }
2191
2192 #[tokio::test]
2193 async fn delayed_worker_event_does_not_block_input_reducer() {
2194 let (command_tx, mut command_rx) = mpsc::channel(2);
2195 let (event_tx, mut event_rx) = mpsc::channel(4);
2196 let worker = tokio::spawn(async move {
2197 let Some(BrowserCommand::Execute { id, .. }) = command_rx.recv().await else {
2198 return;
2199 };
2200 event_tx
2201 .send(BrowserEvent::OperationStarted {
2202 id,
2203 label: "Observe".to_string(),
2204 })
2205 .await
2206 .unwrap();
2207 time::sleep(Duration::from_millis(100)).await;
2208 event_tx
2209 .send(BrowserEvent::OperationFinished {
2210 id,
2211 result: Box::new(OperationResult {
2212 activity: "Observation refreshed.".to_string(),
2213 update: None,
2214 }),
2215 })
2216 .await
2217 .unwrap();
2218 });
2219
2220 let mut app = App::new();
2221 app.browser_state = BrowserState::Ready;
2222 app.begin_operation(1, "Observe");
2223 command_tx
2224 .send(BrowserCommand::Execute {
2225 id: 1,
2226 operation: BrowserOperation::Observe { fresh: false },
2227 })
2228 .await
2229 .unwrap();
2230 app.apply_browser_event(event_rx.recv().await.unwrap())
2231 .unwrap();
2232
2233 assert_eq!(app.reduce_key(key(KeyCode::Char('x'))), UiIntent::None);
2234 assert_eq!(app.input, "x");
2235 assert!(
2236 time::timeout(Duration::from_millis(20), event_rx.recv())
2237 .await
2238 .is_err()
2239 );
2240
2241 app.apply_browser_event(event_rx.recv().await.unwrap())
2242 .unwrap();
2243 assert!(!app.is_busy());
2244 worker.await.unwrap();
2245 }
2246}