Skip to main content

j_cli/command/chat/
handler.rs

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; // 首次必须绘制
44
45    loop {
46        // 清理过期 toast(如果有 toast 被清理,需要重绘)
47        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        // 非阻塞地处理后台流式消息
54        let was_loading = app.is_loading;
55        app.poll_stream();
56        // 流式加载中使用节流策略:只在内容增长超过阈值或超时才重绘
57        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            // 每增加 200 字节或距离上次渲染超过 200ms 才重绘
62            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            // 加载刚结束时必须重绘一次
70            needs_redraw = true;
71        }
72
73        // 只在状态发生变化时才重绘,大幅降低 CPU 占用
74        if needs_redraw {
75            terminal.draw(|f| draw_chat_ui(f, &mut app))?;
76            needs_redraw = false;
77            // 更新流式节流状态
78            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        // 等待事件:加载中用短间隔以刷新流式内容,空闲时用长间隔节省 CPU
85        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            // 批量消费所有待处理事件,避免快速滚动/打字时事件堆积
93            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                // 继续消费剩余事件(非阻塞,Duration::ZERO)
131                if !event::poll(std::time::Duration::ZERO)? {
132                    break;
133                }
134            }
135            if should_break {
136                break;
137            }
138        }
139    }
140
141    // 保存对话历史
142    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
153/// 绘制 TUI 界面
154
155pub fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
156    // Ctrl+C 强制退出
157    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
158        return true;
159    }
160
161    // Ctrl+T 切换模型(替代 Ctrl+M,因为 Ctrl+M 在终端中等于 Enter)
162    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    // Ctrl+L 清空对话
172    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
173        app.clear_session();
174        return false;
175    }
176
177    // Ctrl+Y 复制最后一条 AI 回复
178    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    // Ctrl+B 进入消息浏览模式(可选中历史消息并复制)
198    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
199        if !app.session.messages.is_empty() {
200            // 默认选中最后一条消息
201            app.browse_msg_index = app.session.messages.len() - 1;
202            app.mode = ChatMode::Browse;
203            app.msg_lines_cache = None; // 清除缓存以触发高亮重绘
204        } else {
205            app.show_toast("暂无消息可浏览", true);
206        }
207        return false;
208    }
209
210    // Ctrl+E 打开配置界面
211    if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
212        // 初始化配置界面状态
213        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    // Ctrl+S 切换流式/非流式输出
225    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        // 滚动消息
249        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        // 光标移动
263        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        // 删除
277        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        // F1 任何时候都能唤起帮助
314        KeyCode::F(1) => {
315            app.mode = ChatMode::Help;
316        }
317        // 输入框为空时,? 也可唤起帮助
318        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
338/// 消息浏览模式按键处理:↑↓ 选择消息,y/Enter 复制选中消息,Esc 退出
339pub 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; // 退出浏览模式时清除缓存,去掉高亮
351        }
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; // 选中变化时清缓存
356            }
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; // 选中变化时清缓存
362            }
363        }
364        KeyCode::Enter | KeyCode::Char('y') => {
365            // 复制选中消息的原始内容到剪切板
366            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
389/// 获取配置界面中当前字段的标签
390pub 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
411/// 获取配置界面中当前字段的值
412pub 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                // 显示时隐藏 API Key 中间部分
424                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
454/// 获取配置字段的原始值(用于编辑时填入输入框)
455pub 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
485/// 将编辑结果写回配置
486pub 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
526/// 配置模式按键处理
527pub fn handle_config_mode(app: &mut ChatApp, key: KeyEvent) {
528    let total_fields = config_total_fields();
529
530    if app.config_editing {
531        // 正在编辑某个字段
532        match key.code {
533            KeyCode::Esc => {
534                // 取消编辑
535                app.config_editing = false;
536            }
537            KeyCode::Enter => {
538                // 确认编辑
539                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    // 非编辑状态
590    match key.code {
591        KeyCode::Esc => {
592            // 保存并返回
593            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            // 切换 provider
613            let count = app.agent_config.providers.len();
614            if count > 1 {
615                app.config_provider_idx = (app.config_provider_idx + 1) % count;
616                // 切换后如果在 provider 字段区域,保持字段位置不变
617            }
618        }
619        KeyCode::BackTab | KeyCode::Left => {
620            // 反向切换 provider
621            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            // 进入编辑模式
632            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            // stream_mode 字段直接切换,不进入编辑模式
638            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            // 新增 Provider
651            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; // 跳到 name 字段
660            app.show_toast("已新增 Provider,请填写配置", false);
661        }
662        KeyCode::Char('d') => {
663            // 删除当前 Provider
664            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                // 调整索引
673                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                // 调整 active_index
679                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            // 将当前 provider 设为活跃
689            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
701/// 绘制配置编辑界面
702
703pub 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}