diff --git a/apps/lumbridge/src/attention.rs b/apps/lumbridge/src/attention.rs new file mode 100644 index 0000000..65ecf24 --- /dev/null +++ b/apps/lumbridge/src/attention.rs @@ -0,0 +1,241 @@ +//! What is waiting on a human, and how we know. +//! +//! The usage footer already refuses to show a number without saying where it +//! came from. Attention is the same kind of claim and gets the same treatment: +//! "three panes need you" is worth acting on if three panes actually asked, and +//! worth nothing if it was guessed from window titles. +//! +//! So every signal carries a [`AttentionSource`], and the rule — enforced by a +//! test, not by care — is that a guessed signal may draw an indicator and sort a +//! row, but **may not increment the count**. A number in the sidebar is a +//! promise that something is really waiting. + +/// Why a pane wants a human. +/// +/// The two ACP variants are deliberately unreachable: a harness asking for +/// permission or asking a question arrives over the ACP client, which does not +/// exist yet. They are declared rather than faked, so the day the client lands +/// there is a defined shape for it to fill, and so nothing invents them from a +/// pattern in the output in the meantime. +#[allow(dead_code, reason = "the ACP variants land with the ACP client")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AttentionKind { + /// The runtime broke. Nothing will happen until someone looks. + Faulted, + /// The process ended. Whether that is finished or failed is in the code. + Finished { exit_code: u32 }, + /// A harness asked for permission to act. Needs the ACP client. + PermissionRequested, + /// A harness asked the user a question. Needs the ACP client. + QuestionAsked, +} + +impl AttentionKind { + /// The words the sidebar shows. Present tense, specific, no jargon. + pub(crate) fn label(&self) -> String { + match self { + Self::Faulted => "Runtime faulted".to_owned(), + Self::Finished { exit_code: 0 } => "Finished".to_owned(), + Self::Finished { exit_code } => format!("Exited with code {exit_code}"), + Self::PermissionRequested => "Waiting for permission".to_owned(), + Self::QuestionAsked => "Asked you a question".to_owned(), + } + } +} + +/// How we came to believe it. +/// +/// Ordered from most to least trustworthy, which is also the order the sidebar +/// sorts by when two panes want attention at once. +#[allow( + dead_code, + reason = "ACP and heuristic sources land with the clients that produce them" +)] +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum AttentionSource { + /// The harness asked, over ACP. It said so itself. + AcpRequest, + /// The harness reported it through a documented status surface. + HarnessReported, + /// Lumbridge observed it in a process it supervises: an exit, a fault. + RuntimeObserved, + /// Inferred from a window title or output pattern. A guess. + TitleHeuristic, + /// Test or demonstration data. + Fixture, +} + +impl AttentionSource { + /// Whether a signal from this source may be counted. + /// + /// A guess can be shown and can order a list. It cannot make the sidebar + /// assert that something is waiting, because a wrong count is worse than no + /// count: it teaches people to ignore the number. + pub(crate) const fn is_countable(self) -> bool { + matches!( + self, + Self::AcpRequest | Self::HarnessReported | Self::RuntimeObserved + ) + } + + pub(crate) const fn label(self) -> &'static str { + match self { + Self::AcpRequest => "asked", + Self::HarnessReported => "reported", + Self::RuntimeObserved => "observed", + Self::TitleHeuristic => "inferred", + Self::Fixture => "fixture", + } + } +} + +/// One pane wanting a human. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AttentionSignal { + pub(crate) pane: Pane, + pub(crate) kind: AttentionKind, + pub(crate) source: AttentionSource, + pub(crate) observed_at_ms: u64, +} + +impl AttentionSignal { + pub(crate) const fn is_countable(&self) -> bool { + self.source.is_countable() + } +} + +/// Every pane wanting a human, and how many of those are certain. +#[derive(Debug, Default)] +pub(crate) struct Attention { + signals: Vec>, +} + +impl Attention { + pub(crate) const fn new() -> Self { + Self { + signals: Vec::new(), + } + } + + /// Records a signal, replacing any earlier one for the same pane. + /// + /// One pane wants attention for one reason at a time; keeping a history + /// would mean the count grew every time a pane was restarted. + pub(crate) fn observe(&mut self, signal: AttentionSignal) { + self.signals.retain(|existing| existing.pane != signal.pane); + self.signals.push(signal); + // Most trustworthy first, then oldest first: the thing that has been + // waiting longest is the thing to deal with. + self.signals + .sort_by_key(|signal| (signal.source, signal.observed_at_ms)); + } + + /// Forgets a pane's signal, because it is no longer waiting. + pub(crate) fn clear(&mut self, pane: Pane) { + self.signals.retain(|signal| signal.pane != pane); + } + + pub(crate) fn signals(&self) -> &[AttentionSignal] { + &self.signals + } + + /// How many panes are *certainly* waiting. + /// + /// This is the number the sidebar shows. Guessed signals are excluded by + /// construction rather than by remembering to filter them. + pub(crate) fn countable(&self) -> usize { + self.signals + .iter() + .filter(|signal| signal.is_countable()) + .count() + } +} + +#[cfg(test)] +mod tests { + use super::{Attention, AttentionKind, AttentionSignal, AttentionSource}; + + fn signal(pane: u8, source: AttentionSource, at: u64) -> AttentionSignal { + AttentionSignal { + pane, + kind: AttentionKind::Faulted, + source, + observed_at_ms: at, + } + } + + /// The rule this module exists to enforce. + #[test] + fn a_guess_may_be_shown_but_never_counted() { + let mut attention = Attention::new(); + attention.observe(signal(1, AttentionSource::TitleHeuristic, 10)); + attention.observe(signal(2, AttentionSource::Fixture, 20)); + assert_eq!(attention.signals().len(), 2, "both are shown"); + assert_eq!( + attention.countable(), + 0, + "neither may claim that something is waiting" + ); + + attention.observe(signal(3, AttentionSource::RuntimeObserved, 30)); + assert_eq!(attention.countable(), 1); + } + + #[test] + fn every_source_declares_whether_it_counts() { + for source in [ + AttentionSource::AcpRequest, + AttentionSource::HarnessReported, + AttentionSource::RuntimeObserved, + ] { + assert!(source.is_countable(), "{source:?} is a report, not a guess"); + } + for source in [AttentionSource::TitleHeuristic, AttentionSource::Fixture] { + assert!(!source.is_countable(), "{source:?} is a guess"); + } + } + + #[test] + fn one_pane_waits_for_one_reason() { + let mut attention = Attention::new(); + attention.observe(signal(1, AttentionSource::RuntimeObserved, 10)); + attention.observe(signal(1, AttentionSource::RuntimeObserved, 20)); + assert_eq!( + attention.signals().len(), + 1, + "restarting must not accumulate" + ); + assert_eq!(attention.signals()[0].observed_at_ms, 20); + } + + #[test] + fn the_most_trustworthy_and_longest_waiting_sorts_first() { + let mut attention = Attention::new(); + attention.observe(signal(1, AttentionSource::TitleHeuristic, 10)); + attention.observe(signal(2, AttentionSource::RuntimeObserved, 30)); + attention.observe(signal(3, AttentionSource::AcpRequest, 40)); + attention.observe(signal(4, AttentionSource::AcpRequest, 20)); + let order: Vec = attention.signals().iter().map(|s| s.pane).collect(); + assert_eq!(order, vec![4, 3, 2, 1]); + } + + #[test] + fn clearing_a_pane_removes_only_that_pane() { + let mut attention = Attention::new(); + attention.observe(signal(1, AttentionSource::RuntimeObserved, 10)); + attention.observe(signal(2, AttentionSource::RuntimeObserved, 20)); + attention.clear(1); + assert_eq!(attention.signals().len(), 1); + assert_eq!(attention.signals()[0].pane, 2); + } + + #[test] + fn kinds_read_as_sentences_and_name_a_failing_code() { + assert_eq!(AttentionKind::Finished { exit_code: 0 }.label(), "Finished"); + assert_eq!( + AttentionKind::Finished { exit_code: 130 }.label(), + "Exited with code 130" + ); + assert_eq!(AttentionKind::Faulted.label(), "Runtime faulted"); + } +} diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 69663d5..c3a28bb 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,3 +1,4 @@ +mod attention; mod keymap; mod panel_registry; mod theme; @@ -27,6 +28,8 @@ use lumbridge_ui_fixture::{ SurfaceKind, }; +use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource}; +use lumbridge_harness::MonotonicWallClock; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; use theme::{ActiveTheme, ThemeColors}; use usage_feed::{UsageFeed, UsageSegment}; @@ -95,6 +98,11 @@ struct LumbridgeShell { pending_terminate: Option, /// Why the last keystroke went nowhere, if it did. input_gap: Option, + /// What is waiting on a human, and how we know. + attention: Attention, + /// Stamps attention signals. Monotonic, so a signal cannot appear to have + /// arrived before one recorded earlier. + clock: MonotonicWallClock, root_focus: FocusHandle, } @@ -712,6 +720,8 @@ impl LumbridgeShell { add_panel_chooser_open: false, pending_terminate: None, input_gap: None, + attention: Attention::new(), + clock: MonotonicWallClock::start(), root_focus, } } @@ -827,6 +837,17 @@ impl LumbridgeShell { status.code )); } + // Observed in a process Lumbridge supervises, so it + // counts. The exit code is carried rather than being + // formatted into a sentence and thrown away. + self.attention.observe(AttentionSignal { + pane, + kind: AttentionKind::Finished { + exit_code: status.code, + }, + source: AttentionSource::RuntimeObserved, + observed_at_ms: self.clock.now_ms(), + }); changed = true; break; } @@ -837,6 +858,12 @@ impl LumbridgeShell { terminal.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}")); } + self.attention.observe(AttentionSignal { + pane, + kind: AttentionKind::Faulted, + source: AttentionSource::RuntimeObserved, + observed_at_ms: self.clock.now_ms(), + }); changed = true; break; } @@ -1050,6 +1077,7 @@ impl LumbridgeShell { terminal.status = LiveRuntimeStatus::Fault(error.to_string()); } self.live_terminals.insert(pane, terminal); + self.attention.clear(pane); self.pending_terminate = None; window.focus(&self.root_focus); cx.notify(); @@ -2380,12 +2408,25 @@ impl LumbridgeShell { /// The count is derived rather than declared: a sidebar that claims one /// pane needs input while no pane is blocked is the same class of untruth /// as an invented usage number. - fn attention_panels(&self) -> Vec { - self.panels - .attached_ids() - .into_iter() - .map(|id| self.panel_view(id)) - .filter(PanelView::needs_input) + /// Panes waiting on a human, most trustworthy first. + /// + /// Read from observed signals rather than from the fixture's + /// `needs_input` flag, which no live pane ever set — the count was frozen + /// at whatever the demo data said. + fn attention_rows(&self) -> Vec { + self.attention + .signals() + .iter() + .filter_map(|signal| { + let panel = self.panels.panel(signal.pane)?; + Some(AttentionRow { + id: signal.pane, + title: panel.title.clone(), + reason: signal.kind.label(), + source: signal.source.label(), + countable: signal.is_countable(), + }) + }) .collect() } @@ -2650,11 +2691,12 @@ impl LumbridgeShell { )] fn render_sidebar( &self, - attention_panels: &[PanelView], + attention_rows: &[AttentionRow], detached_entries: &[(PanelId, String)], cx: &mut Context, ) -> gpui::AnyElement { let theme = self.theme.colors; + let waiting = self.attention.countable(); div() .flex() .flex_col() @@ -2669,14 +2711,17 @@ impl LumbridgeShell { .pt_4() .pb_2() .text_xs() - .text_color(if attention_panels.is_empty() { + .text_color(if waiting == 0 { theme.muted } else { theme.attention }) - .child(format!("ATTENTION · {}", attention_panels.len())), + // The count is only what is certainly waiting. A guessed + // signal still gets a row below, but it may not make the + // sidebar assert that something needs you. + .child(format!("ATTENTION · {waiting}")), ) - .when(attention_panels.is_empty(), |view| { + .when(attention_rows.is_empty(), |view| { view.child( div() .mx_2() @@ -2688,8 +2733,8 @@ impl LumbridgeShell { .child("No pane is waiting on you"), ) }) - .children(attention_panels.iter().map(|pane| { - let id = pane.id; + .children(attention_rows.iter().map(|row| { + let id = row.id; div() .id(("attention-panel", id.get())) .cursor_pointer() @@ -2700,20 +2745,26 @@ impl LumbridgeShell { .rounded(px(5.0)) .bg(theme.surface_active) .border_1() - .border_color(theme.attention) + // A guess is outlined quietly; a report is outlined in the + // attention colour. The row looks like what it is. + .border_color(if row.countable { + theme.attention + } else { + theme.border + }) .hover(|view| view.bg(theme.surface_raised)) .child( div() .text_sm() .text_color(theme.text) - .child(pane.title.clone()), + .child(row.title.clone()), ) .child( div() .mt_1() .text_xs() .text_color(theme.muted) - .child(pane.target.clone()), + .child(format!("{} · {}", row.reason, row.source)), ) .on_click(cx.listener(move |shell, _, window, cx| { shell.select_pane(id, window, cx); @@ -2950,6 +3001,17 @@ struct RootParts { running_runtime_count: usize, } +/// One pane waiting on a human, as the sidebar shows it. +struct AttentionRow { + id: PanelId, + title: String, + /// What it wants, in words. + reason: String, + /// How we know. Shown so a guess never reads like a report. + source: &'static str, + countable: bool, +} + /// One host or endpoint row in the sidebar's runtime list. struct RuntimeRow { label: &'static str, @@ -3015,8 +3077,8 @@ impl Render for LumbridgeShell { "{running_runtime_count}/{} LIVE PTYS", self.live_terminals.len() ); - let attention_panels = self.attention_panels(); - let sidebar = self.render_sidebar(&attention_panels, &detached_entries, cx); + let attention_rows = self.attention_rows(); + let sidebar = self.render_sidebar(&attention_rows, &detached_entries, cx); let selected_position = attached_panes .iter() diff --git a/docs/decisions/0019-attention-carries-provenance.md b/docs/decisions/0019-attention-carries-provenance.md new file mode 100644 index 0000000..0826049 --- /dev/null +++ b/docs/decisions/0019-attention-carries-provenance.md @@ -0,0 +1,61 @@ +# 0019: Attention carries a source, and a guess may not be counted + +Status: accepted; runtime-observed signals are live. + +The sidebar said `ATTENTION · 1` from the first commit. It was reading a +fixture's `needs_input` flag that no live pane ever set, so the number was +frozen at whatever the demo data said and had no relationship to anything +running. + +Replacing it raises the question the usage ledger already answered once: what +is a number in this interface allowed to claim? + +## The rule + +Every attention signal carries an `AttentionSource`, and a source that is a +guess **may draw a row and may sort it, but may not increment the count**. + +- `AcpRequest` — the harness asked, over ACP. It said so itself. +- `HarnessReported` — a documented status surface reported it. +- `RuntimeObserved` — Lumbridge saw it in a process it supervises: an exit, a + fault. The only source that produces signals today. +- `TitleHeuristic` — inferred from a window title or an output pattern. +- `Fixture` — test or demonstration data. + +The first three count. The last two do not, and `is_countable` is a method on +the source rather than a filter at the call site, so a new signal cannot be +counted by forgetting to exclude it. + +This is decision 0012's provenance rule applied to a different claim. The +reasoning is the same: a wrong count is worse than no count, because it teaches +people to ignore the number, and the number is the whole point of the section. + +## What is deliberately unreachable + +`PermissionRequested` and `QuestionAsked` are declared and never constructed. +Those arrive over the ACP client, which does not exist yet. + +They are written down rather than left out so there is a defined shape for the +client to fill — and, more importantly, so nobody is tempted to approximate them +with a `TitleHeuristic` that watches for a question mark in the output. A pane +that *looks* like it is asking something is not a pane that asked. That signal +would be a guess, would therefore not be countable, and the honest version of it +is simply not to ship it. + +## What a signal replaces + +One pane wants attention for one reason at a time: a new signal replaces the +pane's previous one rather than accumulating, or the count would grow every time +a pane was restarted. Signals sort by source first and age second, so the most +trustworthy and longest-waiting thing is at the top. + +Restarting or terminating a pane clears its signal, because the thing that was +waiting is no longer waiting. + +## The exit code survives + +`RuntimeEvent::Exited` carries a `u32` code that used to be formatted straight +into a sentence and discarded. `AttentionKind::Finished { exit_code }` keeps it, +which is what lets the row say "Exited with code 42" rather than "needs +attention", and what will later let a finished-successfully signal be told apart +from a failure without re-parsing English.