1use super::app::{CONFIG_FIELDS, CONFIG_GLOBAL_FIELDS, ChatApp, ChatMode, config_total_fields};
2use super::model::{ModelProvider, save_agent_config, save_chat_session};
3use super::render::copy_to_clipboard;
4use super::theme::ThemeName;
5use super::ui::draw_chat_ui;
6use crate::{error, info};
7use crossterm::{
8 event::{
9 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
10 MouseEventKind,
11 },
12 execute,
13 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
14};
15use ratatui::{Terminal, backend::CrosstermBackend};
16use std::io;
17
18pub fn run_chat_tui() {
19 match run_chat_tui_internal() {
20 Ok(_) => {}
21 Err(e) => {
22 error!("❌ Chat TUI 启动失败: {}", e);
23 }
24 }
25}
26
27pub fn run_chat_tui_internal() -> io::Result<()> {
28 terminal::enable_raw_mode()?;
29 let mut stdout = io::stdout();
30 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
31
32 let backend = CrosstermBackend::new(stdout);
33 let mut terminal = Terminal::new(backend)?;
34
35 let mut app = ChatApp::new();
36
37 if app.agent_config.providers.is_empty() {
38 terminal::disable_raw_mode()?;
39 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
40 info!("⚠️ 尚未配置 LLM 模型提供方,请先运行 j chat 查看配置说明。");
41 return Ok(());
42 }
43
44 let mut needs_redraw = true; loop {
47 let had_toast = app.toast.is_some();
49 app.tick_toast();
50 if had_toast && app.toast.is_none() {
51 needs_redraw = true;
52 }
53
54 let was_loading = app.is_loading;
56 app.poll_stream();
57 if app.is_loading {
59 let current_len = app.streaming_content.lock().unwrap().len();
60 let bytes_delta = current_len.saturating_sub(app.last_rendered_streaming_len);
61 let time_elapsed = app.last_stream_render_time.elapsed();
62 if bytes_delta >= 200
64 || time_elapsed >= std::time::Duration::from_millis(200)
65 || current_len == 0
66 {
67 needs_redraw = true;
68 }
69 } else if was_loading {
70 needs_redraw = true;
72 }
73
74 if needs_redraw {
76 terminal.draw(|f| draw_chat_ui(f, &mut app))?;
77 needs_redraw = false;
78 if app.is_loading {
80 app.last_rendered_streaming_len = app.streaming_content.lock().unwrap().len();
81 app.last_stream_render_time = std::time::Instant::now();
82 }
83 }
84
85 let poll_timeout = if app.is_loading {
87 std::time::Duration::from_millis(150)
88 } else {
89 std::time::Duration::from_millis(1000)
90 };
91
92 if event::poll(poll_timeout)? {
93 let mut should_break = false;
95 loop {
96 let evt = event::read()?;
97 match evt {
98 Event::Key(key) => {
99 needs_redraw = true;
100 match app.mode {
101 ChatMode::Chat => {
102 if handle_chat_mode(&mut app, key) {
103 should_break = true;
104 break;
105 }
106 }
107 ChatMode::SelectModel => handle_select_model(&mut app, key),
108 ChatMode::Browse => handle_browse_mode(&mut app, key),
109 ChatMode::Help => {
110 app.mode = ChatMode::Chat;
111 }
112 ChatMode::Config => handle_config_mode(&mut app, key),
113 }
114 }
115 Event::Mouse(mouse) => match mouse.kind {
116 MouseEventKind::ScrollUp => {
117 app.scroll_up();
118 needs_redraw = true;
119 }
120 MouseEventKind::ScrollDown => {
121 app.scroll_down();
122 needs_redraw = true;
123 }
124 _ => {}
125 },
126 Event::Resize(_, _) => {
127 needs_redraw = true;
128 }
129 _ => {}
130 }
131 if !event::poll(std::time::Duration::ZERO)? {
133 break;
134 }
135 }
136 if should_break {
137 break;
138 }
139 }
140 }
141
142 let _ = save_chat_session(&app.session);
144
145 terminal::disable_raw_mode()?;
146 execute!(
147 terminal.backend_mut(),
148 LeaveAlternateScreen,
149 DisableMouseCapture
150 )?;
151 Ok(())
152}
153
154pub fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
157 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
159 return true;
160 }
161
162 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') {
164 if !app.agent_config.providers.is_empty() {
165 app.mode = ChatMode::SelectModel;
166 app.model_list_state
167 .select(Some(app.agent_config.active_index));
168 }
169 return false;
170 }
171
172 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
174 app.clear_session();
175 return false;
176 }
177
178 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('y') {
180 if let Some(last_ai) = app
181 .session
182 .messages
183 .iter()
184 .rev()
185 .find(|m| m.role == "assistant")
186 {
187 if copy_to_clipboard(&last_ai.content) {
188 app.show_toast("已复制最后一条 AI 回复", false);
189 } else {
190 app.show_toast("复制到剪切板失败", true);
191 }
192 } else {
193 app.show_toast("暂无 AI 回复可复制", true);
194 }
195 return false;
196 }
197
198 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
200 if !app.session.messages.is_empty() {
201 app.browse_msg_index = app.session.messages.len() - 1;
203 app.mode = ChatMode::Browse;
204 app.msg_lines_cache = None; } else {
206 app.show_toast("暂无消息可浏览", true);
207 }
208 return false;
209 }
210
211 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
213 app.config_provider_idx = app
215 .agent_config
216 .active_index
217 .min(app.agent_config.providers.len().saturating_sub(1));
218 app.config_field_idx = 0;
219 app.config_editing = false;
220 app.config_edit_buf.clear();
221 app.mode = ChatMode::Config;
222 return false;
223 }
224
225 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
227 app.agent_config.stream_mode = !app.agent_config.stream_mode;
228 let _ = save_agent_config(&app.agent_config);
229 let mode_str = if app.agent_config.stream_mode {
230 "流式输出"
231 } else {
232 "整体输出"
233 };
234 app.show_toast(&format!("已切换为: {}", mode_str), false);
235 return false;
236 }
237
238 let char_count = app.input.chars().count();
239
240 match key.code {
241 KeyCode::Esc => return true,
242
243 KeyCode::Enter => {
244 if !app.is_loading {
245 app.send_message();
246 }
247 }
248
249 KeyCode::Up => app.scroll_up(),
251 KeyCode::Down => app.scroll_down(),
252 KeyCode::PageUp => {
253 for _ in 0..10 {
254 app.scroll_up();
255 }
256 }
257 KeyCode::PageDown => {
258 for _ in 0..10 {
259 app.scroll_down();
260 }
261 }
262
263 KeyCode::Left => {
265 if app.cursor_pos > 0 {
266 app.cursor_pos -= 1;
267 }
268 }
269 KeyCode::Right => {
270 if app.cursor_pos < char_count {
271 app.cursor_pos += 1;
272 }
273 }
274 KeyCode::Home => app.cursor_pos = 0,
275 KeyCode::End => app.cursor_pos = char_count,
276
277 KeyCode::Backspace => {
279 if app.cursor_pos > 0 {
280 let start = app
281 .input
282 .char_indices()
283 .nth(app.cursor_pos - 1)
284 .map(|(i, _)| i)
285 .unwrap_or(0);
286 let end = app
287 .input
288 .char_indices()
289 .nth(app.cursor_pos)
290 .map(|(i, _)| i)
291 .unwrap_or(app.input.len());
292 app.input.drain(start..end);
293 app.cursor_pos -= 1;
294 }
295 }
296 KeyCode::Delete => {
297 if app.cursor_pos < char_count {
298 let start = app
299 .input
300 .char_indices()
301 .nth(app.cursor_pos)
302 .map(|(i, _)| i)
303 .unwrap_or(app.input.len());
304 let end = app
305 .input
306 .char_indices()
307 .nth(app.cursor_pos + 1)
308 .map(|(i, _)| i)
309 .unwrap_or(app.input.len());
310 app.input.drain(start..end);
311 }
312 }
313
314 KeyCode::F(1) => {
316 app.mode = ChatMode::Help;
317 }
318 KeyCode::Char('?') if app.input.is_empty() => {
320 app.mode = ChatMode::Help;
321 }
322 KeyCode::Char(c) => {
323 let byte_idx = app
324 .input
325 .char_indices()
326 .nth(app.cursor_pos)
327 .map(|(i, _)| i)
328 .unwrap_or(app.input.len());
329 app.input.insert_str(byte_idx, &c.to_string());
330 app.cursor_pos += 1;
331 }
332
333 _ => {}
334 }
335
336 false
337}
338
339pub fn handle_browse_mode(app: &mut ChatApp, key: KeyEvent) {
341 let msg_count = app.session.messages.len();
342 if msg_count == 0 {
343 app.mode = ChatMode::Chat;
344 app.msg_lines_cache = None;
345 return;
346 }
347
348 match key.code {
349 KeyCode::Esc => {
350 app.mode = ChatMode::Chat;
351 app.msg_lines_cache = None; }
353 KeyCode::Up | KeyCode::Char('k') => {
354 if app.browse_msg_index > 0 {
355 app.browse_msg_index -= 1;
356 app.msg_lines_cache = None; }
358 }
359 KeyCode::Down | KeyCode::Char('j') => {
360 if app.browse_msg_index < msg_count - 1 {
361 app.browse_msg_index += 1;
362 app.msg_lines_cache = None; }
364 }
365 KeyCode::Enter | KeyCode::Char('y') => {
366 if let Some(msg) = app.session.messages.get(app.browse_msg_index) {
368 let content = msg.content.clone();
369 let role_label = if msg.role == "assistant" {
370 "AI"
371 } else if msg.role == "user" {
372 "用户"
373 } else {
374 "系统"
375 };
376 if copy_to_clipboard(&content) {
377 app.show_toast(
378 &format!("已复制第 {} 条{}消息", app.browse_msg_index + 1, role_label),
379 false,
380 );
381 } else {
382 app.show_toast("复制到剪切板失败", true);
383 }
384 }
385 }
386 _ => {}
387 }
388}
389
390pub fn config_field_label(idx: usize) -> &'static str {
392 let total_provider = CONFIG_FIELDS.len();
393 if idx < total_provider {
394 match CONFIG_FIELDS[idx] {
395 "name" => "显示名称",
396 "api_base" => "API Base",
397 "api_key" => "API Key",
398 "model" => "模型名称",
399 _ => CONFIG_FIELDS[idx],
400 }
401 } else {
402 let gi = idx - total_provider;
403 match CONFIG_GLOBAL_FIELDS[gi] {
404 "system_prompt" => "系统提示词",
405 "stream_mode" => "流式输出",
406 "max_history_messages" => "历史消息数",
407 "theme" => "主题风格",
408 _ => CONFIG_GLOBAL_FIELDS[gi],
409 }
410 }
411}
412
413pub fn config_field_value(app: &ChatApp, field_idx: usize) -> String {
415 let total_provider = CONFIG_FIELDS.len();
416 if field_idx < total_provider {
417 if app.agent_config.providers.is_empty() {
418 return String::new();
419 }
420 let p = &app.agent_config.providers[app.config_provider_idx];
421 match CONFIG_FIELDS[field_idx] {
422 "name" => p.name.clone(),
423 "api_base" => p.api_base.clone(),
424 "api_key" => {
425 if p.api_key.len() > 8 {
427 format!(
428 "{}****{}",
429 &p.api_key[..4],
430 &p.api_key[p.api_key.len() - 4..]
431 )
432 } else {
433 p.api_key.clone()
434 }
435 }
436 "model" => p.model.clone(),
437 _ => String::new(),
438 }
439 } else {
440 let gi = field_idx - total_provider;
441 match CONFIG_GLOBAL_FIELDS[gi] {
442 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
443 "stream_mode" => {
444 if app.agent_config.stream_mode {
445 "开启".into()
446 } else {
447 "关闭".into()
448 }
449 }
450 "max_history_messages" => app.agent_config.max_history_messages.to_string(),
451 "theme" => app.agent_config.theme.display_name().to_string(),
452 _ => String::new(),
453 }
454 }
455}
456
457pub fn config_field_raw_value(app: &ChatApp, field_idx: usize) -> String {
459 let total_provider = CONFIG_FIELDS.len();
460 if field_idx < total_provider {
461 if app.agent_config.providers.is_empty() {
462 return String::new();
463 }
464 let p = &app.agent_config.providers[app.config_provider_idx];
465 match CONFIG_FIELDS[field_idx] {
466 "name" => p.name.clone(),
467 "api_base" => p.api_base.clone(),
468 "api_key" => p.api_key.clone(),
469 "model" => p.model.clone(),
470 _ => String::new(),
471 }
472 } else {
473 let gi = field_idx - total_provider;
474 match CONFIG_GLOBAL_FIELDS[gi] {
475 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
476 "stream_mode" => {
477 if app.agent_config.stream_mode {
478 "true".into()
479 } else {
480 "false".into()
481 }
482 }
483 "theme" => app.agent_config.theme.to_str().to_string(),
484 _ => String::new(),
485 }
486 }
487}
488
489pub fn config_field_set(app: &mut ChatApp, field_idx: usize, value: &str) {
491 let total_provider = CONFIG_FIELDS.len();
492 if field_idx < total_provider {
493 if app.agent_config.providers.is_empty() {
494 return;
495 }
496 let p = &mut app.agent_config.providers[app.config_provider_idx];
497 match CONFIG_FIELDS[field_idx] {
498 "name" => p.name = value.to_string(),
499 "api_base" => p.api_base = value.to_string(),
500 "api_key" => p.api_key = value.to_string(),
501 "model" => p.model = value.to_string(),
502 _ => {}
503 }
504 } else {
505 let gi = field_idx - total_provider;
506 match CONFIG_GLOBAL_FIELDS[gi] {
507 "system_prompt" => {
508 if value.is_empty() {
509 app.agent_config.system_prompt = None;
510 } else {
511 app.agent_config.system_prompt = Some(value.to_string());
512 }
513 }
514 "stream_mode" => {
515 app.agent_config.stream_mode = matches!(
516 value.trim().to_lowercase().as_str(),
517 "true" | "1" | "开启" | "on" | "yes"
518 );
519 }
520 "max_history_messages" => {
521 if let Ok(num) = value.trim().parse::<usize>() {
522 app.agent_config.max_history_messages = num;
523 }
524 }
525 "theme" => {
526 app.agent_config.theme = ThemeName::from_str(value.trim());
527 app.theme = super::theme::Theme::from_name(&app.agent_config.theme);
528 app.msg_lines_cache = None;
529 }
530 _ => {}
531 }
532 }
533}
534
535pub fn handle_config_mode(app: &mut ChatApp, key: KeyEvent) {
537 let total_fields = config_total_fields();
538
539 if app.config_editing {
540 match key.code {
542 KeyCode::Esc => {
543 app.config_editing = false;
545 }
546 KeyCode::Enter => {
547 let val = app.config_edit_buf.clone();
549 config_field_set(app, app.config_field_idx, &val);
550 app.config_editing = false;
551 }
552 KeyCode::Backspace => {
553 if app.config_edit_cursor > 0 {
554 let idx = app
555 .config_edit_buf
556 .char_indices()
557 .nth(app.config_edit_cursor - 1)
558 .map(|(i, _)| i)
559 .unwrap_or(0);
560 let end_idx = app
561 .config_edit_buf
562 .char_indices()
563 .nth(app.config_edit_cursor)
564 .map(|(i, _)| i)
565 .unwrap_or(app.config_edit_buf.len());
566 app.config_edit_buf = format!(
567 "{}{}",
568 &app.config_edit_buf[..idx],
569 &app.config_edit_buf[end_idx..]
570 );
571 app.config_edit_cursor -= 1;
572 }
573 }
574 KeyCode::Left => {
575 app.config_edit_cursor = app.config_edit_cursor.saturating_sub(1);
576 }
577 KeyCode::Right => {
578 let char_count = app.config_edit_buf.chars().count();
579 if app.config_edit_cursor < char_count {
580 app.config_edit_cursor += 1;
581 }
582 }
583 KeyCode::Char(c) => {
584 let byte_idx = app
585 .config_edit_buf
586 .char_indices()
587 .nth(app.config_edit_cursor)
588 .map(|(i, _)| i)
589 .unwrap_or(app.config_edit_buf.len());
590 app.config_edit_buf.insert(byte_idx, c);
591 app.config_edit_cursor += 1;
592 }
593 _ => {}
594 }
595 return;
596 }
597
598 match key.code {
600 KeyCode::Esc => {
601 let _ = save_agent_config(&app.agent_config);
603 app.show_toast("配置已保存 ✅", false);
604 app.mode = ChatMode::Chat;
605 }
606 KeyCode::Up | KeyCode::Char('k') => {
607 if total_fields > 0 {
608 if app.config_field_idx == 0 {
609 app.config_field_idx = total_fields - 1;
610 } else {
611 app.config_field_idx -= 1;
612 }
613 }
614 }
615 KeyCode::Down | KeyCode::Char('j') => {
616 if total_fields > 0 {
617 app.config_field_idx = (app.config_field_idx + 1) % total_fields;
618 }
619 }
620 KeyCode::Tab | KeyCode::Right => {
621 let count = app.agent_config.providers.len();
623 if count > 1 {
624 app.config_provider_idx = (app.config_provider_idx + 1) % count;
625 }
627 }
628 KeyCode::BackTab | KeyCode::Left => {
629 let count = app.agent_config.providers.len();
631 if count > 1 {
632 if app.config_provider_idx == 0 {
633 app.config_provider_idx = count - 1;
634 } else {
635 app.config_provider_idx -= 1;
636 }
637 }
638 }
639 KeyCode::Enter => {
640 let total_provider = CONFIG_FIELDS.len();
642 if app.config_field_idx < total_provider && app.agent_config.providers.is_empty() {
643 app.show_toast("还没有 Provider,按 a 新增", true);
644 return;
645 }
646 let gi = app.config_field_idx.checked_sub(total_provider);
648 if let Some(gi) = gi {
649 if CONFIG_GLOBAL_FIELDS[gi] == "stream_mode" {
650 app.agent_config.stream_mode = !app.agent_config.stream_mode;
651 return;
652 }
653 if CONFIG_GLOBAL_FIELDS[gi] == "theme" {
655 app.switch_theme();
656 return;
657 }
658 }
659 app.config_edit_buf = config_field_raw_value(app, app.config_field_idx);
660 app.config_edit_cursor = app.config_edit_buf.chars().count();
661 app.config_editing = true;
662 }
663 KeyCode::Char('a') => {
664 let new_provider = ModelProvider {
666 name: format!("Provider-{}", app.agent_config.providers.len() + 1),
667 api_base: "https://api.openai.com/v1".to_string(),
668 api_key: String::new(),
669 model: String::new(),
670 };
671 app.agent_config.providers.push(new_provider);
672 app.config_provider_idx = app.agent_config.providers.len() - 1;
673 app.config_field_idx = 0; app.show_toast("已新增 Provider,请填写配置", false);
675 }
676 KeyCode::Char('d') => {
677 let count = app.agent_config.providers.len();
679 if count == 0 {
680 app.show_toast("没有可删除的 Provider", true);
681 } else {
682 let removed_name = app.agent_config.providers[app.config_provider_idx]
683 .name
684 .clone();
685 app.agent_config.providers.remove(app.config_provider_idx);
686 if app.config_provider_idx >= app.agent_config.providers.len()
688 && app.config_provider_idx > 0
689 {
690 app.config_provider_idx -= 1;
691 }
692 if app.agent_config.active_index >= app.agent_config.providers.len()
694 && app.agent_config.active_index > 0
695 {
696 app.agent_config.active_index -= 1;
697 }
698 app.show_toast(format!("已删除 Provider: {}", removed_name), false);
699 }
700 }
701 KeyCode::Char('s') => {
702 if !app.agent_config.providers.is_empty() {
704 app.agent_config.active_index = app.config_provider_idx;
705 let name = app.agent_config.providers[app.config_provider_idx]
706 .name
707 .clone();
708 app.show_toast(format!("已设为活跃模型: {}", name), false);
709 }
710 }
711 _ => {}
712 }
713}
714
715pub fn handle_select_model(app: &mut ChatApp, key: KeyEvent) {
718 let count = app.agent_config.providers.len();
719 match key.code {
720 KeyCode::Esc => {
721 app.mode = ChatMode::Chat;
722 }
723 KeyCode::Up | KeyCode::Char('k') => {
724 if count > 0 {
725 let i = app
726 .model_list_state
727 .selected()
728 .map(|i| if i == 0 { count - 1 } else { i - 1 })
729 .unwrap_or(0);
730 app.model_list_state.select(Some(i));
731 }
732 }
733 KeyCode::Down | KeyCode::Char('j') => {
734 if count > 0 {
735 let i = app
736 .model_list_state
737 .selected()
738 .map(|i| if i >= count - 1 { 0 } else { i + 1 })
739 .unwrap_or(0);
740 app.model_list_state.select(Some(i));
741 }
742 }
743 KeyCode::Enter => {
744 app.switch_model();
745 }
746 _ => {}
747 }
748}