Skip to main content

netget/tui/
app.rs

1//! Dashboard application state: where the cursor is, what the last poll saw,
2//! and which modals are open.
3
4use std::collections::HashMap;
5
6use crate::cli::input_state::InputState;
7use crate::state::{ClientId, ServerId};
8use crate::tui::activity::{Activity, ActivityFeed, ActivityKind, Tracker};
9use crate::tui::cards::{CardState, Row};
10use crate::tui::chat::EntryKind;
11use crate::tui::hit::HitRegistry;
12use crate::tui::metrics::Throughput;
13use crate::tui::modal::Modal;
14use crate::tui::projection::{ClientRow, RailSnapshot, ServerRow};
15use crate::tui::theme::Styles;
16use crate::ui::App;
17
18/// Stable identity of an instance across re-polls.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum UiKey {
21    Server(ServerId),
22    Client(ClientId),
23}
24
25impl UiKey {
26    pub fn section(&self) -> Section {
27        match self {
28            UiKey::Server(_) => Section::Servers,
29            UiKey::Client(_) => Section::Clients,
30        }
31    }
32
33    /// `server #1` / `client #4`, for stream lines and titles.
34    pub fn describe(&self) -> String {
35        match self {
36            UiKey::Server(id) => format!("server #{}", id.as_u32()),
37            UiKey::Client(id) => format!("client #{}", id.as_u32()),
38        }
39    }
40
41    pub fn raw_id(&self) -> u32 {
42        match self {
43            UiKey::Server(id) => id.as_u32(),
44            UiKey::Client(id) => id.as_u32(),
45        }
46    }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum Section {
51    Servers,
52    Clients,
53}
54
55/// Where keyboard input goes when no modal is open.
56///
57/// Two columns. `Cards` is the whole management column — every server and
58/// client, their buttons and sections — walked with the arrows. `ChatInput`
59/// is the box at the bottom of the stream; `Stream` is that column scrolled
60/// away from its tail (PageUp, the wheel), where the arrows move through
61/// the lines. Tab hops between the columns.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Focus {
64    Cards,
65    Stream,
66    ChatInput,
67}
68
69/// The management column's cursor and fold state.
70#[derive(Debug, Default)]
71pub struct CardsUi {
72    /// The row the cursor is on (index into `cards::rows`).
73    pub row: usize,
74    /// The position on that row: the label (0, where the label acts) then
75    /// each button.
76    pub col: usize,
77    /// First visible row.
78    pub scroll: usize,
79    /// Inner width at the last paint, so the keymap builds the same rows
80    /// the renderer did (button grids depend on it).
81    pub width: usize,
82    pub state: CardState,
83    /// Throughput history per instance, sampled once a second.
84    pub metrics: HashMap<UiKey, Throughput>,
85}
86
87/// Status-bar model.
88#[derive(Debug, Clone, Default)]
89pub struct StatusModel {
90    pub model: String,
91    pub backend: String,
92    pub web_search: String,
93    pub handler_mode: String,
94    pub scripting: String,
95    pub notice: Option<String>,
96    pub input_tokens: u64,
97    pub output_tokens: u64,
98    pub llm_calls: u64,
99    pub active_conversations: usize,
100}
101
102/// A borrowed view of either kind of instance row.
103#[derive(Debug, Clone, Copy)]
104pub enum InstanceRef<'a> {
105    Server(&'a ServerRow),
106    Client(&'a ClientRow),
107}
108
109impl<'a> InstanceRef<'a> {
110    pub fn key(&self) -> UiKey {
111        match self {
112            InstanceRef::Server(s) => UiKey::Server(s.id),
113            InstanceRef::Client(c) => UiKey::Client(c.id),
114        }
115    }
116
117    pub fn protocol(&self) -> &str {
118        match self {
119            InstanceRef::Server(s) => &s.protocol,
120            InstanceRef::Client(c) => &c.protocol,
121        }
122    }
123
124    pub fn routing(&self) -> Option<&crate::scripting::EventHandlerConfig> {
125        match self {
126            InstanceRef::Server(s) => s.routing.as_ref(),
127            InstanceRef::Client(c) => c.routing.as_ref(),
128        }
129    }
130
131    pub fn intercepts(&self) -> &[crate::state::intercepts::InterceptView] {
132        match self {
133            InstanceRef::Server(s) => &s.intercepts,
134            InstanceRef::Client(c) => &c.intercepts,
135        }
136    }
137
138    pub fn requests(&self) -> &[crate::state::app_state::AccessLogEntry] {
139        match self {
140            InstanceRef::Server(s) => &s.requests,
141            InstanceRef::Client(c) => &c.requests,
142        }
143    }
144
145    /// `http#1`-style tag used by the stream.
146    pub fn tag(&self) -> String {
147        format!("{}#{}", self.protocol().to_lowercase(), self.key().raw_id())
148    }
149}
150
151pub struct DashboardApp {
152    /// Legacy display state reused for command history, log level and caps.
153    pub core: App,
154    /// The one timeline: machine events and the conversation.
155    pub activity: ActivityFeed,
156    pub tracker: Tracker,
157    pub input: InputState,
158    pub focus: Focus,
159    pub cards: CardsUi,
160    pub snapshot: RailSnapshot,
161    pub modals: Vec<Modal>,
162    pub hits: HitRegistry,
163    pub status: StatusModel,
164    pub styles: Styles,
165    pub dirty: bool,
166    pub animation_tick: usize,
167    pub mouse_capture: bool,
168    pub should_quit: bool,
169    /// Clone of the status channel, so modal actions (create/update/send) can
170    /// stream their progress into the same pane.
171    pub status_tx: tokio::sync::mpsc::UnboundedSender<String>,
172    /// Results of spawned actions (see `crate::tui::uimsg`). Network work must
173    /// never be awaited on the event loop.
174    pub ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
175    /// The configured LLM client, needed when creating/updating clients.
176    pub llm_client: crate::llm::OllamaClient,
177}
178
179impl DashboardApp {
180    pub fn new(
181        core: App,
182        styles: Styles,
183        status_tx: tokio::sync::mpsc::UnboundedSender<String>,
184        ui_tx: tokio::sync::mpsc::UnboundedSender<crate::tui::uimsg::UiMsg>,
185        llm_client: crate::llm::OllamaClient,
186    ) -> Self {
187        Self {
188            status_tx,
189            ui_tx,
190            llm_client,
191            core,
192            activity: ActivityFeed::new(),
193            tracker: Tracker::default(),
194            input: InputState::new(),
195            focus: Focus::ChatInput,
196            cards: CardsUi {
197                width: 60,
198                ..Default::default()
199            },
200            snapshot: RailSnapshot::default(),
201            modals: Vec::new(),
202            hits: HitRegistry::default(),
203            status: StatusModel::default(),
204            styles,
205            dirty: true,
206            animation_tick: 0,
207            mouse_capture: true,
208            should_quit: false,
209        }
210    }
211
212    pub fn modal(&self) -> Option<&Modal> {
213        self.modals.last()
214    }
215
216    pub fn modal_mut(&mut self) -> Option<&mut Modal> {
217        self.modals.last_mut()
218    }
219
220    /// The management column's rows as the renderer last laid them out.
221    pub fn rows(&self) -> Vec<Row> {
222        crate::tui::cards::rows(
223            &self.snapshot,
224            &self.cards.state,
225            &self.cards.metrics,
226            self.cards.width,
227        )
228    }
229
230    /// Called on the 100ms UI tick. Only the small live-work registry is polled;
231    /// the full state projection and traffic samples keep their slower cadence.
232    pub fn tick_llm_activity(&mut self, activity: Vec<crate::state::llm_activity::LlmActivity>) {
233        if self.snapshot.llm_activity != activity {
234            // Transient status rows must not move the operator onto a different button.
235            let cursor = self.rows().get(self.cards.row).cloned();
236            self.snapshot.active_conversations = activity.len();
237            self.snapshot.llm_activity = activity;
238            if let Some(cursor) = cursor {
239                let rows = self.rows();
240                if let Some(index) = rows.iter().position(|row| {
241                    row.key == cursor.key
242                        && row.on_enter == cursor.on_enter
243                        && row.buttons == cursor.buttons
244                        && row.positions() > 0
245                }) {
246                    self.cards.row = index;
247                }
248                self.clamp_cursor_to(&rows);
249            }
250            self.dirty = true;
251        }
252        if !self.snapshot.llm_activity.is_empty() {
253            self.animation_tick = self.animation_tick.wrapping_add(1);
254            self.dirty = true;
255        }
256    }
257
258    pub fn spinner(&self) -> &'static str {
259        crate::tui::llm_activity::spinner(self.animation_tick)
260    }
261
262    /// Take a freshly built snapshot: derive activity from the change, keep
263    /// the cursor on a row that exists, drop per-instance state for
264    /// instances that are gone.
265    pub fn absorb_snapshot(&mut self, snapshot: RailSnapshot) {
266        let cursor = if self.snapshot.llm_activity != snapshot.llm_activity {
267            self.rows().get(self.cards.row).cloned()
268        } else {
269            None
270        };
271        let events = self.tracker.diff(&snapshot);
272        for event in events {
273            self.activity.push(event);
274        }
275        let newest = self.newest_arrival(&snapshot);
276        self.snapshot = snapshot;
277        self.prune();
278        // An instance that just appeared gets the cursor: the thing you just
279        // made is what you want to look at, and its buttons are one ↓ away.
280        if let Some(key) = newest {
281            self.focus_card(key);
282        }
283        let rows = self.rows();
284        if newest.is_none() {
285            if let Some(cursor) = cursor {
286                if let Some(index) = rows.iter().position(|row| {
287                    row.key == cursor.key
288                        && row.on_enter == cursor.on_enter
289                        && row.buttons == cursor.buttons
290                        && row.positions() > 0
291                }) {
292                    self.cards.row = index;
293                }
294            }
295        }
296        self.clamp_cursor_to(&rows);
297        self.dirty = true;
298    }
299
300    /// The highest-id instance in `next` that the current snapshot lacks.
301    fn newest_arrival(&self, next: &RailSnapshot) -> Option<UiKey> {
302        let server = next
303            .servers
304            .iter()
305            .filter(|s| !self.snapshot.servers.iter().any(|old| old.id == s.id))
306            .map(|s| s.id)
307            .max_by_key(|id| id.as_u32())
308            .map(UiKey::Server);
309        let client = next
310            .clients
311            .iter()
312            .filter(|c| !self.snapshot.clients.iter().any(|old| old.id == c.id))
313            .map(|c| c.id)
314            .max_by_key(|id| id.as_u32())
315            .map(UiKey::Client);
316        client.or(server)
317    }
318
319    /// Put the cursor on `key`'s header row.
320    pub fn focus_card(&mut self, key: UiKey) {
321        let rows = self.rows();
322        if let Some(index) = crate::tui::cards::header_index(&rows, key) {
323            self.cards.row = index;
324            self.cards.col = 0;
325        }
326    }
327
328    /// Record one throughput sample per instance. Called on the 1s stats
329    /// tick, so a sample is bytes per second.
330    pub fn sample_metrics(&mut self) {
331        for server in &self.snapshot.servers {
332            let (mut rx, mut tx) = (0u64, 0u64);
333            for c in &server.conns {
334                rx += c.bytes_received;
335                tx += c.bytes_sent;
336            }
337            for c in &server.recent {
338                rx += c.bytes_received;
339                tx += c.bytes_sent;
340            }
341            self.cards
342                .metrics
343                .entry(UiKey::Server(server.id))
344                .or_default()
345                .sample(rx, tx);
346        }
347        for client in &self.snapshot.clients {
348            let (rx, tx) = client
349                .connection
350                .as_ref()
351                .map(|c| (c.bytes_received, c.bytes_sent))
352                .unwrap_or((0, 0));
353            self.cards
354                .metrics
355                .entry(UiKey::Client(client.id))
356                .or_default()
357                .sample(rx, tx);
358        }
359    }
360
361    fn prune(&mut self) {
362        let live: std::collections::HashSet<UiKey> = self
363            .snapshot
364            .servers
365            .iter()
366            .map(|s| UiKey::Server(s.id))
367            .chain(self.snapshot.clients.iter().map(|c| UiKey::Client(c.id)))
368            .collect();
369        self.cards.metrics.retain(|key, _| live.contains(key));
370        self.cards.state.retain_snapshot(&self.snapshot);
371    }
372
373    /// Keep the cursor on a row that can take it.
374    ///
375    /// Rows come and go under the cursor — a peer closes, a card folds, a
376    /// server stops — so the index is re-checked against the rows as they
377    /// are now: forward to the next stop, else back to the previous one.
378    pub fn clamp_cursor_to(&mut self, rows: &[Row]) {
379        if rows.is_empty() {
380            self.cards.row = 0;
381            self.cards.col = 0;
382            return;
383        }
384        let row = self.cards.row.min(rows.len() - 1);
385        let stop = (row..rows.len())
386            .find(|i| rows[*i].positions() > 0)
387            .or_else(|| (0..row).rev().find(|i| rows[*i].positions() > 0))
388            .unwrap_or(0);
389        self.cards.row = stop;
390        let positions = rows[stop].positions();
391        if positions == 0 {
392            self.cards.col = 0;
393        } else if self.cards.col >= positions {
394            self.cards.col = positions - 1;
395        }
396    }
397
398    /// The instance whose row the cursor is on, if any.
399    pub fn cursor_key(&self) -> Option<UiKey> {
400        self.rows().get(self.cards.row).and_then(|r| r.key)
401    }
402
403    pub fn server_row(&self, id: ServerId) -> Option<&ServerRow> {
404        self.snapshot.servers.iter().find(|s| s.id == id)
405    }
406
407    pub fn client_row(&self, id: ClientId) -> Option<&ClientRow> {
408        self.snapshot.clients.iter().find(|c| c.id == id)
409    }
410
411    pub fn instance(&self, key: UiKey) -> Option<InstanceRef<'_>> {
412        match key {
413            UiKey::Server(id) => self.server_row(id).map(InstanceRef::Server),
414            UiKey::Client(id) => self.client_row(id).map(InstanceRef::Client),
415        }
416    }
417
418    /// Pending intercepts across every instance.
419    pub fn waiting_count(&self) -> usize {
420        self.snapshot
421            .servers
422            .iter()
423            .map(|s| s.intercepts.len())
424            .sum::<usize>()
425            + self
426                .snapshot
427                .clients
428                .iter()
429                .map(|c| c.intercepts.len())
430                .sum::<usize>()
431    }
432
433    /// A conversation entry into the stream.
434    pub fn push_chat(&mut self, kind: EntryKind, text: impl Into<String>) {
435        let kind = match kind {
436            EntryKind::Log(level) => ActivityKind::Log(level),
437            other => ActivityKind::Chat(other),
438        };
439        self.activity.push(Activity {
440            kind,
441            owner: None,
442            tag: String::new(),
443            text: text.into(),
444            link: None,
445            at_unix_ms: None,
446        });
447        self.dirty = true;
448    }
449
450    pub fn push_system(&mut self, text: impl Into<String>) {
451        self.push_chat(EntryKind::System, text);
452    }
453
454    pub fn push_error(&mut self, text: impl Into<String>) {
455        self.push_chat(EntryKind::Log(crate::ui::app::LogLevel::Error), text);
456    }
457}