Skip to main content

glass/tui/
app.rs

1//! Interactive TUI application state and rendering.
2//!
3//! Implements the Ratatui-based terminal interface with split-pane layout,
4//! command input, observation display, and keyboard-driven interaction.
5
6use 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}