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::VecDeque,
22 future::Future,
23 io,
24 sync::{
25 Arc,
26 atomic::{AtomicBool, Ordering},
27 },
28 thread,
29 time::Duration,
30};
31use tokio::{
32 sync::{mpsc, watch},
33 task::{JoinHandle, LocalSet},
34 time::{self, MissedTickBehavior},
35};
36
37use crate::browser::policy::BrowserPolicy;
38use crate::browser::profile::ProfileManager;
39use crate::browser::session::{
40 ActionOutcome, BrowserResult, BrowserSession, PageContext, PageInfo, SessionOptions,
41};
42use crate::cli::args::Cli;
43
44const INPUT_CHANNEL_CAPACITY: usize = 64;
45const BROWSER_COMMAND_CHANNEL_CAPACITY: usize = 8;
46const BROWSER_EVENT_CHANNEL_CAPACITY: usize = 8;
47const ACTIVITY_LIMIT: usize = 100;
48const TUI_PAGE_MAX_BYTES: usize = 24 * 1024;
49const TUI_HEADER_MAX_BYTES: usize = 512;
50const TUI_ACTIVITY_MAX_BYTES: usize = 512;
51const TUI_INPUT_MAX_BYTES: usize = 4 * 1024;
52const BUSY_TICK: Duration = Duration::from_millis(120);
53const INPUT_POLL: Duration = Duration::from_millis(50);
54const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
55
56pub struct App {
57 url: String,
58 title: String,
59 activity: VecDeque<String>,
60 page_content: String,
61 page_scroll: u16,
62 input: String,
63 cursor_pos: usize,
64 should_quit: bool,
65 error_msg: Option<String>,
66 status: String,
67 browser_state: BrowserState,
68 busy: Option<BusyState>,
69 next_operation_id: u64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum BrowserState {
74 Connecting,
75 Ready,
76 Unavailable,
77 Stopped,
78}
79
80#[derive(Debug, Clone)]
81struct BusyState {
82 id: u64,
83 label: String,
84 cancelling: bool,
85 spinner: usize,
86}
87
88#[derive(Debug, PartialEq, Eq)]
89enum UiIntent {
90 None,
91 Submit(String),
92 Cancel(u64),
93 Quit,
94}
95
96impl App {
97 fn new() -> Self {
98 let mut activity = VecDeque::new();
99 activity.push_back("Glass started.".to_string());
100 activity.push_back("Connecting to Chrome…".to_string());
101 Self {
102 url: String::new(),
103 title: "Glass — Browser Agent".to_string(),
104 activity,
105 page_content: "No page loaded.".to_string(),
106 page_scroll: 0,
107 input: String::new(),
108 cursor_pos: 0,
109 should_quit: false,
110 error_msg: None,
111 status: "Connecting to Chrome…".to_string(),
112 browser_state: BrowserState::Connecting,
113 busy: None,
114 next_operation_id: 1,
115 }
116 }
117
118 fn add_activity(&mut self, message: impl Into<String>) {
119 let message = bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES);
120 if self.activity.len() == ACTIVITY_LIMIT {
121 self.activity.pop_front();
122 }
123 self.activity.push_back(message);
124 }
125
126 fn set_error(&mut self, message: impl Into<String>) {
127 self.error_msg = Some(bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES));
128 }
129
130 fn report_error(&mut self, message: impl Into<String>) {
131 let message = bounded_text(&message.into(), TUI_ACTIVITY_MAX_BYTES);
132 self.set_error(message.clone());
133 self.add_activity(format!("Error: {message}"));
134 }
135
136 fn clear_error(&mut self) {
137 self.error_msg = None;
138 }
139
140 fn set_status(&mut self, status: impl Into<String>) {
141 self.status = bounded_text(&status.into(), TUI_ACTIVITY_MAX_BYTES);
142 }
143
144 fn cursor_byte_index(&self) -> usize {
145 self.input
146 .char_indices()
147 .nth(self.cursor_pos)
148 .map(|(index, _)| index)
149 .unwrap_or(self.input.len())
150 }
151
152 fn insert_char(&mut self, character: char) -> bool {
153 if self.input.len().saturating_add(character.len_utf8()) > TUI_INPUT_MAX_BYTES {
154 return false;
155 }
156 let index = self.cursor_byte_index();
157 self.input.insert(index, character);
158 self.cursor_pos += 1;
159 true
160 }
161
162 fn remove_before_cursor(&mut self) {
163 if self.cursor_pos == 0 {
164 return;
165 }
166 let end = self.cursor_byte_index();
167 let start = self
168 .input
169 .char_indices()
170 .nth(self.cursor_pos - 1)
171 .map(|(index, _)| index)
172 .unwrap_or(0);
173 self.input.drain(start..end);
174 self.cursor_pos -= 1;
175 }
176
177 fn remove_at_cursor(&mut self) {
178 let start = self.cursor_byte_index();
179 let end = self
180 .input
181 .char_indices()
182 .nth(self.cursor_pos + 1)
183 .map(|(index, _)| index)
184 .unwrap_or(self.input.len());
185 if start < end {
186 self.input.drain(start..end);
187 }
188 }
189
190 fn reduce_key(&mut self, key: KeyEvent) -> UiIntent {
191 if key.kind != KeyEventKind::Press {
192 return UiIntent::None;
193 }
194
195 match key.code {
196 KeyCode::Char('q' | 'c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
197 UiIntent::Quit
198 }
199 KeyCode::Char('q') if self.input.is_empty() => UiIntent::Quit,
200 KeyCode::Esc => {
201 let cancellation = self.busy.as_mut().and_then(|busy| {
202 if busy.cancelling {
203 None
204 } else {
205 busy.cancelling = true;
206 Some((busy.id, busy.label.clone()))
207 }
208 });
209 if let Some((id, label)) = cancellation {
210 self.set_status(format!("Cancelling: {label}"));
211 self.add_activity(format!("Cancellation requested: {label}"));
212 UiIntent::Cancel(id)
213 } else if self.busy.is_some() {
214 UiIntent::None
215 } else if self.error_msg.is_some() {
216 self.clear_error();
217 UiIntent::None
218 } else {
219 UiIntent::Quit
220 }
221 }
222 KeyCode::Enter if !self.input.trim().is_empty() => {
223 let command = std::mem::take(&mut self.input);
224 self.cursor_pos = 0;
225 UiIntent::Submit(command)
226 }
227 KeyCode::Backspace => {
228 self.remove_before_cursor();
229 UiIntent::None
230 }
231 KeyCode::Delete => {
232 self.remove_at_cursor();
233 UiIntent::None
234 }
235 KeyCode::Left => {
236 self.cursor_pos = self.cursor_pos.saturating_sub(1);
237 UiIntent::None
238 }
239 KeyCode::Right => {
240 self.cursor_pos = (self.cursor_pos + 1).min(self.input.chars().count());
241 UiIntent::None
242 }
243 KeyCode::Home => {
244 self.cursor_pos = 0;
245 UiIntent::None
246 }
247 KeyCode::End => {
248 self.cursor_pos = self.input.chars().count();
249 UiIntent::None
250 }
251 KeyCode::PageUp => {
252 self.page_scroll = self.page_scroll.saturating_sub(10);
253 UiIntent::None
254 }
255 KeyCode::PageDown => {
256 self.page_scroll = self.page_scroll.saturating_add(10);
257 UiIntent::None
258 }
259 KeyCode::Char(character) => {
260 if !self.insert_char(character) {
261 self.report_error(format!(
262 "Command input is limited to {TUI_INPUT_MAX_BYTES} bytes."
263 ));
264 }
265 UiIntent::None
266 }
267 _ => UiIntent::None,
268 }
269 }
270
271 fn browser_ready(&self) -> bool {
272 self.browser_state == BrowserState::Ready
273 }
274
275 fn is_busy(&self) -> bool {
276 self.busy.is_some()
277 }
278
279 fn allocate_operation_id(&mut self) -> u64 {
280 let id = self.next_operation_id;
281 self.next_operation_id = self.next_operation_id.checked_add(1).unwrap_or(1);
282 id
283 }
284
285 fn begin_operation(&mut self, id: u64, label: impl Into<String>) {
286 let label = bounded_text(&label.into(), TUI_ACTIVITY_MAX_BYTES);
287 self.busy = Some(BusyState {
288 id,
289 label: label.clone(),
290 cancelling: false,
291 spinner: 0,
292 });
293 self.set_status(format!("Queued: {label}"));
294 self.add_activity(format!("Queued: {label}"));
295 }
296
297 fn finish_operation(&mut self, id: u64) {
298 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
299 self.busy = None;
300 if self.browser_ready() {
301 self.set_status("Ready");
302 }
303 }
304 }
305
306 fn cancellation_enqueue_failed(&mut self, id: u64) {
307 let label = self.busy.as_mut().filter(|busy| busy.id == id).map(|busy| {
308 busy.cancelling = false;
309 busy.label.clone()
310 });
311 if let Some(label) = label {
312 self.set_status(format!("Working: {label}"));
313 }
314 }
315
316 fn tick_busy(&mut self) {
317 let status = self.busy.as_mut().map(|busy| {
318 busy.spinner = busy.spinner.wrapping_add(1);
319 if busy.cancelling {
320 format!("Cancelling: {}", busy.label)
321 } else {
322 let frame = ['|', '/', '-', '\\'][busy.spinner % 4];
323 format!("{frame} Working: {}", busy.label)
324 }
325 });
326 if let Some(status) = status {
327 self.set_status(status);
328 }
329 }
330
331 fn apply_browser_event(
332 &mut self,
333 event: BrowserEvent,
334 ) -> BrowserResult<Option<BrowserOperation>> {
335 match event {
336 BrowserEvent::Connecting => {
337 self.browser_state = BrowserState::Connecting;
338 self.set_status("Connecting to Chrome…");
339 self.add_activity("Browser worker is connecting.");
340 }
341 BrowserEvent::Ready { port } => {
342 self.browser_state = BrowserState::Ready;
343 self.set_status(format!("Connected on port {port}"));
344 self.add_activity("Connected to Chrome.");
345 return Ok(Some(BrowserOperation::Observe { fresh: false }));
346 }
347 BrowserEvent::StartupFailed { message } => {
348 self.browser_state = BrowserState::Unavailable;
349 self.busy = None;
350 self.set_status("Browser unavailable");
351 self.report_error(message);
352 }
353 BrowserEvent::OperationStarted { id, label } => {
354 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
355 self.set_status(format!("Working: {label}"));
356 self.add_activity(format!("Started: {label}"));
357 }
358 }
359 BrowserEvent::OperationFinished { id, result } => {
360 self.finish_operation(id);
361 if let Some(update) = result.update {
362 self.apply_page_update(update)?;
363 }
364 self.add_activity(result.activity);
365 }
366 BrowserEvent::OperationFailed { id, message } => {
367 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
368 self.finish_operation(id);
369 self.report_error(message);
370 } else {
371 self.add_activity(format!("Rejected operation {id}: {message}"));
372 }
373 }
374 BrowserEvent::OperationCancelled { id } => {
375 if self.busy.as_ref().is_some_and(|busy| busy.id == id) {
376 self.finish_operation(id);
377 self.add_activity(format!("Cancelled operation {id}."));
378 }
379 }
380 BrowserEvent::WorkerFailed { message } => {
381 self.browser_state = BrowserState::Unavailable;
382 self.busy = None;
383 self.set_status("Browser worker failed");
384 self.report_error(message);
385 }
386 BrowserEvent::WorkerStopped => {
387 self.browser_state = BrowserState::Stopped;
388 self.busy = None;
389 self.set_status("Browser worker stopped");
390 self.add_activity("Browser worker stopped.");
391 }
392 }
393 Ok(None)
394 }
395
396 fn apply_page_update(&mut self, update: PageUpdate) -> BrowserResult<()> {
397 match update {
398 PageUpdate::Context(context) => self.apply_context(&context),
399 PageUpdate::Text { page, text } => {
400 self.apply_page_header(&page);
401 self.set_page_content(text);
402 Ok(())
403 }
404 }
405 }
406
407 fn apply_context(&mut self, context: &PageContext) -> BrowserResult<()> {
408 if context.screenshot.is_some() {
409 return Err("TUI worker must not retain screenshot data".into());
410 }
411 self.apply_page_header(&context.page);
412 self.set_page_content(serde_json::to_string_pretty(context)?);
413 Ok(())
414 }
415
416 fn apply_page_header(&mut self, page: &PageInfo) {
417 self.url = bounded_text(&page.url, TUI_HEADER_MAX_BYTES);
418 self.title = bounded_text(&format!("Glass — {}", page.title), TUI_HEADER_MAX_BYTES);
419 }
420
421 fn set_page_content(&mut self, content: impl Into<String>) {
422 self.page_content = bounded_text(&content.into(), TUI_PAGE_MAX_BYTES);
423 self.page_scroll = 0;
424 }
425}
426
427#[derive(Debug)]
428enum InputEvent {
429 Key(KeyEvent),
430 Redraw,
431 Error(String),
432}
433
434struct InputWorker {
435 shutdown: Arc<AtomicBool>,
436 join: Option<thread::JoinHandle<()>>,
437}
438
439impl InputWorker {
440 fn spawn(events: mpsc::Sender<InputEvent>) -> Self {
441 let shutdown = Arc::new(AtomicBool::new(false));
442 let worker_shutdown = Arc::clone(&shutdown);
443 let join = thread::spawn(move || {
444 while !worker_shutdown.load(Ordering::Relaxed) {
445 match event::poll(INPUT_POLL) {
446 Ok(false) => {}
447 Ok(true) => match event::read() {
448 Ok(Event::Key(key)) => {
449 if events.blocking_send(InputEvent::Key(key)).is_err() {
450 break;
451 }
452 }
453 Ok(_) => {
454 if events.blocking_send(InputEvent::Redraw).is_err() {
455 break;
456 }
457 }
458 Err(error) => {
459 let _ = events.blocking_send(InputEvent::Error(error.to_string()));
460 break;
461 }
462 },
463 Err(error) => {
464 let _ = events.blocking_send(InputEvent::Error(error.to_string()));
465 break;
466 }
467 }
468 }
469 });
470 Self {
471 shutdown,
472 join: Some(join),
473 }
474 }
475
476 fn stop(&mut self) -> BrowserResult<()> {
477 self.shutdown.store(true, Ordering::Relaxed);
478 if self.join.take().is_some_and(|join| join.join().is_err()) {
479 return Err("TUI input worker panicked".into());
480 }
481 Ok(())
482 }
483}
484
485impl Drop for InputWorker {
486 fn drop(&mut self) {
487 let _ = self.stop();
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Eq)]
492enum LocalCommand {
493 Help,
494 Profiles,
495}
496
497#[derive(Debug, Clone, PartialEq)]
498enum BrowserOperation {
499 Navigate(String),
500 Screenshot(String),
501 Text,
502 Dom,
503 Observe { fresh: bool },
504 Click(String),
505 DoubleClick(String),
506 Type(String),
507 Scroll { dx: f64, dy: f64 },
508 Evaluate(String),
509}
510
511impl BrowserOperation {
512 fn label(&self) -> &'static str {
513 match self {
514 Self::Navigate(_) => "Navigate",
515 Self::Screenshot(_) => "Screenshot",
516 Self::Text => "Text",
517 Self::Dom => "Compact DOM",
518 Self::Observe { .. } => "Observe",
519 Self::Click(_) => "Click",
520 Self::DoubleClick(_) => "Double-click",
521 Self::Type(_) => "Type",
522 Self::Scroll { .. } => "Scroll",
523 Self::Evaluate(_) => "Evaluate",
524 }
525 }
526}
527
528#[derive(Debug, Clone, PartialEq)]
529enum ParsedCommand {
530 Local(LocalCommand),
531 Browser(BrowserOperation),
532}
533
534fn parse_command(input: &str) -> Result<ParsedCommand, String> {
535 let command = input.trim();
536 if command.is_empty() {
537 return Err("command cannot be empty".to_string());
538 }
539 if command.eq_ignore_ascii_case("help") {
540 return Ok(ParsedCommand::Local(LocalCommand::Help));
541 }
542 if command.eq_ignore_ascii_case("profiles") {
543 return Ok(ParsedCommand::Local(LocalCommand::Profiles));
544 }
545 for prefix in ["navigate ", "go to ", "go "] {
546 if let Some(url) = strip_ascii_prefix(command, prefix) {
547 return required_command_argument(url, "URL")
548 .map(BrowserOperation::Navigate)
549 .map(ParsedCommand::Browser);
550 }
551 }
552 if let Some(target) = strip_ascii_prefix(command, "double click ") {
553 return required_command_argument(target, "double-click target")
554 .map(BrowserOperation::DoubleClick)
555 .map(ParsedCommand::Browser);
556 }
557 if let Some(target) = strip_ascii_prefix(command, "click ") {
558 return required_command_argument(target, "click target")
559 .map(BrowserOperation::Click)
560 .map(ParsedCommand::Browser);
561 }
562 if let Some(text) = strip_ascii_prefix(command, "type ") {
563 return required_command_argument(text, "text")
564 .map(BrowserOperation::Type)
565 .map(ParsedCommand::Browser);
566 }
567 if command.eq_ignore_ascii_case("screenshot") {
568 return Ok(ParsedCommand::Browser(BrowserOperation::Screenshot(
569 "screenshot.png".to_string(),
570 )));
571 }
572 if let Some(output) = strip_ascii_prefix(command, "screenshot ") {
573 let output = output.trim();
574 return Ok(ParsedCommand::Browser(BrowserOperation::Screenshot(
575 if output.is_empty() {
576 "screenshot.png".to_string()
577 } else {
578 output.to_string()
579 },
580 )));
581 }
582 if ["text", "content", "get text", "page text"]
583 .iter()
584 .any(|candidate| command.eq_ignore_ascii_case(candidate))
585 {
586 return Ok(ParsedCommand::Browser(BrowserOperation::Text));
587 }
588 if ["dom", "snapshot", "get dom"]
589 .iter()
590 .any(|candidate| command.eq_ignore_ascii_case(candidate))
591 {
592 return Ok(ParsedCommand::Browser(BrowserOperation::Dom));
593 }
594 if ["observe", "context"]
595 .iter()
596 .any(|candidate| command.eq_ignore_ascii_case(candidate))
597 {
598 return Ok(ParsedCommand::Browser(BrowserOperation::Observe {
599 fresh: false,
600 }));
601 }
602 if command.eq_ignore_ascii_case("scroll") {
603 return Ok(ParsedCommand::Browser(BrowserOperation::Scroll {
604 dx: 0.0,
605 dy: 600.0,
606 }));
607 }
608 if let Some(values) = strip_ascii_prefix(command, "scroll ") {
609 return parse_scroll(values).map(ParsedCommand::Browser);
610 }
611 Ok(ParsedCommand::Browser(BrowserOperation::Evaluate(
612 command.to_string(),
613 )))
614}
615
616fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
617 value
618 .get(..prefix.len())
619 .filter(|head| head.eq_ignore_ascii_case(prefix))?;
620 Some(&value[prefix.len()..])
621}
622
623fn required_command_argument(value: &str, name: &str) -> Result<String, String> {
624 let value = value.trim();
625 if value.is_empty() {
626 Err(format!("{name} cannot be empty"))
627 } else {
628 Ok(value.to_string())
629 }
630}
631
632fn parse_scroll(values: &str) -> Result<BrowserOperation, String> {
633 let mut values = values.split_whitespace();
634 let dx = values
635 .next()
636 .map(|value| {
637 value
638 .parse::<f64>()
639 .map_err(|_| "scroll dx must be a number")
640 })
641 .transpose()?
642 .unwrap_or(0.0);
643 let dy = values
644 .next()
645 .map(|value| {
646 value
647 .parse::<f64>()
648 .map_err(|_| "scroll dy must be a number")
649 })
650 .transpose()?
651 .unwrap_or(600.0);
652 if values.next().is_some() {
653 return Err("scroll accepts at most dx and dy".to_string());
654 }
655 Ok(BrowserOperation::Scroll { dx, dy })
656}
657
658#[derive(Debug)]
659enum BrowserCommand {
660 Execute {
661 id: u64,
662 operation: BrowserOperation,
663 },
664 Cancel {
665 id: u64,
666 },
667 Shutdown,
668}
669
670#[derive(Debug)]
671enum BrowserEvent {
672 Connecting,
673 Ready {
674 port: u16,
675 },
676 StartupFailed {
677 message: String,
678 },
679 OperationStarted {
680 id: u64,
681 label: String,
682 },
683 OperationFinished {
684 id: u64,
685 result: Box<OperationResult>,
686 },
687 OperationFailed {
688 id: u64,
689 message: String,
690 },
691 OperationCancelled {
692 id: u64,
693 },
694 WorkerFailed {
695 message: String,
696 },
697 WorkerStopped,
698}
699
700#[derive(Debug)]
701enum PageUpdate {
702 Context(Box<PageContext>),
703 Text { page: PageInfo, text: String },
704}
705
706#[derive(Debug)]
707struct OperationResult {
708 activity: String,
709 update: Option<PageUpdate>,
710}
711
712enum ActiveOperationState {
713 Completed(BrowserResult<Box<OperationResult>>),
714 Cancelled,
715 Shutdown,
716}
717
718async fn browser_worker(
719 options: SessionOptions,
720 policy: BrowserPolicy,
721 mut commands: mpsc::Receiver<BrowserCommand>,
722 events: mpsc::Sender<BrowserEvent>,
723 mut shutdown: watch::Receiver<bool>,
724) {
725 if !send_browser_event(&events, BrowserEvent::Connecting).await {
726 return;
727 }
728
729 let Some(session) =
730 start_browser_session(&options, policy, &mut commands, &events, &mut shutdown).await
731 else {
732 return;
733 };
734 if !send_browser_event(&events, BrowserEvent::Ready { port: options.port }).await {
735 let _ = session.close().await;
736 return;
737 }
738
739 worker_loop(session, &mut commands, &events, &mut shutdown).await;
740}
741
742async fn start_browser_session(
743 options: &SessionOptions,
744 policy: BrowserPolicy,
745 commands: &mut mpsc::Receiver<BrowserCommand>,
746 events: &mpsc::Sender<BrowserEvent>,
747 shutdown: &mut watch::Receiver<bool>,
748) -> Option<BrowserSession> {
749 let start = BrowserSession::start_with_policy(options, policy);
750 tokio::pin!(start);
751
752 loop {
753 if *shutdown.borrow() {
754 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
755 return None;
756 }
757 tokio::select! {
758 biased;
759 changed = shutdown.changed() => {
760 if changed.is_err() || *shutdown.borrow() {
761 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
762 return None;
763 }
764 }
765 command = commands.recv() => match command {
766 Some(BrowserCommand::Shutdown) | None => {
767 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
768 return None;
769 }
770 Some(BrowserCommand::Execute { id, .. }) => {
771 if !send_browser_event(events, BrowserEvent::OperationFailed {
772 id,
773 message: "browser is still starting".to_string(),
774 }).await {
775 return None;
776 }
777 }
778 Some(BrowserCommand::Cancel { id }) => {
779 if !send_browser_event(events, BrowserEvent::OperationCancelled { id }).await {
780 return None;
781 }
782 }
783 },
784 result = &mut start => match result {
785 Ok(session) => return Some(session),
786 Err(error) => {
787 let message = error.to_string();
788 drop(error);
789 let _ = send_browser_event(events, BrowserEvent::StartupFailed {
790 message,
791 }).await;
792 return None;
793 }
794 },
795 }
796 }
797}
798
799async fn worker_loop(
800 session: BrowserSession,
801 commands: &mut mpsc::Receiver<BrowserCommand>,
802 events: &mpsc::Sender<BrowserEvent>,
803 shutdown: &mut watch::Receiver<bool>,
804) {
805 loop {
806 if *shutdown.borrow() {
807 break;
808 }
809 tokio::select! {
810 biased;
811 changed = shutdown.changed() => {
812 if changed.is_err() || *shutdown.borrow() {
813 break;
814 }
815 }
816 command = commands.recv() => match command {
817 Some(BrowserCommand::Shutdown) | None => break,
818 Some(BrowserCommand::Cancel { .. }) => {}
819 Some(BrowserCommand::Execute { id, operation }) => {
820 let label = operation.label().to_string();
821 if !send_browser_event(events, BrowserEvent::OperationStarted { id, label }).await {
822 break;
823 }
824 match await_active_operation(
825 execute_browser_operation(&session, operation),
826 id,
827 commands,
828 shutdown,
829 events,
830 ).await {
831 ActiveOperationState::Completed(Ok(result)) => {
832 if !send_browser_event(events, BrowserEvent::OperationFinished { id, result }).await {
833 break;
834 }
835 }
836 ActiveOperationState::Completed(Err(error)) => {
837 let message = error.to_string();
838 drop(error);
839 if !send_browser_event(events, BrowserEvent::OperationFailed {
840 id,
841 message,
842 }).await {
843 break;
844 }
845 }
846 ActiveOperationState::Cancelled => {
847 if !send_browser_event(events, BrowserEvent::OperationCancelled { id }).await {
848 break;
849 }
850 }
851 ActiveOperationState::Shutdown => break,
852 }
853 }
854 },
855 }
856 }
857
858 let close_error = session.close().await.err().map(|error| error.to_string());
859 if let Some(message) = close_error {
860 let _ = send_browser_event(events, BrowserEvent::WorkerFailed { message }).await;
861 }
862 let _ = send_browser_event(events, BrowserEvent::WorkerStopped).await;
863}
864
865async fn await_active_operation<F>(
866 operation: F,
867 id: u64,
868 commands: &mut mpsc::Receiver<BrowserCommand>,
869 shutdown: &mut watch::Receiver<bool>,
870 events: &mpsc::Sender<BrowserEvent>,
871) -> ActiveOperationState
872where
873 F: Future<Output = BrowserResult<Box<OperationResult>>>,
874{
875 tokio::pin!(operation);
876 loop {
877 if *shutdown.borrow() {
878 return ActiveOperationState::Shutdown;
879 }
880 tokio::select! {
881 biased;
882 changed = shutdown.changed() => {
883 if changed.is_err() || *shutdown.borrow() {
884 return ActiveOperationState::Shutdown;
885 }
886 }
887 command = commands.recv() => match command {
888 Some(BrowserCommand::Shutdown) | None => return ActiveOperationState::Shutdown,
889 Some(BrowserCommand::Cancel { id: cancel_id }) if cancel_id == id => {
890 return ActiveOperationState::Cancelled;
891 }
892 Some(BrowserCommand::Execute { id: queued_id, .. }) => {
893 if !send_browser_event(events, BrowserEvent::OperationFailed {
894 id: queued_id,
895 message: "browser worker is already executing an operation".to_string(),
896 }).await {
897 return ActiveOperationState::Shutdown;
898 }
899 }
900 Some(BrowserCommand::Cancel { .. }) => {}
901 },
902 result = &mut operation => return ActiveOperationState::Completed(result),
903 }
904 }
905}
906
907async fn execute_browser_operation(
908 session: &BrowserSession,
909 operation: BrowserOperation,
910) -> BrowserResult<Box<OperationResult>> {
911 match operation {
912 BrowserOperation::Navigate(url) => {
913 let page = session.navigate(&url).await?;
914 let context = session.observe_fresh().await?;
915 Ok(Box::new(OperationResult {
916 activity: format!("Page loaded: {}", page.title),
917 update: Some(PageUpdate::Context(Box::new(context))),
918 }))
919 }
920 BrowserOperation::Screenshot(output) => {
921 let output = session
922 .policy()
923 .require_output_path(std::path::Path::new(&output))?;
924 tokio::fs::write(&output, session.screenshot_png().await?).await?;
925 Ok(Box::new(OperationResult {
926 activity: format!("Screenshot saved to {}", output.display()),
927 update: None,
928 }))
929 }
930 BrowserOperation::Text => {
931 let context = session.observe().await?;
932 Ok(Box::new(OperationResult {
933 activity: "Page text refreshed.".to_string(),
934 update: Some(PageUpdate::Text {
935 page: context.page,
936 text: context.text,
937 }),
938 }))
939 }
940 BrowserOperation::Dom => {
941 let context = session.observe_fresh().await?;
942 Ok(Box::new(OperationResult {
943 activity: "Compact DOM and accessibility context refreshed.".to_string(),
944 update: Some(PageUpdate::Context(Box::new(context))),
945 }))
946 }
947 BrowserOperation::Observe { fresh } => {
948 let context = if fresh {
949 session.observe_fresh().await?
950 } else {
951 session.observe().await?
952 };
953 Ok(Box::new(OperationResult {
954 activity: "Compact observation refreshed.".to_string(),
955 update: Some(PageUpdate::Context(Box::new(context))),
956 }))
957 }
958 BrowserOperation::Click(target) => {
959 let outcome = session.click(&target).await?;
960 let context = session.observe_fresh().await?;
961 Ok(Box::new(OperationResult {
962 activity: action_activity("Clicked", &outcome),
963 update: Some(PageUpdate::Context(Box::new(context))),
964 }))
965 }
966 BrowserOperation::DoubleClick(target) => {
967 let outcome = session.double_click(&target).await?;
968 let context = session.observe_fresh().await?;
969 Ok(Box::new(OperationResult {
970 activity: action_activity("Double-clicked", &outcome),
971 update: Some(PageUpdate::Context(Box::new(context))),
972 }))
973 }
974 BrowserOperation::Type(text) => {
975 let character_count = text.chars().count();
976 let outcome = session.type_text(&text, None).await?;
977 let context = session.observe_fresh().await?;
978 Ok(Box::new(OperationResult {
979 activity: format!(
980 "Typed {character_count} characters (revision {}).",
981 outcome.revision
982 ),
983 update: Some(PageUpdate::Context(Box::new(context))),
984 }))
985 }
986 BrowserOperation::Scroll { dx, dy } => {
987 let outcome = session.scroll(dx, dy).await?;
988 let context = session.observe_fresh().await?;
989 Ok(Box::new(OperationResult {
990 activity: format!("Scrolled (revision {}).", outcome.revision),
991 update: Some(PageUpdate::Context(Box::new(context))),
992 }))
993 }
994 BrowserOperation::Evaluate(expression) => {
995 let result = session.evaluate(&expression).await?;
996 let context = session.observe_fresh().await?;
997 Ok(Box::new(OperationResult {
998 activity: format!(
999 "Result: {}",
1000 bounded_text(&result.to_string(), TUI_ACTIVITY_MAX_BYTES)
1001 ),
1002 update: Some(PageUpdate::Context(Box::new(context))),
1003 }))
1004 }
1005 }
1006}
1007
1008fn action_activity(verb: &str, outcome: &ActionOutcome) -> String {
1009 let target = outcome
1010 .target
1011 .as_ref()
1012 .map(|target| target.label.as_str())
1013 .unwrap_or("page");
1014 format!("{verb} {target} (revision {}).", outcome.revision)
1015}
1016
1017async fn send_browser_event(events: &mpsc::Sender<BrowserEvent>, event: BrowserEvent) -> bool {
1018 events.send(event).await.is_ok()
1019}
1020
1021fn dispatch_ui_intent(
1022 app: &mut App,
1023 commands: &mpsc::Sender<BrowserCommand>,
1024 policy: &BrowserPolicy,
1025 intent: UiIntent,
1026) {
1027 match intent {
1028 UiIntent::None => {}
1029 UiIntent::Submit(command) => handle_submission(app, commands, policy, command),
1030 UiIntent::Cancel(id) => {
1031 if commands.try_send(BrowserCommand::Cancel { id }).is_err() {
1032 app.cancellation_enqueue_failed(id);
1033 app.report_error(
1034 "Browser worker is unavailable; cancellation could not be queued.",
1035 );
1036 }
1037 }
1038 UiIntent::Quit => app.should_quit = true,
1039 }
1040}
1041
1042fn handle_submission(
1043 app: &mut App,
1044 commands: &mpsc::Sender<BrowserCommand>,
1045 policy: &BrowserPolicy,
1046 command: String,
1047) {
1048 app.add_activity(format!("> {command}"));
1049 match parse_command(&command) {
1050 Ok(ParsedCommand::Local(LocalCommand::Help)) => {
1051 app.add_activity("navigate URL | click TARGET | double click TARGET | type TEXT");
1052 app.add_activity(
1053 "observe | text | dom | scroll [DX [DY]] | screenshot [FILE] | profiles | JavaScript",
1054 );
1055 }
1056 Ok(ParsedCommand::Local(LocalCommand::Profiles)) => {
1057 if let Err(error) =
1058 policy.require(crate::browser::policy::PolicyCapability::PersistentProfile)
1059 {
1060 app.report_error(error.to_string());
1061 return;
1062 }
1063 match ProfileManager::new().list_profiles() {
1064 Ok(profiles) if profiles.is_empty() => app.add_activity("No saved profiles."),
1065 Ok(profiles) => {
1066 for profile in profiles {
1067 app.add_activity(format!(" - {profile}"));
1068 }
1069 }
1070 Err(error) => app.report_error(error.to_string()),
1071 }
1072 }
1073 Ok(ParsedCommand::Browser(operation)) => queue_browser_operation(app, commands, operation),
1074 Err(error) => app.report_error(error),
1075 }
1076}
1077
1078fn queue_browser_operation(
1079 app: &mut App,
1080 commands: &mpsc::Sender<BrowserCommand>,
1081 operation: BrowserOperation,
1082) {
1083 if !app.browser_ready() {
1084 app.report_error("Browser is not ready yet.");
1085 return;
1086 }
1087 if app.is_busy() {
1088 app.report_error("A browser operation is already running; press Esc to cancel it.");
1089 return;
1090 }
1091
1092 let id = app.allocate_operation_id();
1093 let label = operation.label().to_string();
1094 match commands.try_send(BrowserCommand::Execute { id, operation }) {
1095 Ok(()) => app.begin_operation(id, label),
1096 Err(mpsc::error::TrySendError::Full(_)) => {
1097 app.report_error("Browser command queue is full.");
1098 }
1099 Err(mpsc::error::TrySendError::Closed(_)) => {
1100 app.browser_state = BrowserState::Unavailable;
1101 app.report_error("Browser worker is unavailable.");
1102 }
1103 }
1104}
1105
1106fn draw(frame: &mut Frame, app: &App) {
1107 let chunks = Layout::default()
1108 .direction(Direction::Vertical)
1109 .constraints([
1110 Constraint::Length(3),
1111 Constraint::Min(10),
1112 Constraint::Length(3),
1113 Constraint::Length(1),
1114 ])
1115 .split(frame.area());
1116
1117 let header = Layout::default()
1118 .direction(Direction::Horizontal)
1119 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1120 .split(chunks[0]);
1121
1122 let title = Paragraph::new(app.title.as_str())
1123 .block(Block::default().borders(Borders::ALL).title("Glass"))
1124 .style(
1125 Style::default()
1126 .fg(Color::Cyan)
1127 .add_modifier(Modifier::BOLD),
1128 );
1129 frame.render_widget(title, header[0]);
1130
1131 let url = Paragraph::new(app.url.as_str())
1132 .block(Block::default().borders(Borders::ALL).title("URL"))
1133 .style(Style::default().fg(Color::Yellow));
1134 frame.render_widget(url, header[1]);
1135
1136 let content = Layout::default()
1137 .direction(Direction::Horizontal)
1138 .constraints([Constraint::Percentage(42), Constraint::Percentage(58)])
1139 .split(chunks[1]);
1140
1141 let activity = app
1142 .activity
1143 .iter()
1144 .map(|entry| ListItem::new(Line::from(entry.as_str())))
1145 .collect::<Vec<_>>();
1146 frame.render_widget(
1147 List::new(activity)
1148 .block(Block::default().borders(Borders::ALL).title("Activity"))
1149 .style(Style::default().fg(Color::Green)),
1150 content[0],
1151 );
1152
1153 frame.render_widget(
1154 Paragraph::new(app.page_content.as_str())
1155 .block(
1156 Block::default()
1157 .borders(Borders::ALL)
1158 .title("Structured Observation"),
1159 )
1160 .scroll((app.page_scroll, 0))
1161 .wrap(Wrap { trim: true }),
1162 content[1],
1163 );
1164
1165 frame.render_widget(
1166 Paragraph::new(app.input.as_str())
1167 .block(Block::default().borders(Borders::ALL).title("Command")),
1168 chunks[2],
1169 );
1170 let escape_hint = if app.is_busy() {
1171 "Esc: cancel"
1172 } else {
1173 "Esc: close error/quit"
1174 };
1175 frame.render_widget(
1176 Paragraph::new(format!(
1177 " {} PgUp/PgDn: observation q/Ctrl-C: quit Enter: execute {escape_hint} {}",
1178 app.status,
1179 app.input.chars().count()
1180 ))
1181 .style(Style::default().fg(Color::DarkGray)),
1182 chunks[3],
1183 );
1184
1185 if let Some(error) = &app.error_msg {
1186 let popup = centered_popup(frame.area());
1187 frame.render_widget(Clear, popup);
1188 frame.render_widget(
1189 Paragraph::new(error.as_str())
1190 .block(Block::default().borders(Borders::ALL).title("Error"))
1191 .style(Style::default().fg(Color::Red))
1192 .wrap(Wrap { trim: true }),
1193 popup,
1194 );
1195 }
1196}
1197
1198fn centered_popup(area: Rect) -> Rect {
1199 let width = (area.width.saturating_mul(2) / 3).max(1).min(area.width);
1200 let height = 5.min(area.height);
1201 Rect {
1202 x: area.width.saturating_sub(width) / 2,
1203 y: area.height.saturating_sub(height) / 2,
1204 width,
1205 height,
1206 }
1207}
1208
1209struct TerminalGuard {
1210 active: bool,
1211}
1212
1213impl TerminalGuard {
1214 fn enter() -> io::Result<Self> {
1215 enable_raw_mode()?;
1216 let mut stdout = io::stdout();
1217 if let Err(error) = execute!(stdout, EnterAlternateScreen, Hide) {
1218 let _ = disable_raw_mode();
1219 return Err(error);
1220 }
1221 Ok(Self { active: true })
1222 }
1223
1224 fn restore(&mut self) -> io::Result<()> {
1225 if !self.active {
1226 return Ok(());
1227 }
1228 self.active = false;
1229 let raw_result = disable_raw_mode();
1230 let mut stdout = io::stdout();
1231 let screen_result = execute!(stdout, LeaveAlternateScreen, Show);
1232 match (raw_result, screen_result) {
1233 (Err(error), _) | (_, Err(error)) => Err(error),
1234 (Ok(()), Ok(())) => Ok(()),
1235 }
1236 }
1237}
1238
1239impl Drop for TerminalGuard {
1240 fn drop(&mut self) {
1241 let _ = self.restore();
1242 }
1243}
1244
1245pub async fn run_tui(cli: &Cli) -> BrowserResult<()> {
1246 let mut terminal_guard = TerminalGuard::enter()?;
1247 let stdout = io::stdout();
1248 let backend = CrosstermBackend::new(stdout);
1249 let mut terminal = Terminal::new(backend)?;
1250
1251 let (input_tx, mut input_events) = mpsc::channel(INPUT_CHANNEL_CAPACITY);
1252 let (browser_commands, browser_command_rx) = mpsc::channel(BROWSER_COMMAND_CHANNEL_CAPACITY);
1253 let (browser_event_tx, mut browser_events) = mpsc::channel(BROWSER_EVENT_CHANNEL_CAPACITY);
1254 let (shutdown_tx, shutdown_rx) = watch::channel(false);
1255
1256 let options = SessionOptions {
1257 port: cli.port,
1258 chrome_path: cli.chrome_path.clone(),
1259 profile: cli.profile.clone(),
1260 incognito: cli.incognito,
1261 attach: cli.attach,
1262 target_id: cli.target_id.clone(),
1263 frame_id: cli.frame_id.clone(),
1264 headed: cli.headed,
1265 interaction_mode: cli.interaction,
1266 audit: cli.audit,
1267 policy: None,
1268 };
1269 let policy = crate::cli::runner::policy_from_cli(cli)?;
1270 let local = LocalSet::new();
1271 let browser_worker = local.spawn_local(browser_worker(
1272 options,
1273 policy.clone(),
1274 browser_command_rx,
1275 browser_event_tx,
1276 shutdown_rx,
1277 ));
1278 let mut input_worker = InputWorker::spawn(input_tx);
1279 let mut app = App::new();
1280
1281 let loop_result = local
1282 .run_until(run_tui_loop(
1283 &mut terminal,
1284 &mut app,
1285 &browser_commands,
1286 &mut input_events,
1287 &mut browser_events,
1288 &policy,
1289 ))
1290 .await;
1291
1292 drop(input_events);
1293 drop(browser_events);
1294 let _ = shutdown_tx.send(true);
1295 let _ = browser_commands.try_send(BrowserCommand::Shutdown);
1296 drop(browser_commands);
1297 let input_result = input_worker.stop();
1298 let cursor_result = terminal.show_cursor();
1299 let terminal_result = terminal_guard.restore();
1300 let worker_result = local.run_until(finish_browser_worker(browser_worker)).await;
1301
1302 loop_result?;
1303 input_result?;
1304 cursor_result?;
1305 terminal_result?;
1306 worker_result
1307}
1308
1309async fn run_tui_loop(
1310 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
1311 app: &mut App,
1312 commands: &mpsc::Sender<BrowserCommand>,
1313 input_events: &mut mpsc::Receiver<InputEvent>,
1314 browser_events: &mut mpsc::Receiver<BrowserEvent>,
1315 policy: &BrowserPolicy,
1316) -> BrowserResult<()> {
1317 let mut redraw = true;
1318 let mut browser_events_open = true;
1319 let mut busy_tick = time::interval(BUSY_TICK);
1320 busy_tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
1321
1322 while !app.should_quit {
1323 if redraw {
1324 terminal.draw(|frame| draw(frame, app))?;
1325 }
1326
1327 redraw = tokio::select! {
1328 biased;
1329 input = input_events.recv() => match input {
1330 Some(InputEvent::Key(key)) => {
1331 let intent = app.reduce_key(key);
1332 dispatch_ui_intent(app, commands, policy, intent);
1333 true
1334 }
1335 Some(InputEvent::Redraw) => true,
1336 Some(InputEvent::Error(error)) => return Err(error.into()),
1337 None => return Err("TUI input worker stopped".into()),
1338 },
1339 event = browser_events.recv(), if browser_events_open => match event {
1340 Some(event) => {
1341 if let Some(operation) = app.apply_browser_event(event)? {
1342 queue_browser_operation(app, commands, operation);
1343 }
1344 true
1345 }
1346 None => {
1347 browser_events_open = false;
1348 app.busy = None;
1349 if !matches!(app.browser_state, BrowserState::Unavailable | BrowserState::Stopped) {
1350 app.browser_state = BrowserState::Unavailable;
1351 app.set_status("Browser worker unavailable");
1352 app.report_error("Browser worker stopped unexpectedly.");
1353 }
1354 true
1355 }
1356 },
1357 _ = busy_tick.tick(), if app.is_busy() => {
1358 app.tick_busy();
1359 true
1360 },
1361 };
1362 }
1363 Ok(())
1364}
1365
1366async fn finish_browser_worker(mut worker: JoinHandle<()>) -> BrowserResult<()> {
1367 match time::timeout(WORKER_SHUTDOWN_TIMEOUT, &mut worker).await {
1368 Ok(Ok(())) => Ok(()),
1369 Ok(Err(error)) => Err(format!("browser worker failed: {error}").into()),
1370 Err(_) => {
1371 worker.abort();
1372 let _ = worker.await;
1373 Err("timed out waiting for browser worker shutdown".into())
1374 }
1375 }
1376}
1377
1378fn bounded_text(value: &str, max_bytes: usize) -> String {
1379 if value.len() <= max_bytes {
1380 return value.to_string();
1381 }
1382 if max_bytes == 0 {
1383 return String::new();
1384 }
1385
1386 const MARKER: &str = "\n[truncated]";
1387 if max_bytes <= MARKER.len() {
1388 let mut end = max_bytes;
1389 while end > 0 && !value.is_char_boundary(end) {
1390 end -= 1;
1391 }
1392 return value[..end].to_string();
1393 }
1394
1395 let mut end = max_bytes - MARKER.len();
1396 while end > 0 && !value.is_char_boundary(end) {
1397 end -= 1;
1398 }
1399 let mut truncated = value[..end].to_string();
1400 truncated.push_str(MARKER);
1401 truncated
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406 use super::*;
1407
1408 fn key(code: KeyCode) -> KeyEvent {
1409 KeyEvent::new(code, KeyModifiers::NONE)
1410 }
1411
1412 #[test]
1413 fn command_parser_preserves_browser_actions_and_rejects_bad_scroll() {
1414 assert!(matches!(
1415 parse_command("double click r7:b42"),
1416 Ok(ParsedCommand::Browser(BrowserOperation::DoubleClick(target))) if target == "r7:b42"
1417 ));
1418 assert!(matches!(
1419 parse_command("scroll -4 120"),
1420 Ok(ParsedCommand::Browser(BrowserOperation::Scroll { dx, dy })) if dx == -4.0 && dy == 120.0
1421 ));
1422 assert!(parse_command("scroll nope").is_err());
1423 assert!(matches!(
1424 parse_command("profiles"),
1425 Ok(ParsedCommand::Local(LocalCommand::Profiles))
1426 ));
1427 }
1428
1429 #[test]
1430 fn reducer_edits_unicode_and_requests_matching_cancellation() {
1431 let mut app = App::new();
1432 assert_eq!(app.reduce_key(key(KeyCode::Char('日'))), UiIntent::None);
1433 assert_eq!(app.reduce_key(key(KeyCode::Char('本'))), UiIntent::None);
1434 app.reduce_key(key(KeyCode::Backspace));
1435 assert_eq!(app.input, "日");
1436
1437 app.browser_state = BrowserState::Ready;
1438 app.begin_operation(4, "Observe");
1439 assert_eq!(app.reduce_key(key(KeyCode::Esc)), UiIntent::Cancel(4));
1440 assert!(app.busy.as_ref().unwrap().cancelling);
1441 assert_eq!(app.reduce_key(key(KeyCode::Esc)), UiIntent::None);
1442 }
1443
1444 #[test]
1445 fn app_bounds_retained_page_state() {
1446 let mut app = App::new();
1447 app.set_page_content("界".repeat(TUI_PAGE_MAX_BYTES));
1448
1449 assert!(app.page_content.len() <= TUI_PAGE_MAX_BYTES);
1450 assert!(app.page_content.contains("[truncated]"));
1451 }
1452
1453 #[tokio::test]
1454 async fn matching_cancel_interrupts_an_active_operation() {
1455 let (command_tx, mut command_rx) = mpsc::channel(2);
1456 let (_shutdown_tx, mut shutdown_rx) = watch::channel(false);
1457 let (event_tx, _event_rx) = mpsc::channel(2);
1458 command_tx
1459 .send(BrowserCommand::Cancel { id: 9 })
1460 .await
1461 .unwrap();
1462
1463 let result = await_active_operation(
1464 std::future::pending::<BrowserResult<Box<OperationResult>>>(),
1465 9,
1466 &mut command_rx,
1467 &mut shutdown_rx,
1468 &event_tx,
1469 )
1470 .await;
1471
1472 assert!(matches!(result, ActiveOperationState::Cancelled));
1473 }
1474
1475 #[tokio::test]
1476 async fn delayed_worker_event_does_not_block_input_reducer() {
1477 let (command_tx, mut command_rx) = mpsc::channel(2);
1478 let (event_tx, mut event_rx) = mpsc::channel(4);
1479 let worker = tokio::spawn(async move {
1480 let Some(BrowserCommand::Execute { id, .. }) = command_rx.recv().await else {
1481 return;
1482 };
1483 event_tx
1484 .send(BrowserEvent::OperationStarted {
1485 id,
1486 label: "Observe".to_string(),
1487 })
1488 .await
1489 .unwrap();
1490 time::sleep(Duration::from_millis(100)).await;
1491 event_tx
1492 .send(BrowserEvent::OperationFinished {
1493 id,
1494 result: Box::new(OperationResult {
1495 activity: "Observation refreshed.".to_string(),
1496 update: None,
1497 }),
1498 })
1499 .await
1500 .unwrap();
1501 });
1502
1503 let mut app = App::new();
1504 app.browser_state = BrowserState::Ready;
1505 app.begin_operation(1, "Observe");
1506 command_tx
1507 .send(BrowserCommand::Execute {
1508 id: 1,
1509 operation: BrowserOperation::Observe { fresh: false },
1510 })
1511 .await
1512 .unwrap();
1513 app.apply_browser_event(event_rx.recv().await.unwrap())
1514 .unwrap();
1515
1516 assert_eq!(app.reduce_key(key(KeyCode::Char('x'))), UiIntent::None);
1517 assert_eq!(app.input, "x");
1518 assert!(
1519 time::timeout(Duration::from_millis(20), event_rx.recv())
1520 .await
1521 .is_err()
1522 );
1523
1524 app.apply_browser_event(event_rx.recv().await.unwrap())
1525 .unwrap();
1526 assert!(!app.is_busy());
1527 worker.await.unwrap();
1528 }
1529}