Skip to main content

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}