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