lattice_runtime/messages.rs
1//! `*messages*` transcript types -- one historical record per
2//! editor echo, a bounded ring of them, and a typed
3//! `MessagePushed` event published whenever a new record
4//! lands.
5//!
6//! Lives in `lattice-runtime` so any host-side crate that
7//! cares about message stream events (TUI, GPU renderer,
8//! plugin host, telemetry) can subscribe without taking a
9//! dependency on the renderer crate. The wire-typed
10//! `lattice_grammar::EchoLevel` rides through unchanged so
11//! grammar / runtime / plugin code all read the same severity
12//! enum without round-tripping through a display-typed clone.
13//!
14//! Plugins via the WASM Component Model subscribe through the
15//! plugin host's WIT bridge -- the host wires
16//! `event_bus.subscribe_typed::<MessagePushed>(...)` and
17//! marshals each record into the plugin's WIT struct. The
18//! Rust type's location here is purely for in-process Rust
19//! consumers; plugins never import this module directly.
20
21use lattice_grammar::EchoLevel;
22
23/// One historical minibuffer echo. The renderer's
24/// `*messages*` buffer renders these in chronological order;
25/// each `set_message` call appends one record. Cheap to clone
26/// (bounded text) -- producers fan the same record out to
27/// every subscriber on the typed event bus.
28#[derive(Debug, Clone)]
29pub struct MessageRecord {
30 pub timestamp: std::time::SystemTime,
31 pub level: EchoLevel,
32 pub text: String,
33}
34
35/// Bounded chronological ring of every echo the editor has
36/// emitted. Push on every `set_message`; snapshot on demand
37/// for `:messages` open and live refresh. Capacity is fixed
38/// at construction; once full, the oldest entry drops to make
39/// room for the newest.
40#[derive(Debug, Clone)]
41pub struct MessagesRing {
42 records: std::collections::VecDeque<MessageRecord>,
43 capacity: usize,
44}
45
46impl MessagesRing {
47 /// Construct an empty ring with the given capacity. `0`
48 /// is normalised via `max(1)` so push never silently
49 /// drops every record.
50 pub fn with_capacity(capacity: usize) -> Self {
51 Self {
52 records: std::collections::VecDeque::new(),
53 capacity: capacity.max(1),
54 }
55 }
56
57 /// Append a record. Evicts the oldest entry when at
58 /// capacity. O(1) amortized; the ring is bounded so a
59 /// flood of messages stays at a fixed memory ceiling.
60 pub fn push(&mut self, record: MessageRecord) {
61 if self.records.len() == self.capacity {
62 self.records.pop_front();
63 }
64 self.records.push_back(record);
65 }
66
67 /// Borrow the records in chronological order (oldest
68 /// first). Renderers walk this for the transcript view.
69 pub fn records(&self) -> &std::collections::VecDeque<MessageRecord> {
70 &self.records
71 }
72
73 pub fn len(&self) -> usize {
74 self.records.len()
75 }
76
77 pub fn is_empty(&self) -> bool {
78 self.records.is_empty()
79 }
80
81 pub fn capacity(&self) -> usize {
82 self.capacity
83 }
84}
85
86impl Default for MessagesRing {
87 fn default() -> Self {
88 // 2000 records is plenty for typical sessions: at an
89 // average ~200 bytes per record the worst-case
90 // footprint is ~400 KB. Users can review a morning's
91 // worth of LSP progress + diagnostics without
92 // hitting the wrap.
93 Self::with_capacity(2000)
94 }
95}
96
97/// Typed event published on the editor's event bus whenever
98/// `set_message` runs. Carries the appended record (cloned
99/// from the ring). Subscribers see every echo in arrival
100/// order; the renderer's `*messages*` buffer live tail, the
101/// plugin host's WIT bridge, future telemetry hooks, etc.
102/// are all peer subscribers with no privileged path.
103#[derive(Debug, Clone)]
104pub struct MessagePushed {
105 pub record: MessageRecord,
106}
107
108lattice_protocol::register_event!(
109 MessagePushed,
110 "ui.message-pushed",
111 "Fired when the editor pushes a new minibuffer echo / notification \
112 onto the messages ring. Subscribers receive one event per \
113 `set_message` call regardless of renderer; the bounded ring on \
114 `App.messages` is the source of truth for replay.",
115 "lattice-runtime",
116);
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn ring_push_appends_in_order_and_caps_capacity() {
124 let mut ring = MessagesRing::with_capacity(3);
125 for i in 0..5 {
126 ring.push(MessageRecord {
127 timestamp: std::time::SystemTime::now(),
128 level: EchoLevel::Info,
129 text: format!("m{i}"),
130 });
131 }
132 assert_eq!(ring.len(), 3);
133 let texts: Vec<&str> = ring.records().iter().map(|r| r.text.as_str()).collect();
134 assert_eq!(texts, vec!["m2", "m3", "m4"]);
135 }
136
137 #[test]
138 fn ring_default_has_nonzero_capacity() {
139 let r = MessagesRing::default();
140 assert!(r.capacity() >= 2000);
141 assert!(r.is_empty());
142 }
143
144 #[test]
145 fn ring_with_zero_capacity_normalises_to_one() {
146 let mut r = MessagesRing::with_capacity(0);
147 assert_eq!(r.capacity(), 1);
148 r.push(MessageRecord {
149 timestamp: std::time::SystemTime::now(),
150 level: EchoLevel::Warn,
151 text: "x".into(),
152 });
153 assert_eq!(r.len(), 1);
154 }
155}