Make the attention count real, and make it carry a source

The sidebar had said ATTENTION · 1 since 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.

Attention is now built from observed signals, and every signal carries an
AttentionSource. The rule, enforced by is_countable on the source rather than by
a filter at the call site: a guess may draw a row and sort it, but may not
increment the count. That is decision 0012's provenance rule applied to a
different claim, for the same reason — a wrong count teaches people to ignore
the number, and the number is the whole point of the section.

RuntimeObserved is the only source that produces signals today, from process
exits and runtime faults. The two ACP kinds are declared and never constructed,
so there is a shape for the ACP client to fill and nobody is tempted to
approximate "asked you a question" by watching output for a question mark.

RuntimeEvent::Exited carries a u32 code that was being formatted into a sentence
and discarded; AttentionKind::Finished keeps it, which is why a row can say
"Exited with code 42" instead of "needs attention".

Verified live: exiting a pane with code 42 produces ATTENTION · 1, a row reading
"Exited with code 42 · observed", a dimmed pane banner offering restart, and a
live-PTY count that drops from 3/3 to 2/3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Metal Agent
2026-08-31 23:38:55 -07:00
co-authored by Claude Opus 5
parent d76da3babb
commit 3a8a100ea5
3 changed files with 381 additions and 17 deletions
+79 -17
View File
@@ -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<PanelId>,
/// Why the last keystroke went nowhere, if it did.
input_gap: Option<String>,
/// What is waiting on a human, and how we know.
attention: Attention<PanelId>,
/// 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<PanelView> {
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<AttentionRow> {
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<Self>,
) -> 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()