Skip to main content

llm_manager/tui/
app.rs

1pub mod async_ops;
2pub mod help;
3pub mod metadata;
4pub mod panels;
5pub mod pickers;
6pub mod profiles;
7pub mod state;
8pub mod sync_ops;
9pub mod types;
10
11// Re-export all types for backward compatibility
12pub use types::*;
13
14use crate::config::Config;
15use crate::config::LogEntry;
16use crate::config::physical_cores;
17use std::collections::VecDeque;
18
19// Import App from types submodule for impl block
20pub use types::App;
21
22impl App {
23    pub fn new(config: Config) -> Self {
24        let active_panel = config.active_panel;
25        let left_pct = config.left_pct;
26        let mut log = VecDeque::new();
27        log.push_back(LogEntry::new(
28            "Starting llm-manager...",
29            crate::config::LogLevel::Info,
30        ));
31        let settings: crate::models::ModelSettings =
32            crate::models::ModelSettings::from_config(&config);
33        let settings_clone = settings.clone();
34        let server_mode = config.default.server_mode;
35        let router_max_models = config.default.router_max_models;
36          Self {
37            running: true,
38            config,
39            models: Vec::new(),
40            selected_model_idx: None,
41            models_mode: types::ModelsMode::List,
42            settings: settings_clone,
43            model_settings_cache: settings.clone(),
44            model_states: Default::default(),
45            metrics: Default::default(),
46            max_threads: physical_cores(),
47            cancelled: None,
48            server_mode,
49            router_max_models,
50            ws_server_handle: None,
51            background_tasks: Default::default(),
52
53            settings_state: SettingsState {
54                settings_selected_idx: 0,
55                server_settings_selected_idx: 0,
56                server_settings_scroll_offset: 0,
57                settings_edit_buffer: String::new(),
58                settings_scroll_offset: 0,
59                settings_render_cache: None,
60                expert_mode: false,
61            },
62            picker: PickerState {
63                host_picker_entries: Vec::new(),
64                host_picker_selected: 0,
65                backend_picker_entries: Vec::new(),
66                backend_picker_selected: 0,
67                prompt_picker_entries: Vec::new(),
68                prompt_picker_selected: 0,
69                profile_picker_entries: Vec::new(),
70                profile_picker_selected: 0,
71                profiles_scroll_offset: 0,
72                system_prompt_presets_scroll_offset: 0,
73                rpc_workers_selected_idx: 0,
74                editing_rpc_worker: None,
75                rpc_workers_scroll_offset: 0,
76                readme_scroll_offset: 0,
77            },
78            download: DownloadState {
79                download_progress: Vec::new(),
80                download_tx: None,
81                download_rx: None,
82                download_scroll_state: Default::default(),
83                downloading: false,
84            },
85            server: ServerState {
86                server_handle: None,
87                metrics_task_handle: None,
88                sync_task_handle: None,
89                spawn_task_handle: None,
90                bench_tune_task_handle: None,
91                server_log_rx: None,
92                metrics_rx: None,
93                sync_rx: None,
94                spawn_log_tx: None,
95                metrics_model_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
96                loaded_model_names: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
97                api_proxy_handle: None,
98                metrics_tx: None,
99                running_ws_port: None,
100                running_ws_auth: None,
101                running_ws_tls: None,
102                running_api_port: None,
103                running_api_server_port: None,
104                running_api_model: None,
105                running_ws_tls_cfg: None,
106                running_ws_tls_cert_path: None,
107                running_ws_tls_key_path: None,
108                cmd_display: None,
109                spawned_settings: None,
110                spawned_model_name: None,
111                spawned_model_state: None,
112                spawned_context_length: 0,
113                server_exit_rx: None,
114                server_exit_tx: None,
115                api_shutdown_tx: None,
116            },
117            bench_tune: BenchTuneState {
118                bench_tune_progress: None,
119                bench_tune_results: Vec::new(),
120                bench_tune_running: false,
121                bench_tune_config: None,
122                bench_tune_rx: None,
123                bench_tune_tx: None,
124                bench_tune_output_view: None,
125                bench_tune_cancel_tx: None,
126                bench_tune_output_scroll: 0,
127                bench_tune_output_h_scroll: 0,
128                bench_tune_result_row: 0,
129                bench_tune_table_state: Default::default(),
130                bench_tune_output_index: 0,
131            },
132            log: LogState {
133                log_entries: log,
134                log_expanded: false,
135                log_scroll_offset: 0,
136                log_follow: true,
137                log_total_lines: 0,
138            },
139            loading: LoadingState {
140                loading_phases: Default::default(),
141                last_active_phase: None,
142                loading_progress: 0.0,
143                progress_target: 0.0,
144                load_progress: Default::default(),
145                last_spinner_time: None,
146                loading_spinner: 0,
147                model_total_layers: 0,
148                model_hidden_size: 0,
149                model_n_ctx_train: 0,
150                model_n_head: 0,
151                model_n_kv_head: 0,
152                vram_estimate: 0,
153                last_metadata_parse: (std::path::PathBuf::new(), std::time::SystemTime::now()),
154                health_poll_handle: None,
155                loading_completion_rx: None,
156            },
157            pending: PendingOperations {
158                pending_download: None,
159                pending_deletion: None,
160                pending_backend_deletion: None,
161                pending_spawn: None,
162                pending_api_load: None,
163                pending_api_unload: None,
164                pending_kill: None,
165                backend_resolving: false,
166                backend_resolve_handle: None,
167            },
168            search: SearchState {
169                local_filter: String::new(),
170                filtering_local: false,
171                search_results_idx: None,
172                search_table_state: Default::default(),
173                files_table_state: Default::default(),
174                readme_cache: None,
175                gguf_metadata_cache: Default::default(),
176                pending_search_load: None,
177                search_loading: false,
178                search_input: None,
179            },
180            ui: UIState {
181                active_panel,
182                global_mode: types::GlobalMode::Normal,
183                panel_visibility: 0b111111,
184                panel_help: false,
185                panel_help_offset: 0,
186                last_error_message: None,
187                list_state: Default::default(),
188                resize_state: None,
189                left_pct,
190                needs_full_redraw: false,
191                needs_redraw: true,
192                text_scrolls: Default::default(),
193            },
194            edit: EditState {
195                edit_cursor_pos: 0,
196                editing_n_predict: false,
197                n_predict_edit_buffer: String::new(),
198                editing_iters: false,
199                iters_edit_buffer: String::new(),
200                tags_editing: false,
201                tags_edit_buffer: String::new(),
202                tags_selected_idx: None,
203                tags_insert_mode: false,
204                editing_preset: None,
205            },
206        }
207    }
208}
209
210impl App {
211    const SCROLL_TICK_MS: u64 = 870;
212    const SCROLL_HOLD_FRAMES: u8 = 5;
213
214    pub fn tick_text_scrolls(&mut self) {
215        let now = std::time::Instant::now();
216        let mut changed = false;
217
218        for (_, state) in self.ui.text_scrolls.iter_mut() {
219            // Skip invisible entries entirely
220            if !state.visible {
221                continue;
222            }
223
224            if state.max_offset == 0 {
225                if state.offset != 0 {
226                    state.offset = 0;
227                    changed = true;
228                }
229                continue;
230            }
231
232            if now.duration_since(state.last_tick) >= std::time::Duration::from_millis(Self::SCROLL_TICK_MS) {
233                // Handle window resize where the new visible width is smaller (max_offset shrinks)
234                if state.offset > state.max_offset {
235                    state.offset = state.max_offset;
236                    state.direction = -1;
237                    state.hold_count = Self::SCROLL_HOLD_FRAMES;
238                }
239
240                let prev_offset = state.offset;
241
242                if state.offset == 0 && state.direction == -1 {
243                    state.direction = 1;
244                    state.hold_count = Self::SCROLL_HOLD_FRAMES;
245                } else if state.offset == state.max_offset && state.direction == 1 {
246                    state.direction = -1;
247                    state.hold_count = Self::SCROLL_HOLD_FRAMES;
248                }
249
250                if state.hold_count > 0 {
251                    state.hold_count -= 1;
252                } else {
253                    state.offset = if state.direction > 0 {
254                        state.offset.saturating_add(1)
255                    } else {
256                        state.offset.saturating_sub(1)
257                    };
258                }
259
260                state.last_tick = now;
261                if state.offset != prev_offset {
262                    changed = true;
263                }
264            }
265        }
266
267        if changed {
268            self.ui.needs_redraw = true;
269        }
270    }
271
272    pub fn init_scrolls_for_models(&mut self) {
273        use std::time::Instant;
274        for model in &self.models {
275            let key = model.display_name.clone();
276            let max_offset = model.display_name.chars().count().saturating_sub(20);
277            self.ui.text_scrolls.insert(key, TextScrollState {
278                offset: 0,
279                last_tick: Instant::now(),
280                direction: 1,
281                hold_count: 0,
282                max_offset,
283                visible: false,
284            });
285        }
286    }
287
288    #[allow(dead_code)]
289    pub fn get_scroll_state(&self, key: &str) -> Option<&TextScrollState> {
290        self.ui.text_scrolls.get(key)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::models::LoadProgress;
298
299    fn make_app() -> App {
300        let config = crate::config::Config {
301            models_dirs: vec![],
302            llama_server: std::path::PathBuf::new(),
303            default: crate::config::DefaultParams::default(),
304            model_overrides: crate::config::ModelConfigStore::new(),
305            profiles: crate::config::ProfileStore::new(),
306            system_prompt_presets: crate::config::PresetStore::new(),
307            rpc_workers: Vec::new(),
308            search_limit: 50,
309            active_panel: types::ActivePanel::Models,
310            left_pct: 55,
311        };
312        let mut app = App::new(config);
313        app.loading.loading_phases.clear();
314        app.loading.last_active_phase = None;
315        app.loading.loading_progress = 0.0;
316        app.loading.progress_target = 0.0;
317        app.loading.load_progress = LoadProgress {
318            layers_total: None,
319            layers_loaded: None,
320            tensors_total: None,
321            tensors_loaded: 0,
322            buffers: vec![],
323        };
324        app.loading.last_spinner_time = None;
325        app
326    }
327
328    #[test]
329    fn test_progress_server_starting() {
330        let mut app = make_app();
331        app.loading
332            .loading_phases
333            .insert(LoadingPhase::ServerStarting);
334        app.loading.last_active_phase = Some(LoadingPhase::ServerStarting);
335        app.compute_progress();
336        assert!((app.loading.loading_progress - 0.08).abs() < 0.001);
337    }
338
339    #[test]
340    fn test_progress_with_layers() {
341        let mut app = make_app();
342        app.loading
343            .loading_phases
344            .insert(LoadingPhase::ServerStarting);
345        app.loading
346            .loading_phases
347            .insert(LoadingPhase::LoadingModel);
348        app.loading.loading_phases.insert(LoadingPhase::LoadingMeta);
349        app.loading
350            .loading_phases
351            .insert(LoadingPhase::LoadingTensors);
352        app.loading.last_active_phase = Some(LoadingPhase::LoadingTensors);
353        app.loading.load_progress.layers_loaded = Some(16);
354        app.loading.load_progress.layers_total = Some(32);
355        app.compute_progress();
356        assert!((app.loading.loading_progress - 0.57).abs() < 0.01);
357    }
358
359    #[test]
360    fn test_progress_complete() {
361        let mut app = make_app();
362        app.loading.loading_phases.insert(LoadingPhase::Complete);
363        app.loading.last_active_phase = Some(LoadingPhase::Complete);
364        app.compute_progress();
365        assert!((app.loading.loading_progress - 1.0).abs() < 0.001);
366    }
367
368    #[test]
369    fn test_progress_all_phases() {
370        let mut app = make_app();
371        app.loading
372            .loading_phases
373            .insert(LoadingPhase::ServerStarting);
374        app.loading
375            .loading_phases
376            .insert(LoadingPhase::LoadingModel);
377        app.loading.loading_phases.insert(LoadingPhase::LoadingMeta);
378        app.loading
379            .loading_phases
380            .insert(LoadingPhase::LoadingTensors);
381        app.loading
382            .loading_phases
383            .insert(LoadingPhase::ServerListening);
384        app.loading.last_active_phase = Some(LoadingPhase::ServerListening);
385        app.compute_progress();
386        assert!((app.loading.loading_progress - 0.98).abs() < 0.01);
387    }
388}