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}