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}