Skip to main content

zero_tui/app/
log.rs

1//! Conversation log — append-only list of entries the operator has
2//! seen. The widget renders the tail that fits the visible area.
3
4use chrono::{DateTime, Utc};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum EntryKind {
8    /// Text the operator typed.
9    Prompt,
10    /// Reply from the engine or an ambient system message.
11    System,
12    /// A slash-command output line.
13    Command,
14    /// A warning the operator should not miss (muted amber).
15    Warn,
16    /// A blocked / denied action (alert red).
17    Alert,
18}
19
20#[derive(Debug, Clone)]
21pub struct LogEntry {
22    pub at: DateTime<Utc>,
23    pub kind: EntryKind,
24    pub text: String,
25    /// Opaque correlation id for streaming appends. `Some(id)`
26    /// marks the entry as "the engine may send more text for
27    /// this id"; [`ConversationLog::extend_last`] targets it.
28    /// `None` is a finalized entry — the normal case.
29    pub stream_id: Option<String>,
30}
31
32impl LogEntry {
33    #[must_use]
34    pub fn new(kind: EntryKind, text: impl Into<String>) -> Self {
35        Self {
36            at: Utc::now(),
37            kind,
38            text: text.into(),
39            stream_id: None,
40        }
41    }
42
43    #[must_use]
44    pub fn at(mut self, ts: DateTime<Utc>) -> Self {
45        self.at = ts;
46        self
47    }
48
49    /// Attach a streaming id so subsequent
50    /// [`ConversationLog::extend_last`] calls can append to this
51    /// row rather than emitting a new one. The engine-side
52    /// streaming source lands with M2 (HTTP SSE/WS partial
53    /// messages); this accessor is scaffolding so the TUI does
54    /// not need another schema change when it arrives.
55    #[must_use]
56    pub fn streaming(mut self, id: impl Into<String>) -> Self {
57        self.stream_id = Some(id.into());
58        self
59    }
60}
61
62#[derive(Debug, Default)]
63pub struct ConversationLog {
64    entries: Vec<LogEntry>,
65    cap: usize,
66}
67
68impl ConversationLog {
69    /// `cap = 0` means unbounded. Production uses ~2048; tests use
70    /// smaller values to exercise wrapping cheaply.
71    #[must_use]
72    pub const fn with_capacity(cap: usize) -> Self {
73        Self {
74            entries: Vec::new(),
75            cap,
76        }
77    }
78
79    pub fn push(&mut self, entry: LogEntry) {
80        self.entries.push(entry);
81        if self.cap > 0 && self.entries.len() > self.cap {
82            // Drop the oldest; a ring buffer would be faster but
83            // this path runs at typing speed, not market speed.
84            let drop_n = self.entries.len() - self.cap;
85            self.entries.drain(..drop_n);
86        }
87    }
88
89    #[must_use]
90    pub fn tail(&self, n: usize) -> &[LogEntry] {
91        let len = self.entries.len();
92        let start = len.saturating_sub(n);
93        &self.entries[start..]
94    }
95
96    /// Full entry slice, ordered oldest → newest. The conversation
97    /// pane uses this for scrollback indexing; [`tail`] stays for
98    /// callers that only care about the most recent N rows.
99    #[must_use]
100    pub fn entries(&self) -> &[LogEntry] {
101        &self.entries
102    }
103
104    /// Mutable access to the newest entry, if any. Used by the
105    /// streaming hook to append text onto a partial assistant
106    /// reply without allocating a fresh row.
107    pub fn last_mut(&mut self) -> Option<&mut LogEntry> {
108        self.entries.last_mut()
109    }
110
111    /// Streaming append — extend the text of the entry whose
112    /// [`LogEntry::stream_id`] matches `id`. The engine-side
113    /// streaming transport (SSE / WS partial messages) is not yet
114    /// wired in M1; this method exists so the UI is ready to
115    /// consume it in M2 without a further widget rewrite.
116    ///
117    /// Returns `true` when the append landed, `false` if no
118    /// matching entry was found (the caller should create a new
119    /// entry in that case).
120    pub fn extend_last(&mut self, id: &str, more: &str) -> bool {
121        let Some(last) = self.entries.last_mut() else {
122            return false;
123        };
124        if last.stream_id.as_deref() != Some(id) {
125            return false;
126        }
127        last.text.push_str(more);
128        true
129    }
130
131    #[must_use]
132    pub fn len(&self) -> usize {
133        self.entries.len()
134    }
135
136    #[must_use]
137    pub fn is_empty(&self) -> bool {
138        self.entries.is_empty()
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::{ConversationLog, EntryKind, LogEntry};
145
146    #[test]
147    fn tail_returns_last_n() {
148        let mut log = ConversationLog::with_capacity(0);
149        for i in 0..5 {
150            log.push(LogEntry::new(EntryKind::System, format!("{i}")));
151        }
152        let tail: Vec<&str> = log.tail(3).iter().map(|e| e.text.as_str()).collect();
153        assert_eq!(tail, ["2", "3", "4"]);
154    }
155
156    #[test]
157    fn capacity_trims_oldest() {
158        let mut log = ConversationLog::with_capacity(3);
159        for i in 0..5 {
160            log.push(LogEntry::new(EntryKind::System, format!("{i}")));
161        }
162        assert_eq!(log.len(), 3);
163        let tail: Vec<&str> = log.tail(10).iter().map(|e| e.text.as_str()).collect();
164        assert_eq!(tail, ["2", "3", "4"]);
165    }
166
167    #[test]
168    fn extend_last_appends_to_streaming_entry() {
169        let mut log = ConversationLog::with_capacity(0);
170        log.push(LogEntry::new(EntryKind::System, "hello").streaming("sid-1"));
171        assert!(log.extend_last("sid-1", ", world"));
172        assert_eq!(log.tail(1)[0].text, "hello, world");
173    }
174
175    #[test]
176    fn extend_last_refuses_mismatched_id() {
177        let mut log = ConversationLog::with_capacity(0);
178        log.push(LogEntry::new(EntryKind::System, "partial").streaming("sid-1"));
179        assert!(
180            !log.extend_last("sid-2", " nope"),
181            "mismatched id must not silently append"
182        );
183        assert_eq!(log.tail(1)[0].text, "partial");
184    }
185
186    #[test]
187    fn extend_last_refuses_finalized_entry() {
188        let mut log = ConversationLog::with_capacity(0);
189        log.push(LogEntry::new(EntryKind::System, "final"));
190        assert!(
191            !log.extend_last("any", " more"),
192            "finalized entry (no stream_id) must reject appends"
193        );
194    }
195}