Skip to main content

netget/tui/
mod.rs

1//! Full-screen ratatui dashboard — the default interactive UI.
2//!
3//! Management on the left: every server and client as a card with its
4//! buttons and sections, always visible. On the right, one stream of what is
5//! happening and of the conversation, with the input box at the bottom. See
6//! `src/tui/AGENTS.md` for the design.
7//!
8
9pub mod actions;
10pub mod activity;
11pub mod app;
12pub mod cards;
13pub mod chat;
14pub mod command_exec;
15pub mod commands;
16pub mod driver;
17pub mod event_loop;
18pub mod hit;
19pub mod keymap;
20pub mod llm_activity;
21pub mod metrics;
22pub mod modal;
23pub mod modal_keys;
24pub mod projection;
25pub mod rail;
26pub mod render;
27pub mod theme;
28pub mod uimsg;
29pub mod wireshark;
30
31use std::sync::Arc;
32
33use anyhow::Result;
34use tokio::sync::{mpsc, Mutex};
35
36use crate::cli::theme::ColorPalette;
37use crate::events::EventHandler;
38use crate::llm::OllamaClient;
39use crate::settings::Settings;
40use crate::state::app_state::AppState;
41use crate::ui::App;
42
43use app::DashboardApp;
44use event_loop::LoopContext;
45use theme::Styles;
46
47/// Entry point: the interactive UI.
48#[cfg(not(target_arch = "wasm32"))]
49pub async fn run_dashboard(
50    state: AppState,
51    core: App,
52    event_handler: EventHandler,
53    llm_client: OllamaClient,
54    settings: Settings,
55    args: &crate::cli::Args,
56    palette: ColorPalette,
57) -> Result<()> {
58    let (app, ctx) = prepare_dashboard(
59        state,
60        core,
61        event_handler,
62        llm_client,
63        settings,
64        args,
65        palette,
66    )
67    .await?;
68    event_loop::run(app, ctx).await
69}
70
71/// Channels a hosted page uses to report status and request dashboard forms.
72pub type DashboardReady =
73    Box<dyn FnOnce(mpsc::UnboundedSender<String>, mpsc::UnboundedSender<uimsg::UiMsg>)>;
74
75/// The dashboard on a caller-supplied terminal and input stream: the browser
76/// build's entry point (`crates/netget-web`), where the terminal is xterm.js
77/// and the events come from the DOM. Same construction as [`run_dashboard`];
78/// `on_ready` receives the status and UI channels once the loop is about to start.
79#[allow(clippy::too_many_arguments)]
80pub async fn run_dashboard_on<B, S>(
81    terminal: &mut ratatui::Terminal<B>,
82    events: S,
83    state: AppState,
84    core: App,
85    event_handler: EventHandler,
86    llm_client: OllamaClient,
87    settings: Settings,
88    args: &crate::cli::Args,
89    palette: ColorPalette,
90    on_ready: Option<DashboardReady>,
91) -> Result<()>
92where
93    B: ratatui::backend::Backend,
94    S: futures::Stream<Item = std::io::Result<crossterm::event::Event>> + Unpin,
95{
96    let (app, ctx) = prepare_dashboard(
97        state,
98        core,
99        event_handler,
100        llm_client,
101        settings,
102        args,
103        palette,
104    )
105    .await?;
106    // The caller gets the dashboard's own status channel, so anything it starts on the
107    // side (the page's quick-start buttons go through `ServerForm::create`) logs into the
108    // same stream and repaints the same cards.
109    if let Some(ready) = on_ready {
110        ready(ctx.status_tx.clone(), app.ui_tx.clone());
111    }
112    event_loop::run_loop(terminal, events, app, ctx).await
113}
114
115/// Everything `run_dashboard` does before the loop: resolve the model, wire
116/// the channels, build the `DashboardApp`.
117async fn prepare_dashboard(
118    state: AppState,
119    mut core: App,
120    event_handler: EventHandler,
121    llm_client: OllamaClient,
122    settings: Settings,
123    args: &crate::cli::Args,
124    palette: ColorPalette,
125) -> Result<(DashboardApp, LoopContext)> {
126    let settings = Arc::new(Mutex::new(settings));
127
128    // Resolve the model exactly as the legacy TUI does (shared module).
129    let resolved = {
130        let guard = settings.lock().await;
131        crate::cli::model_select::resolve_startup_model(args, &guard).await?
132    };
133    state
134        .set_ollama_model(if resolved.model.is_empty() {
135            None
136        } else {
137            Some(resolved.model.clone())
138        })
139        .await;
140    core.connection_info.model = resolved.model.clone();
141
142    // Web-search mode from settings, and the CLI handler-mode override.
143    let web_search_mode = settings.lock().await.get_web_search_mode();
144    state.set_web_search_mode(web_search_mode).await;
145    if let Ok(Some(handler_mode)) = args.parse_event_handler_mode() {
146        state.set_event_handler_mode(handler_mode).await;
147    }
148
149    let (status_tx, status_rx) = mpsc::unbounded_channel::<String>();
150    let (web_approval_tx, web_approval_rx) = mpsc::unbounded_channel();
151    let (ui_tx, ui_rx) = mpsc::unbounded_channel::<uimsg::UiMsg>();
152    state.set_web_approval_channel(web_approval_tx).await;
153
154    let styles = Styles::from_palette(&palette);
155    let mut app = DashboardApp::new(core, styles, status_tx.clone(), ui_tx, llm_client.clone());
156
157    app.push_system("NetGet — a starts a server or client, Tab reaches them, F1 lists every key");
158    for message in resolved.messages {
159        app.push_system(message);
160    }
161
162    // ASCII banner streams into chat like any other status output.
163    #[cfg(not(target_arch = "wasm32"))]
164    if args.show_art {
165        let base_url = resolved.base_url.clone();
166        let model = resolved.model.clone();
167        let tx = status_tx.clone();
168        tokio::spawn(async move {
169            let _ =
170                crate::cli::banner::generate_and_stream_ascii_banner(&base_url, &model, tx).await;
171        });
172    }
173
174    let ctx = LoopContext {
175        state,
176        event_handler,
177        llm_client,
178        settings,
179        status_tx,
180        status_rx,
181        web_approval_rx,
182        ui_rx,
183    };
184
185    Ok((app, ctx))
186}