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}