//! What a pane *is*, what it can be *looked at* through, and what its process //! is *doing* — the three descriptions the renderer draws from. //! //! A pane is the durable unit of work in Lumbridge; a surface is a view over //! that work. Keeping the two apart is a product boundary, not a rendering //! convenience: switching a tab never launches a process, never moves the pane, //! and never changes which agent owns the session. It changes what is drawn and //! nothing else, which is why [`SurfaceTab`] is a plain enum with no handle to //! anything and no way to reach a runtime. //! //! The other rule this file encodes is that an unsupported surface is shown as //! unavailable rather than simulated. Every tab exists on every pane, and the //! five a pane cannot serve say plainly why — "No isolated web engine is wired //! yet", not an empty panel that reads as a bug, and never a plausible-looking //! mock that a user would take for a working feature. Those sentences are the //! product's honesty about how much of itself is built, so they are asserted //! here rather than left to whoever last edited the renderer. //! //! [`LiveRuntimeStatus`] is the same idea applied to a process: four states, a //! badge for the header and a detail line, and no attempt to summarise a fault //! into something reassuring. None of it needs a renderer, so none of it lives //! next to one. use lumbridge_ui_fixture::{OutputSource, PaneStatus, SurfaceKind}; use crate::panel_registry::PanelId; /// The surfaces a pane can be viewed through. /// /// A pane is the durable unit of work and its surface is a view over that work. /// Switching one never launches a process, moves the pane, or changes which /// agent owns the session — it only changes what is drawn. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum SurfaceTab { Terminal, Browser, Tools, Context, Goal, Review, } impl SurfaceTab { pub(crate) const ALL: [Self; 6] = [ Self::Terminal, Self::Browser, Self::Tools, Self::Context, Self::Goal, Self::Review, ]; pub(crate) const fn label(self) -> &'static str { match self { Self::Terminal => "TERMINAL", Self::Browser => "BROWSER", Self::Tools => "TOOLS", Self::Context => "CONTEXT", Self::Goal => "GOAL", Self::Review => "REVIEW", } } pub(crate) const fn ordinal(self) -> u64 { match self { Self::Terminal => 0, Self::Browser => 1, Self::Tools => 2, Self::Context => 3, Self::Goal => 4, Self::Review => 5, } } /// The one surface a pane of this kind actually provides. pub(crate) const fn native_for(kind: SurfaceKind) -> Self { match kind { SurfaceKind::Terminal => Self::Terminal, SurfaceKind::Browser => Self::Browser, SurfaceKind::Markdown => Self::Context, SurfaceKind::Review => Self::Review, } } /// Why this pane cannot show this surface. Stated plainly, because the /// product boundary says an unsupported surface is shown as unavailable /// rather than simulated. pub(crate) const fn unavailable_reason(self) -> &'static str { match self { Self::Terminal => "Only a terminal pane owns a live shell.", Self::Browser => "No isolated web engine is wired yet.", Self::Tools => "Tool calls arrive with the ACP client.", Self::Context => "Context projection is not built yet.", Self::Goal => "Goal tracking is not built yet.", Self::Review => "Review is not wired to Git yet.", } } } #[derive(Clone)] pub(crate) struct PanelView { pub(crate) id: PanelId, pub(crate) kind: SurfaceKind, pub(crate) title: String, pub(crate) badge: String, pub(crate) target: String, pub(crate) status: PaneStatus, pub(crate) lines: Vec, pub(crate) output_source: OutputSource, } impl PanelView { pub(crate) const fn needs_input(&self) -> bool { matches!(self.status, PaneStatus::NeedsInput) } } #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum LiveRuntimeStatus { Starting, Running { session_id: u64, process_id: Option, }, Exited(String), Fault(String), } impl LiveRuntimeStatus { pub(crate) fn badge(&self) -> &'static str { match self { Self::Starting => "PTY STARTING", Self::Running { .. } => "LIVE PTY", Self::Exited(_) => "PTY EXITED", Self::Fault(_) => "PTY FAULT", } } pub(crate) fn detail(&self) -> String { match self { Self::Starting => "local · runtime actor starting".to_owned(), Self::Running { session_id, process_id, } => match process_id { Some(process_id) => { format!("local · runtime session {session_id} · pid {process_id}") } None => format!("local · runtime session {session_id}"), }, Self::Exited(status) => format!("local · {status}"), Self::Fault(message) => format!("local · {message}"), } } pub(crate) const fn is_terminal(&self) -> bool { matches!(self, Self::Exited(_) | Self::Fault(_)) } } #[cfg(test)] mod tests { use super::{LiveRuntimeStatus, SurfaceTab}; use lumbridge_ui_fixture::SurfaceKind; #[test] fn every_pane_kind_opens_on_the_surface_it_actually_provides() { // The failure this prevents is a pane whose default tab is one it // cannot serve: the workspace would open on an "unavailable" panel and // read as broken before the user has touched anything. for kind in [ SurfaceKind::Terminal, SurfaceKind::Browser, SurfaceKind::Markdown, SurfaceKind::Review, ] { let native = SurfaceTab::native_for(kind); assert!( SurfaceTab::ALL.contains(&native), "{kind:?} opens on a tab the tab bar does not draw" ); } // Markdown is the one that is not its own name: there is no MARKDOWN // tab, and a document pane is read through CONTEXT. assert_eq!( SurfaceTab::native_for(SurfaceKind::Markdown), SurfaceTab::Context ); assert_eq!( SurfaceTab::native_for(SurfaceKind::Terminal), SurfaceTab::Terminal ); assert_eq!( SurfaceTab::native_for(SurfaceKind::Browser), SurfaceTab::Browser ); assert_eq!( SurfaceTab::native_for(SurfaceKind::Review), SurfaceTab::Review ); } #[test] fn the_tab_bar_lists_every_surface_exactly_once_and_in_a_stable_order() { // ordinal() is mixed into the element ID a pane's tabs are built with, // so a duplicate would collide two tabs into one element, and a gap // would only show up as a tab that cannot be clicked. for (index, tab) in SurfaceTab::ALL.into_iter().enumerate() { assert_eq!( u64::try_from(index).expect("six tabs fit in a u64"), tab.ordinal(), "{tab:?} is at position {index} but reports ordinal {}", tab.ordinal() ); } } #[test] fn no_surface_is_unavailable_without_saying_why() { // An unsupported surface is shown as unavailable rather than simulated, // and "unavailable" with no sentence after it is indistinguishable from // a panel that failed to render. for tab in SurfaceTab::ALL { let reason = tab.unavailable_reason(); assert!( !reason.is_empty() && reason.ends_with('.'), "{tab:?} owes the user a complete sentence, not {reason:?}" ); assert!( !tab.label().is_empty(), "{tab:?} has no label for the tab bar" ); } // Distinct sentences, not one generic line repeated six times: the // reason a browser pane is empty and the reason review is empty are // different facts about how much of the product exists. let mut reasons = SurfaceTab::ALL.map(SurfaceTab::unavailable_reason).to_vec(); reasons.sort_unstable(); let before = reasons.len(); reasons.dedup(); assert_eq!(before, reasons.len(), "two surfaces share an excuse"); } #[test] fn a_running_pty_names_its_pid_when_the_runtime_reported_one() { // The pid is what a user needs to go and look at the process from // outside Lumbridge, and it is genuinely absent on a session the actor // has accepted but not yet spawned. Printing "pid None" or a zero would // send someone hunting for a process that does not exist. assert_eq!( LiveRuntimeStatus::Running { session_id: 7, process_id: Some(4321), } .detail(), "local · runtime session 7 · pid 4321" ); assert_eq!( LiveRuntimeStatus::Running { session_id: 7, process_id: None, } .detail(), "local · runtime session 7" ); } #[test] fn every_status_reports_where_it_is_running_and_a_fault_keeps_its_message() { assert_eq!( LiveRuntimeStatus::Starting.detail(), "local · runtime actor starting" ); // The message is carried through rather than summarised: "PTY FAULT" is // the badge, and the badge alone never tells anyone what to fix. assert_eq!( LiveRuntimeStatus::Fault("spawn failed: no such file".to_owned()).detail(), "local · spawn failed: no such file" ); assert_eq!( LiveRuntimeStatus::Exited("exited with status 130".to_owned()).detail(), "local · exited with status 130" ); } #[test] fn only_the_states_a_process_cannot_leave_are_terminal() { // is_terminal decides whether the event drain keeps polling a pane. // Calling Starting terminal would abandon a pane before it ever ran; // calling Exited non-terminal would poll a dead actor forever. assert!(LiveRuntimeStatus::Exited(String::new()).is_terminal()); assert!(LiveRuntimeStatus::Fault(String::new()).is_terminal()); assert!(!LiveRuntimeStatus::Starting.is_terminal()); assert!( !LiveRuntimeStatus::Running { session_id: 1, process_id: None, } .is_terminal() ); } #[test] fn each_status_has_its_own_badge() { let badges = [ LiveRuntimeStatus::Starting.badge(), LiveRuntimeStatus::Running { session_id: 1, process_id: None, } .badge(), LiveRuntimeStatus::Exited(String::new()).badge(), LiveRuntimeStatus::Fault(String::new()).badge(), ]; let mut sorted = badges.to_vec(); sorted.sort_unstable(); sorted.dedup(); assert_eq!( sorted.len(), badges.len(), "two runtime states would look identical in the pane header" ); } }