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