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