Skip to main content

cc_switch_lib/cli/tui/
mod.rs

1mod app;
2mod data;
3mod form;
4mod route;
5mod runtime_actions;
6mod runtime_skills;
7mod runtime_systems;
8mod terminal;
9#[cfg(test)]
10mod tests;
11mod theme;
12mod ui;
13
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16
17use crossterm::event::{self, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind};
18
19use crate::app_config::AppType;
20use crate::cli::i18n::texts;
21use crate::error::AppError;
22
23use app::{Action, App, ToastKind};
24use runtime_actions::handle_action;
25#[cfg(test)]
26use runtime_actions::{
27    import_mcp_for_current_app_with, open_proxy_help_overlay_with, queue_managed_proxy_action,
28    run_external_editor_for_current_editor,
29};
30#[cfg(test)]
31use runtime_skills::{
32    finish_skills_import_with, open_skills_import_picker_with, scan_unmanaged_skills_with,
33};
34pub(crate) use runtime_systems::build_stream_check_result_lines;
35#[cfg(test)]
36use runtime_systems::{
37    apply_webdav_jianguoyun_quick_setup, build_model_fetch_candidate_urls, drain_latest_webdav_req,
38    model_fetch_strategy_for_field, parse_model_ids_from_response, update_webdav_last_error_with,
39    ProxyReq, UpdateMsg, WebDavReq, WebDavReqKind,
40};
41pub(crate) use runtime_systems::{fetch_provider_models_for_tui, ModelFetchStrategy};
42use runtime_systems::{
43    handle_local_env_msg, handle_model_fetch_msg, handle_proxy_msg, handle_quota_msg,
44    handle_skills_msg, handle_speedtest_msg, handle_stream_check_msg, handle_update_msg,
45    handle_webdav_msg, start_local_env_system, start_model_fetch_system, start_proxy_system,
46    start_quota_system, start_skills_system, start_speedtest_system, start_stream_check_system,
47    start_update_system, start_webdav_system, LocalEnvReq, QuotaReq, RequestTracker,
48};
49use terminal::{PanicRestoreHookGuard, TuiTerminal};
50
51pub(super) const TUI_TICK_RATE: Duration = Duration::from_millis(200);
52const QUOTA_REFRESH_INTERVAL_TICKS: u64 = 5 * 60 * 1000 / 200;
53
54fn resolve_initial_app_type(app_override: Option<AppType>) -> AppType {
55    let requested = app_override.unwrap_or(AppType::Claude);
56    let visible_apps = crate::settings::get_visible_apps();
57
58    if visible_apps.is_enabled_for(&requested) {
59        return requested;
60    }
61
62    crate::settings::next_visible_app(&visible_apps, &requested, 1).unwrap_or(requested)
63}
64
65fn initialize_app_state_with<F>(
66    app_override: Option<AppType>,
67    load_data: F,
68) -> Result<(App, data::UiData), AppError>
69where
70    F: FnOnce(&AppType) -> Result<data::UiData, AppError>,
71{
72    let app_type = resolve_initial_app_type(app_override);
73    let app = App::new(Some(app_type));
74    let data = load_data(&app.app_type)?;
75    Ok((app, data))
76}
77
78#[cfg(test)]
79fn initialize_app_state_for_test<F>(
80    app_override: Option<AppType>,
81    load_data: F,
82) -> Result<(App, data::UiData), AppError>
83where
84    F: FnOnce(&AppType) -> Result<data::UiData, AppError>,
85{
86    initialize_app_state_with(app_override, load_data)
87}
88
89#[derive(Default)]
90struct ProxyOpenFlash {
91    effect: Option<tachyonfx::Effect>,
92    started_tick: Option<u64>,
93}
94
95impl ProxyOpenFlash {
96    fn sync(&mut self, app: &App, area: ratatui::layout::Rect) {
97        let Some(transition) = app.proxy_visual_transition else {
98            return;
99        };
100
101        if transition.to_on && self.started_tick != Some(transition.started_tick) {
102            self.effect = Some(ui::proxy_open_flash_effect(area));
103            self.started_tick = Some(transition.started_tick);
104        }
105    }
106
107    fn process(
108        &mut self,
109        frame_dt: Duration,
110        buf: &mut ratatui::buffer::Buffer,
111        area: ratatui::layout::Rect,
112    ) {
113        let Some(effect) = self.effect.as_mut() else {
114            return;
115        };
116
117        effect.set_area(area);
118        effect.process(frame_dt.into(), buf, area);
119
120        if effect.done() {
121            self.effect = None;
122        }
123    }
124
125    #[cfg(test)]
126    fn active(&self) -> bool {
127        self.effect.is_some()
128    }
129}
130
131fn queue_quota_refresh(
132    app: &mut App,
133    data: &mut data::UiData,
134    quota_req_tx: Option<&mpsc::Sender<QuotaReq>>,
135    target: data::QuotaTarget,
136    manual: bool,
137) {
138    let Some(tx) = quota_req_tx else {
139        if manual {
140            app.push_toast(
141                texts::tui_toast_quota_worker_unavailable("quota worker is not running"),
142                ToastKind::Warning,
143            );
144        }
145        return;
146    };
147
148    data.quota.mark_loading(target.clone(), manual);
149    if let Err(error) = tx.send(QuotaReq::Refresh {
150        target: target.clone(),
151    }) {
152        data.quota.finish_error(target, error.to_string());
153        app.push_toast(
154            texts::tui_toast_quota_refresh_failed(&error.to_string()),
155            ToastKind::Warning,
156        );
157    } else if manual {
158        app.push_toast(
159            texts::tui_toast_quota_refresh_started(&target.provider_name),
160            ToastKind::Info,
161        );
162    }
163}
164
165fn queue_current_quota_refresh_if_due(
166    app: &mut App,
167    data: &mut data::UiData,
168    quota_req_tx: Option<&mpsc::Sender<QuotaReq>>,
169) {
170    let Some(target) = data::quota_target_for_current_provider(&app.app_type, data) else {
171        app.quota_auto_target_key = None;
172        app.quota_last_auto_tick = None;
173        return;
174    };
175
176    let target_key = target.cache_key();
177    let target_changed = app.quota_auto_target_key.as_deref() != Some(target_key.as_str());
178    let target_missing_state = data.quota.state_for(&target.provider_id).is_none();
179    let due = app
180        .quota_last_auto_tick
181        .is_none_or(|last_tick| app.tick.saturating_sub(last_tick) >= QUOTA_REFRESH_INTERVAL_TICKS);
182
183    if target_changed || target_missing_state || due {
184        app.quota_auto_target_key = Some(target_key);
185        app.quota_last_auto_tick = Some(app.tick);
186        queue_quota_refresh(app, data, quota_req_tx, target, false);
187    }
188}
189
190fn queue_provider_quota_refresh(
191    app: &mut App,
192    data: &mut data::UiData,
193    quota_req_tx: Option<&mpsc::Sender<QuotaReq>>,
194    provider_id: &str,
195) {
196    let Some(row) = data.providers.rows.iter().find(|row| row.id == provider_id) else {
197        return;
198    };
199    let Some(target) = data::quota_target_for_provider(&app.app_type, row) else {
200        app.push_toast(texts::tui_toast_quota_not_available(), ToastKind::Info);
201        return;
202    };
203
204    queue_quota_refresh(app, data, quota_req_tx, target, true);
205}
206
207pub fn run(app_override: Option<AppType>) -> Result<(), AppError> {
208    let _panic_hook = PanicRestoreHookGuard::install();
209    let mut terminal = TuiTerminal::new()?;
210    let (mut app, mut data) = initialize_app_state_with(app_override, data::UiData::load)?;
211    let mut proxy_open_flash = ProxyOpenFlash::default();
212    app.reset_proxy_activity(
213        data.proxy.estimated_input_tokens_total,
214        data.proxy.estimated_output_tokens_total,
215    );
216    app.observe_proxy_visual_state(&data);
217
218    let tick_rate = TUI_TICK_RATE;
219    let mut last_tick = Instant::now();
220    let mut last_frame = Instant::now();
221    let mut proxy_loading = RequestTracker::default();
222    let mut webdav_loading = RequestTracker::default();
223    let mut update_check = RequestTracker::default();
224
225    let speedtest = match start_speedtest_system() {
226        Ok(system) => Some(system),
227        Err(err) => {
228            app.push_toast(
229                texts::tui_toast_speedtest_unavailable(&err.to_string()),
230                ToastKind::Warning,
231            );
232            None
233        }
234    };
235
236    let stream_check = match start_stream_check_system() {
237        Ok(system) => Some(system),
238        Err(err) => {
239            app.push_toast(
240                texts::tui_toast_stream_check_unavailable(&err.to_string()),
241                ToastKind::Warning,
242            );
243            None
244        }
245    };
246
247    let skills = match start_skills_system() {
248        Ok(system) => Some(system),
249        Err(err) => {
250            app.push_toast(
251                texts::tui_toast_skills_worker_unavailable(&err.to_string()),
252                ToastKind::Warning,
253            );
254            None
255        }
256    };
257
258    let local_env = match start_local_env_system() {
259        Ok(system) => {
260            if let Err(err) = system.req_tx.send(LocalEnvReq::Refresh) {
261                app.local_env_loading = false;
262                app.push_toast(
263                    texts::tui_toast_local_env_check_request_failed(&err.to_string()),
264                    ToastKind::Warning,
265                );
266            }
267            Some(system)
268        }
269        Err(err) => {
270            app.local_env_loading = false;
271            app.push_toast(
272                texts::tui_toast_local_env_check_unavailable(&err.to_string()),
273                ToastKind::Warning,
274            );
275            None
276        }
277    };
278
279    let proxy_system = match start_proxy_system() {
280        Ok(system) => Some(system),
281        Err(err) => {
282            app.push_toast(
283                texts::tui_toast_proxy_worker_unavailable(&err.to_string()),
284                ToastKind::Warning,
285            );
286            None
287        }
288    };
289
290    let quota = match start_quota_system() {
291        Ok(system) => Some(system),
292        Err(err) => {
293            app.push_toast(
294                texts::tui_toast_quota_worker_unavailable(&err.to_string()),
295                ToastKind::Warning,
296            );
297            None
298        }
299    };
300    queue_current_quota_refresh_if_due(&mut app, &mut data, quota.as_ref().map(|s| &s.req_tx));
301
302    let webdav = match start_webdav_system() {
303        Ok(system) => Some(system),
304        Err(err) => {
305            app.push_toast(
306                texts::tui_toast_webdav_worker_unavailable(&err.to_string()),
307                ToastKind::Warning,
308            );
309            None
310        }
311    };
312
313    let update_system = match start_update_system() {
314        Ok(system) => Some(system),
315        Err(err) => {
316            app.push_toast(
317                texts::tui_toast_update_check_failed(&err.to_string()),
318                ToastKind::Warning,
319            );
320            None
321        }
322    };
323
324    let model_fetch = match start_model_fetch_system() {
325        Ok(system) => Some(system),
326        Err(err) => {
327            app.push_toast(
328                texts::tui_toast_model_fetch_worker_unavailable(&err.to_string()),
329                ToastKind::Warning,
330            );
331            None
332        }
333    };
334
335    loop {
336        app.last_size = terminal.size()?;
337        app.observe_proxy_visual_state(&data);
338        let frame_dt = last_frame.elapsed();
339        last_frame = Instant::now();
340        terminal.draw(|f| {
341            let area = f.area();
342            proxy_open_flash.sync(&app, area);
343            ui::render(f, &app, &data);
344            proxy_open_flash.process(frame_dt, f.buffer_mut(), area);
345        })?;
346
347        if let Some(speedtest) = speedtest.as_ref() {
348            while let Ok(msg) = speedtest.result_rx.try_recv() {
349                handle_speedtest_msg(&mut app, msg);
350            }
351        }
352
353        if let Some(stream_check) = stream_check.as_ref() {
354            while let Ok(msg) = stream_check.result_rx.try_recv() {
355                handle_stream_check_msg(&mut app, msg);
356            }
357        }
358
359        if let Some(local_env) = local_env.as_ref() {
360            while let Ok(msg) = local_env.result_rx.try_recv() {
361                handle_local_env_msg(&mut app, msg);
362            }
363        }
364
365        if let Some(proxy) = proxy_system.as_ref() {
366            while let Ok(msg) = proxy.result_rx.try_recv() {
367                if let Err(err) = handle_proxy_msg(&mut app, &mut data, &mut proxy_loading, msg) {
368                    app.push_toast(err.to_string(), ToastKind::Error);
369                }
370            }
371        }
372
373        if let Some(quota) = quota.as_ref() {
374            while let Ok(msg) = quota.result_rx.try_recv() {
375                handle_quota_msg(&mut app, &mut data, msg);
376            }
377        }
378
379        if let Some(skills) = skills.as_ref() {
380            while let Ok(msg) = skills.result_rx.try_recv() {
381                if let Err(err) = handle_skills_msg(&mut app, &mut data, msg) {
382                    app.push_toast(err.to_string(), ToastKind::Error);
383                }
384            }
385        }
386
387        if let Some(webdav) = webdav.as_ref() {
388            while let Ok(msg) = webdav.result_rx.try_recv() {
389                if let Err(err) = handle_webdav_msg(&mut app, &mut data, &mut webdav_loading, msg) {
390                    app.push_toast(err.to_string(), ToastKind::Error);
391                }
392            }
393        }
394
395        if let Some(us) = update_system.as_ref() {
396            while let Ok(msg) = us.result_rx.try_recv() {
397                handle_update_msg(&mut app, &mut update_check, msg);
398            }
399        }
400
401        if let Some(mf) = model_fetch.as_ref() {
402            while let Ok(msg) = mf.result_rx.try_recv() {
403                handle_model_fetch_msg(&mut app, msg);
404            }
405        }
406
407        let timeout = tick_rate.saturating_sub(last_tick.elapsed());
408        if event::poll(timeout).map_err(|e| AppError::Message(e.to_string()))? {
409            match event::read().map_err(|e| AppError::Message(e.to_string()))? {
410                event::Event::Key(key) if key.kind == KeyEventKind::Press => {
411                    let key = normalize_key_event(key);
412                    let action = app.on_key(key, &data);
413                    if let Action::ProviderQuotaRefresh { id } = action {
414                        queue_provider_quota_refresh(
415                            &mut app,
416                            &mut data,
417                            quota.as_ref().map(|s| &s.req_tx),
418                            &id,
419                        );
420                    } else if let Err(err) = handle_action(
421                        &mut terminal,
422                        &mut app,
423                        &mut data,
424                        speedtest.as_ref().map(|s| &s.req_tx),
425                        stream_check.as_ref().map(|s| &s.req_tx),
426                        skills.as_ref().map(|s| &s.req_tx),
427                        proxy_system.as_ref().map(|s| &s.req_tx),
428                        &mut proxy_loading,
429                        local_env.as_ref().map(|s| &s.req_tx),
430                        webdav.as_ref().map(|s| &s.req_tx),
431                        &mut webdav_loading,
432                        update_system.as_ref().map(|s| &s.req_tx),
433                        &mut update_check,
434                        model_fetch.as_ref().map(|s| &s.req_tx),
435                        action,
436                    ) {
437                        if matches!(
438                            &err,
439                            AppError::Localized { key, .. } if *key == "tui_terminal_error"
440                        ) {
441                            return Err(err);
442                        }
443                        app.push_toast(err.to_string(), ToastKind::Error);
444                    }
445                }
446                event::Event::Mouse(mouse) => {
447                    if let MouseEventKind::ScrollUp | MouseEventKind::ScrollDown = mouse.kind {
448                        let code = if matches!(mouse.kind, MouseEventKind::ScrollUp) {
449                            event::KeyCode::Up
450                        } else {
451                            event::KeyCode::Down
452                        };
453                        let key = event::KeyEvent::new(code, event::KeyModifiers::NONE);
454                        let action = app.on_key(key, &data);
455                        if let Action::ProviderQuotaRefresh { id } = action {
456                            queue_provider_quota_refresh(
457                                &mut app,
458                                &mut data,
459                                quota.as_ref().map(|s| &s.req_tx),
460                                &id,
461                            );
462                        } else if let Err(err) = handle_action(
463                            &mut terminal,
464                            &mut app,
465                            &mut data,
466                            speedtest.as_ref().map(|s| &s.req_tx),
467                            stream_check.as_ref().map(|s| &s.req_tx),
468                            skills.as_ref().map(|s| &s.req_tx),
469                            proxy_system.as_ref().map(|s| &s.req_tx),
470                            &mut proxy_loading,
471                            local_env.as_ref().map(|s| &s.req_tx),
472                            webdav.as_ref().map(|s| &s.req_tx),
473                            &mut webdav_loading,
474                            update_system.as_ref().map(|s| &s.req_tx),
475                            &mut update_check,
476                            model_fetch.as_ref().map(|s| &s.req_tx),
477                            action,
478                        ) {
479                            if matches!(
480                                &err,
481                                AppError::Localized { key, .. } if *key == "tui_terminal_error"
482                            ) {
483                                return Err(err);
484                            }
485                            app.push_toast(err.to_string(), ToastKind::Error);
486                        }
487                    }
488                }
489                event::Event::Resize(_, _) => {}
490                _ => {}
491            }
492        }
493
494        if last_tick.elapsed() >= tick_rate {
495            app.on_tick();
496            if app.should_poll_proxy_activity() {
497                if let Err(err) = data.refresh_proxy_snapshot(&app.app_type) {
498                    log::debug!("refresh proxy snapshot failed: {err}");
499                } else {
500                    app.observe_proxy_token_activity(
501                        data.proxy.estimated_input_tokens_total,
502                        data.proxy.estimated_output_tokens_total,
503                    );
504                }
505            }
506            queue_current_quota_refresh_if_due(
507                &mut app,
508                &mut data,
509                quota.as_ref().map(|s| &s.req_tx),
510            );
511            last_tick = Instant::now();
512        }
513
514        if app.should_quit {
515            break;
516        }
517    }
518
519    Ok(())
520}
521
522fn normalize_key_event(mut key: KeyEvent) -> KeyEvent {
523    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('h') {
524        key.code = KeyCode::Backspace;
525        key.modifiers.remove(KeyModifiers::CONTROL);
526    }
527    key
528}