1mod 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}