Skip to main content

netget/tui/
chat.rs

1//! In-memory chat/log history for the dashboard.
2//!
3//! The full-screen alternate screen has no terminal scrollback, so the
4//! dashboard keeps its own ring buffer of entries. Lines arrive on the same
5//! unbounded status channel the rolling TUI drains, with the same `[LEVEL]`
6//! prefix protocol. Unlike the rolling TUI (which drops filtered lines
7//! forever), filtering happens at render time — raising the log level
8//! retroactively reveals recently buffered DEBUG/TRACE lines.
9
10use std::collections::VecDeque;
11
12use crate::ui::app::LogLevel;
13
14/// Ring capacity: enough scrollback for a long session without unbounded
15/// growth under a log flood.
16pub const CHAT_CAPACITY: usize = 5_000;
17
18/// Per-frame drain cap: an extreme flood delays rendering of the tail rather
19/// than freezing the UI (the channel is unbounded by design).
20pub const DRAIN_CAP_PER_FRAME: usize = 500;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum EntryKind {
24    /// Text the user typed.
25    User,
26    /// Streamed model reasoning (`[REASONING]` lines).
27    Reasoning,
28    /// A `[LEVEL]`-prefixed log line.
29    Log(LogLevel),
30    /// Unprefixed output (command results, welcome text, model notes).
31    System,
32}
33
34#[derive(Debug, Clone)]
35pub struct ChatEntry {
36    pub seq: u64,
37    pub kind: EntryKind,
38    pub text: String,
39}
40
41/// Scroll position: following the tail, or anchored at an entry.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ScrollPos {
44    Follow,
45    /// Anchored: `lines_up` wrapped display lines above the tail.
46    Up(usize),
47}
48
49pub struct ChatState {
50    pub entries: VecDeque<ChatEntry>,
51    pub scroll: ScrollPos,
52    /// Entries that arrived while scrolled up (drives the "[N new] ↓" pill).
53    pub unseen: usize,
54    next_seq: u64,
55}
56
57impl ChatState {
58    pub fn new() -> Self {
59        Self {
60            entries: VecDeque::new(),
61            scroll: ScrollPos::Follow,
62            unseen: 0,
63            next_seq: 1,
64        }
65    }
66
67    pub fn push(&mut self, kind: EntryKind, text: impl Into<String>) {
68        let text = text.into();
69        self.entries.push_back(ChatEntry {
70            seq: self.next_seq,
71            kind,
72            text,
73        });
74        self.next_seq += 1;
75        if self.entries.len() > CHAT_CAPACITY {
76            self.entries.pop_front();
77        }
78        if self.scroll != ScrollPos::Follow {
79            self.unseen += 1;
80        }
81    }
82
83    /// Parse one status-channel line into an entry, mirroring the rolling
84    /// TUI's prefix protocol. Returns false for the `__UPDATE_UI__` sentinel
85    /// (and other `__` control messages), which are not chat content.
86    ///
87    /// The dashboard routes lines between the feed and the chat with
88    /// [`route_status_line`]; this pushes everything into the chat, for
89    /// callers that have only one pane.
90    pub fn push_status_line(&mut self, line: &str) -> bool {
91        match route_status_line(line) {
92            Routed::Control => false,
93            Routed::Activity(level, text) => {
94                self.push(EntryKind::Log(level), text);
95                true
96            }
97            Routed::Chat(kind, text) => {
98                self.push(kind, text);
99                true
100            }
101        }
102    }
103
104    /// Whether an entry passes the current log-level filter.
105    pub fn passes_filter(entry: &ChatEntry, level: LogLevel) -> bool {
106        match entry.kind {
107            EntryKind::Log(entry_level) => entry_level <= level,
108            _ => true,
109        }
110    }
111
112    pub fn scroll_to_follow(&mut self) {
113        self.scroll = ScrollPos::Follow;
114        self.unseen = 0;
115    }
116
117    pub fn scroll_up(&mut self, lines: usize) {
118        self.scroll = match self.scroll {
119            ScrollPos::Follow => ScrollPos::Up(lines),
120            ScrollPos::Up(n) => ScrollPos::Up(n.saturating_add(lines)),
121        };
122    }
123
124    pub fn scroll_down(&mut self, lines: usize) {
125        self.scroll = match self.scroll {
126            ScrollPos::Follow => ScrollPos::Follow,
127            ScrollPos::Up(n) => {
128                let n = n.saturating_sub(lines);
129                if n == 0 {
130                    self.unseen = 0;
131                    ScrollPos::Follow
132                } else {
133                    ScrollPos::Up(n)
134                }
135            }
136        };
137    }
138}
139
140/// Where a status-channel line belongs.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum Routed {
143    /// `__UPDATE_UI__` and other `__` control messages: not content.
144    Control,
145    /// A `[LEVEL]` log line: the machine talking. Goes to the activity feed
146    /// (an `[ERROR]` is mirrored into the chat too, since the person who
147    /// caused it is looking there).
148    Activity(LogLevel, String),
149    /// The conversation: what the model reasons and says, and command output.
150    Chat(EntryKind, String),
151}
152
153/// Decide which pane a status line belongs to.
154pub fn route_status_line(line: &str) -> Routed {
155    if line.starts_with("__") {
156        return Routed::Control;
157    }
158    if let Some(rest) = line.strip_prefix("[ERROR] ") {
159        Routed::Activity(LogLevel::Error, rest.to_string())
160    } else if let Some(rest) = line.strip_prefix("[WARN] ") {
161        Routed::Activity(LogLevel::Warn, rest.to_string())
162    } else if let Some(rest) = line.strip_prefix("[INFO] ") {
163        Routed::Activity(LogLevel::Info, rest.to_string())
164    } else if let Some(rest) = line.strip_prefix("[DEBUG] ") {
165        Routed::Activity(LogLevel::Debug, rest.to_string())
166    } else if let Some(rest) = line.strip_prefix("[TRACE] ") {
167        Routed::Activity(LogLevel::Trace, rest.to_string())
168    } else if let Some(rest) = line.strip_prefix("[REASONING] ") {
169        Routed::Chat(EntryKind::Reasoning, rest.to_string())
170    } else if line.starts_with("[SERVER] ") || line.starts_with("[CLIENT] ") {
171        // Instance lifecycle chatter ("Starting server #1 (TCP) on …"): the
172        // machine talking, at INFO. The feed already derives the structured
173        // version from the snapshot; the line keeps the wording for the log.
174        Routed::Activity(LogLevel::Info, line.to_string())
175    } else {
176        Routed::Chat(EntryKind::System, line.to_string())
177    }
178}