Skip to main content

mobius_cli/frontend/
dashboard.rs

1//! Live gateway dashboard and gateway-scoped setup entrypoints.
2
3mod runtime;
4mod state;
5mod view;
6
7use std::env;
8use std::io;
9use std::path::PathBuf;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12use mobius::protocol::{
13    EventMsg, FrontendActionListItem, FrontendBlock, FrontendEvent, FrontendListItemState,
14    FrontendSlot, FrontendTone, FrontendWidget, FrontendWidgetContent, MAX_USER_INPUT_BYTES, Op,
15    Submission,
16};
17use mobius::{Error, Result};
18use mobius_gateway::client::{Endpoint, GatewayClient, GatewayEvents, GatewaySender};
19use mobius_gateway::config::{ConfigStore, GatewayConfig};
20use mobius_gateway::wire::{
21    ClientKind, ClientMessage, ClientStatus, DailyUsage, ProfileSnapshot, ReadyPayload,
22    ServerMessage, SessionActivityState, SessionReadyPayload, SessionRecord,
23};
24use ratatui::Terminal;
25use ratatui::backend::CrosstermBackend;
26use ratatui::crossterm::event::{
27    Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind,
28};
29use ratatui::layout::{Constraint, Layout, Rect};
30use ratatui::style::{Modifier, Style};
31use ratatui::text::{Line, Span};
32use ratatui::widgets::{Block, Borders, Clear, HighlightSpacing, List, ListState, Paragraph, Wrap};
33use tokio::time::MissedTickBehavior;
34use uuid::Uuid;
35
36use super::setup::{self, SetupMode};
37use super::terminal::{INPUT_POLL, MAX_INPUT_BATCH, TerminalGuard, poll_event};
38use super::terminal_text;
39use super::theme::{Role, current};
40use crate::gateway_accounts::configured_token;
41
42use self::runtime::*;
43use self::state::*;
44use self::view::*;
45
46const REFRESH_INTERVAL: Duration = Duration::from_secs(1);
47
48pub async fn run(state_dir: PathBuf) -> Result<()> {
49    let (sender, mut events, mut state) = connect(state_dir).await?;
50    let _guard = TerminalGuard::alternate()?;
51    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
52    terminal.clear()?;
53    dashboard_loop(&mut terminal, sender, &mut events, &mut state).await
54}
55
56pub async fn run_provider(state_dir: PathBuf) -> Result<()> {
57    let (sender, mut events, mut state) = connect(state_dir).await?;
58    let mut guard = TerminalGuard::alternate()?;
59    guard.set_mouse_capture(false)?;
60    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
61    setup::run_gateway(
62        &mut terminal,
63        SetupMode::Login,
64        &sender,
65        &mut events,
66        &mut state.gateway,
67    )
68    .await
69}
70
71#[cfg(test)]
72mod tests {
73    use mobius::backend::checkpoint::SessionSummary;
74    use mobius::protocol::{
75        FrontendAction, FrontendActionListItem, FrontendBlock, FrontendBlockFormat,
76        FrontendBlockRole, FrontendBlockState, FrontendBlockUpdate, FrontendEvent,
77        FrontendListItemState, FrontendPickerOption, FrontendSlot, FrontendSymbol, FrontendTone,
78        FrontendWidget, FrontendWidgetContent, Op, SessionContext, TokenUsage,
79    };
80    use mobius_gateway::wire::{DailyUsage, SessionActivity, SessionActivityState, SessionRecord};
81    use ratatui::Terminal;
82    use ratatui::backend::TestBackend;
83    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
84    use ratatui::widgets::ListState;
85
86    use super::{
87        CapabilityOverlay, activate_overlay, handle_action_input_key, moved_index,
88        ordered_sessions, prepare_overlay_operation, render_action_list, token_total_for_day,
89    };
90
91    #[test]
92    fn daily_usage_sums_all_providers_on_the_same_day() {
93        let usage = [
94            DailyUsage {
95                unix_day: 7,
96                provider: "openai_socket".into(),
97                usage: TokenUsage {
98                    total_tokens: 11,
99                    ..TokenUsage::default()
100                },
101            },
102            DailyUsage {
103                unix_day: 7,
104                provider: "kimi".into(),
105                usage: TokenUsage {
106                    total_tokens: 13,
107                    ..TokenUsage::default()
108                },
109            },
110            DailyUsage {
111                unix_day: 8,
112                provider: "responses".into(),
113                usage: TokenUsage {
114                    total_tokens: 17,
115                    ..TokenUsage::default()
116                },
117            },
118        ];
119
120        assert_eq!(token_total_for_day(&usage, 7), 24);
121    }
122
123    #[test]
124    fn moved_index_clamps_scroll_to_the_history() {
125        assert_eq!(
126            (
127                moved_index(Some(1), 3, -5),
128                moved_index(Some(1), 3, 5),
129                moved_index(None, 0, 1),
130            ),
131            (Some(0), Some(2), None)
132        );
133    }
134
135    #[test]
136    fn session_identity_survives_activity_sorting() {
137        let mut sessions = vec![
138            session("selected", SessionActivityState::Idle),
139            session("other", SessionActivityState::Running),
140        ];
141        assert_eq!(
142            ordered_sessions(&sessions)
143                .iter()
144                .position(|session| session.summary.session_id == "selected"),
145            Some(1)
146        );
147
148        sessions[0].activity.state = SessionActivityState::Running;
149        sessions[1].activity.state = SessionActivityState::Idle;
150        assert_eq!(
151            ordered_sessions(&sessions)
152                .iter()
153                .position(|session| session.summary.session_id == "selected"),
154            Some(0)
155        );
156    }
157
158    #[test]
159    fn open_widget_tracks_updates_and_submits_the_advertised_operation() {
160        let key: (String, String) = ("capability-a".into(), "view".into());
161        let mut overlay = CapabilityOverlay {
162            session_id: "session-1".into(),
163            widgets: vec![(key.clone(), widget(blocks("Initial")))],
164            widget_list: ListState::default(),
165            open: Some(key.clone()),
166            option_list: ListState::default(),
167            action_index: 0,
168            input: None,
169        };
170        overlay.sync_selection();
171        let op = Op::SetModel {
172            route: "route-a".into(),
173        };
174        overlay.apply(FrontendEvent::Widget {
175            capability: key.0.clone(),
176            item: widget(FrontendWidgetContent::Picker {
177                title: "Updated".into(),
178                options: vec![FrontendPickerOption {
179                    label: "Apply".into(),
180                    description: "Apply the advertised operation".into(),
181                    detail: String::new(),
182                    symbol: None,
183                    shows_detail: false,
184                    op: op.clone(),
185                }],
186            }),
187        });
188
189        assert!(matches!(
190            overlay.open_widget().and_then(|widget| widget.content.as_ref()),
191            Some(FrontendWidgetContent::Picker { title, .. }) if title == "Updated"
192        ));
193        assert_eq!(activate_overlay(&mut overlay), Some(op));
194
195        overlay.apply(FrontendEvent::RemoveWidget {
196            capability: key.0,
197            id: key.1,
198        });
199        assert!(overlay.open.is_none());
200    }
201
202    #[test]
203    fn opening_widget_submits_its_advertised_refresh() {
204        let key: (String, String) = ("capability-a".into(), "view".into());
205        let op = Op::CapabilityCommand {
206            capability: key.0.clone(),
207            command: "refresh".into(),
208            arguments: String::new(),
209            input: None,
210            target: None,
211        };
212        let mut item = widget(blocks("Initial"));
213        item.action = Some(op.clone());
214        let mut overlay = CapabilityOverlay {
215            session_id: "session-1".into(),
216            widgets: vec![(key.clone(), item)],
217            widget_list: ListState::default().with_selected(Some(0)),
218            open: None,
219            option_list: ListState::default(),
220            action_index: 0,
221            input: None,
222        };
223
224        assert_eq!(activate_overlay(&mut overlay), Some(op));
225        assert_eq!(overlay.open, Some(key));
226    }
227
228    #[test]
229    fn action_list_renders_one_row_with_declared_actions_and_runs_the_selected_one() {
230        let edit = capability_op("edit", Some("Remember this"));
231        let delete = capability_op("delete", None);
232        let content = action_list(edit.clone(), delete.clone());
233        let key: (String, String) = ("capability-a".into(), "view".into());
234        let mut overlay = CapabilityOverlay {
235            session_id: "session-1".into(),
236            widgets: vec![(key.clone(), widget(content.clone()))],
237            widget_list: ListState::default(),
238            open: Some(key),
239            option_list: ListState::default().with_selected(Some(0)),
240            action_index: 1,
241            input: None,
242        };
243        let FrontendWidgetContent::ActionList { title, items } = content else {
244            unreachable!();
245        };
246        let mut terminal = Terminal::new(TestBackend::new(72, 5)).expect("terminal");
247
248        terminal
249            .draw(|frame| {
250                render_action_list(
251                    frame,
252                    frame.area(),
253                    &title,
254                    &items,
255                    &mut overlay.option_list,
256                    overlay.action_index,
257                );
258            })
259            .expect("action list draw");
260
261        let rendered = terminal.backend().to_string();
262        let row = rendered
263            .lines()
264            .find(|line| line.contains("Remember this"))
265            .expect("note row");
266        let note_position = row.find("Remember this").expect("note text");
267        let edit_position = row.find("[Edit]").expect("edit action");
268        let delete_position = row.find("[Delete]").expect("delete action");
269        assert!(note_position < edit_position && edit_position < delete_position);
270        assert!(row.len().saturating_sub(delete_position + "[Delete]".len()) <= 1);
271        assert_eq!(activate_overlay(&mut overlay), Some(delete));
272    }
273
274    #[test]
275    fn editable_action_replaces_its_advertised_input_before_submission() {
276        let key: (String, String) = ("capability-a".into(), "view".into());
277        let mut overlay = CapabilityOverlay {
278            session_id: "session-1".into(),
279            widgets: vec![(key.clone(), widget(blocks("Initial")))],
280            widget_list: ListState::default(),
281            open: Some(key),
282            option_list: ListState::default(),
283            action_index: 0,
284            input: None,
285        };
286
287        assert!(
288            prepare_overlay_operation(&mut overlay, capability_op("edit", Some("Before")))
289                .is_none()
290        );
291        handle_action_input_key(
292            &mut overlay,
293            KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE),
294        );
295        let submitted = handle_action_input_key(
296            &mut overlay,
297            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
298        )
299        .expect("submitted operation");
300
301        assert!(matches!(
302            submitted,
303            Op::CapabilityCommand { input: Some(value), .. } if value == "Before!"
304        ));
305    }
306
307    fn session(id: &str, state: SessionActivityState) -> SessionRecord {
308        SessionRecord {
309            summary: SessionSummary {
310                session_id: id.into(),
311                session_context: SessionContext::default(),
312                parent_session_id: None,
313                parent_sequence: None,
314                sequence: 0,
315                catalog_visible: true,
316                first_user_message: None,
317                execution_stats: Default::default(),
318                created_at: 0,
319                updated_at: 0,
320            },
321            title: None,
322            pinned: false,
323            activity: SessionActivity {
324                state,
325                ..SessionActivity::default()
326            },
327        }
328    }
329
330    fn blocks(text: &str) -> FrontendWidgetContent {
331        FrontendWidgetContent::Blocks {
332            title: "View".into(),
333            blocks: vec![FrontendBlock {
334                id: None,
335                group: None,
336                update: FrontendBlockUpdate::Replace,
337                state: FrontendBlockState::Complete,
338                role: FrontendBlockRole::Notice,
339                title: String::new(),
340                text: text.into(),
341                symbol: None,
342                format: FrontendBlockFormat::PlainText,
343                tone: FrontendTone::Neutral,
344                files: Vec::new(),
345            }],
346        }
347    }
348
349    fn action_list(edit: Op, delete: Op) -> FrontendWidgetContent {
350        FrontendWidgetContent::ActionList {
351            title: "Notes".into(),
352            items: vec![FrontendActionListItem {
353                id: "note-1".into(),
354                text: "Remember this".into(),
355                state: FrontendListItemState::Plain,
356                actions: vec![
357                    FrontendAction {
358                        id: "edit".into(),
359                        label: "Edit".into(),
360                        symbol: FrontendSymbol::Edit,
361                        tone: FrontendTone::Neutral,
362                        op: edit,
363                    },
364                    FrontendAction {
365                        id: "delete".into(),
366                        label: "Delete".into(),
367                        symbol: FrontendSymbol::Delete,
368                        tone: FrontendTone::Error,
369                        op: delete,
370                    },
371                ],
372            }],
373        }
374    }
375
376    fn capability_op(command: &str, input: Option<&str>) -> Op {
377        Op::CapabilityCommand {
378            capability: "capability-a".into(),
379            command: command.into(),
380            arguments: "note-1".into(),
381            input: input.map(str::to_owned),
382            target: None,
383        }
384    }
385
386    fn widget(content: FrontendWidgetContent) -> FrontendWidget {
387        FrontendWidget {
388            id: "view".into(),
389            slot: FrontendSlot::Navigation,
390            text: "Capability view".into(),
391            tone: FrontendTone::Neutral,
392            symbol: None,
393            icon_only: false,
394            progress: None,
395            content: Some(content),
396            action: None,
397        }
398    }
399}