lattice_ui_tui/app/messages.rs
1//! `*messages*` buffer -- the emacs `*Messages*` analogue.
2//!
3//! The renderer-agnostic message stream types
4//! ([`lattice_runtime::MessageRecord`],
5//! [`lattice_runtime::MessagesRing`], the
6//! [`lattice_runtime::MessagePushed`] typed event) live in
7//! `lattice-runtime` so any host-side crate (this TUI
8//! renderer, a future GPU renderer, plugins via the WIT
9//! bridge, telemetry hooks) can subscribe without taking a
10//! dep on `lattice-ui-tui`.
11//!
12//! ## Buffer model (Slice E)
13//!
14//! `*messages*` is a subsystem-owned Document buffer in the
15//! unified registry — the same shape as `*lsp*` and friends.
16//! Created eagerly at App boot so `:b *messages*` works the
17//! moment the editor starts; appended to via the per-tick drain
18//! that consumes [`lattice_runtime::MessagePushed`] events.
19//!
20//! - `name = Some("*messages*")` — surfaces in modeline, `:ls`,
21//! `:b` picker.
22//! - `flags.listed = false` — `:bn`/`:bp` skip it (vim's
23//! `nobuflisted` semantic) but `:b *messages*` and the picker
24//! still reach it.
25//! - Major: `text-mode` + minor `read-only-mode` so user
26//! keystrokes can't mutate the buffer; subsystem writes go
27//! through `apply_edit_batch_blocking` which bypasses the
28//! modal dispatcher's read-only gate.
29//! - `:w <path>` saves a snapshot to disk; the streaming buffer
30//! continues to receive records.
31
32use crate::app::App;
33// 5.8.AA.f: `format_message_record` lives host-side now and the
34// drain body moved with it. Kept as `#[allow]` so the existing
35// `#[cfg(test)]` consumers below still resolve.
36#[allow(unused_imports)]
37use lattice_host::messages::format_message_record;
38
39/// Re-export of the canonical
40/// [`lattice_host::messages::MESSAGES_BUFFER_NAME`] (Phase
41/// 5.7.B.9 migration). Kept at this path so existing
42/// `crate::app::messages::MESSAGES_BUFFER_NAME` references in
43/// downstream call sites + tests resolve unchanged. The
44/// `#[allow]` silences rustc's false-positive "unused import"
45/// when the only in-file consumers are inside `#[cfg(test)]`.
46#[allow(unused_imports)]
47pub use lattice_host::messages::MESSAGES_BUFFER_NAME;
48
49impl App {
50 /// `:messages` -- activate the `*messages*` Document buffer.
51 /// 5.8.AF.3: body migrated to
52 /// [`lattice_host::editor::Editor::do_open_messages`]. Wrapper
53 /// fans renderer signals through `handle_renderer_signal`.
54 pub fn do_open_messages(&mut self) {
55 let signals = self.mutate_editor_with(move |e| e.do_open_messages());
56 for sig in signals {
57 self.handle_renderer_signal(sig);
58 }
59 }
60
61 /// `:ai-log [provider]` -- open the per-session AI log buffer
62 /// (AI-1b, T12b). Thin peer forwarder: the count logic (0 →
63 /// info hint, 1 → open, >1 → picker) + the buffer open live
64 /// host-side in [`lattice_host::editor::Editor::do_open_ai_log`];
65 /// the GPUI peer reaches the same method. Mirrors
66 /// `do_open_lsp_log`.
67 pub fn do_open_ai_log(&mut self, provider: Option<&str>) {
68 let provider = provider.map(|s| s.to_string());
69 self.mutate_editor(move |e| e.do_open_ai_log(provider.as_deref()));
70 }
71
72 /// `Effect::OpenSyntheticBuffer` -- open a named synthetic buffer under a
73 /// major mode. Thin peer forwarder to
74 /// [`lattice_host::editor::Editor::open_synthetic_buffer`]; the GPUI peer
75 /// reaches the same method.
76 pub fn open_synthetic_buffer(&mut self, name: &str, mode_id: &str) {
77 let name = name.to_string();
78 let mode_id = mode_id.to_string();
79 self.mutate_editor(move |e| e.open_synthetic_buffer(&name, &mode_id));
80 }
81
82 /// OC.7a: the seeded peer forwarder. Same thin shape as its neighbour —
83 /// the body lives on `Editor` so both renderers reach one implementation.
84 pub fn open_synthetic_buffer_seeded(
85 &mut self,
86 name: &str,
87 mode_id: &str,
88 content: Option<&str>,
89 cursor: Option<lattice_protocol::position::Position>,
90 activate_minor: Option<&str>,
91 ) {
92 let name = name.to_string();
93 let mode_id = mode_id.to_string();
94 let content = content.map(str::to_string);
95 let activate_minor = activate_minor.map(str::to_string);
96 self.mutate_editor(move |e| {
97 e.open_synthetic_buffer_seeded(
98 &name,
99 &mode_id,
100 content.as_deref(),
101 cursor,
102 activate_minor.as_deref(),
103 )
104 });
105 }
106
107 /// Thin wrapper around
108 /// [`lattice_host::editor::Editor::ensure_messages_buffer`]
109 /// (Phase 5.7.B.9 migration). The find-or-create body +
110 /// backlog seeding live host-side; the GPUI peer reaches
111 /// the same logic via `editor.ensure_messages_buffer()`.
112 pub(crate) fn ensure_messages_buffer(&mut self) -> crate::buffers::BufferId {
113 self.mutate_editor_with(move |e| e.ensure_messages_buffer())
114 }
115
116 /// Drain queued [`lattice_runtime::MessagePushed`] events;
117 /// append each formatted record to the `*messages*` buffer.
118 /// Called from the runtime's per-frame tick. Coalescing
119 /// matters during bursts (LSP `$/progress` floods, batch
120 /// echo): all records in a tick land in one
121 /// `apply_edit_batch` so the actor sees one edit per drain
122 /// regardless of event rate.
123 pub fn drain_message_events(&mut self) {
124 // 5.8.AA.f: migrated to host.
125 self.mutate_editor_with(move |e| e.drain_message_events());
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use crate::app::EchoLevel;
133 use lattice_grammar::EchoLevel as WireLevel;
134 use lattice_runtime::MessageRecord;
135
136 /// End-to-end check that `set_message` streams every echo
137 /// over the event bus. Mirrors how a plugin (or any other
138 /// renderer-agnostic subscriber) taps in: subscribe a
139 /// channel via `event_bus.subscribe_typed::<MessagePushed>`,
140 /// drive `set_message`, assert every record landed on
141 /// the subscriber's queue in arrival order with wire-
142 /// typed levels. The renderer's own buffer view is just
143 /// another peer subscriber.
144 #[test]
145 fn message_pushed_event_streams_to_external_subscriber() {
146 use crate::app::test_helpers::app_with;
147 use lattice_runtime::MessagePushed;
148 let mut app = app_with("hi\n", 5);
149 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<MessagePushed>();
150 app.editor.event_bus.subscribe_typed(tx);
151 app.set_message(EchoLevel::Info, "first");
152 app.set_message(EchoLevel::Warn, "second");
153 app.set_message(EchoLevel::Error, "third");
154 let mut got: Vec<(WireLevel, String)> = Vec::new();
155 while let Ok(ev) = rx.try_recv() {
156 got.push((ev.record.level, ev.record.text));
157 }
158 assert_eq!(
159 got,
160 vec![
161 (WireLevel::Info, "first".to_string()),
162 (WireLevel::Warn, "second".to_string()),
163 (WireLevel::Error, "third".to_string()),
164 ],
165 );
166 }
167
168 /// Live-tail: when the `*messages*` buffer exists, the drain
169 /// appends each event's record. Three echoes + one drain
170 /// produce a buffer body containing every record in order.
171 #[test]
172 fn drain_message_events_appends_to_messages_buffer() {
173 use crate::app::test_helpers::app_with;
174 let mut app = app_with("hi\n", 5);
175 let buffer_id = app.ensure_messages_buffer();
176 app.set_message(EchoLevel::Info, "alpha");
177 app.set_message(EchoLevel::Warn, "bravo");
178 app.drain_message_events();
179 let body = app
180 .editor
181 .buffers
182 .document_handle(buffer_id)
183 .expect("*messages* is a Document")
184 .text();
185 assert!(body.contains("alpha"), "got `{body}`");
186 assert!(body.contains("bravo"), "got `{body}`");
187 }
188
189 #[test]
190 fn messages_buffer_appears_in_buffer_registry_with_synthetic_name() {
191 // `*messages*` lives in the unified registry tagged as
192 // `BufferData::Messages` (the storage variant matching
193 // `BufferKind::Messages`); `:b *messages*` reaches it
194 // via name lookup; `:bn`/`:bp` skip it (unlisted).
195 use crate::app::test_helpers::app_with;
196 let mut app = app_with("hi\n", 5);
197 let id = app.ensure_messages_buffer();
198 assert_eq!(app.editor.buffers.by_name(MESSAGES_BUFFER_NAME), Some(id));
199 let (is_messages, name, listed) = app
200 .editor
201 .buffers
202 .with_entry(id, |entry| {
203 (
204 matches!(entry.data, crate::buffer_registry::BufferData::Messages(_)),
205 entry.name.clone(),
206 entry.flags.listed,
207 )
208 })
209 .expect("*messages* entry");
210 assert!(
211 is_messages,
212 "*messages* must be stored as BufferData::Messages"
213 );
214 assert_eq!(name.as_deref(), Some(MESSAGES_BUFFER_NAME));
215 // Unlisted: `:bn`/`:bp` skip it.
216 assert!(!listed);
217 }
218
219 #[test]
220 fn do_open_messages_activates_messages_buffer() {
221 // Slice E: `:messages` switches the active pane to the
222 // `*messages*` buffer. Modeline shows the synthetic name.
223 use crate::app::test_helpers::app_with;
224 let mut app = app_with("hi\n", 5);
225 let initial = app.active_pane_buffer_id();
226 app.do_open_messages();
227 let msgs_id = app
228 .editor
229 .buffers
230 .by_name(MESSAGES_BUFFER_NAME)
231 .expect("*messages* present");
232 assert_ne!(initial, msgs_id);
233 assert_eq!(app.active_pane_buffer_id(), msgs_id);
234 let pane = *app.editor.pane_tree.active();
235 let label = app.pane_status_label(&pane);
236 assert!(
237 label.contains("*messages*"),
238 "modeline must surface *messages*; got `{label}`"
239 );
240 }
241
242 #[test]
243 fn messages_buffer_is_read_only() {
244 use crate::app::test_helpers::app_with;
245 let mut app = app_with("hi\n", 5);
246 let id = app.ensure_messages_buffer();
247 let ro = *app.resolved_option::<lattice_config::ReadOnly>(id);
248 assert!(
249 ro,
250 "*messages* buffer must resolve ReadOnly = true via read-only-mode"
251 );
252 }
253
254 #[test]
255 fn format_message_record_collapses_internal_newlines() {
256 let r = MessageRecord {
257 timestamp: std::time::SystemTime::UNIX_EPOCH,
258 level: WireLevel::Error,
259 text: "line one\nline two".into(),
260 };
261 let s = format_message_record(&r);
262 assert!(!s.contains('\n'), "newlines collapsed");
263 assert!(s.contains("line one line two"));
264 }
265}