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