Skip to main content

j_cli/command/chat/
ui.rs

1use super::app::{CONFIG_FIELDS, CONFIG_GLOBAL_FIELDS, ChatApp, ChatMode, MsgLinesCache};
2use super::handler::{config_field_label, config_field_value};
3use super::model::agent_config_path;
4use super::render::{build_message_lines_incremental, char_width, display_width, wrap_text};
5use ratatui::{
6    layout::{Constraint, Direction, Layout, Rect},
7    style::{Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, List, ListItem, Paragraph},
10};
11
12pub fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
13    let size = f.area();
14
15    // 整体背景
16    let bg = Block::default().style(Style::default().bg(app.theme.bg_primary));
17    f.render_widget(bg, size);
18
19    let chunks = Layout::default()
20        .direction(Direction::Vertical)
21        .constraints([
22            Constraint::Length(3), // 标题栏
23            Constraint::Min(5),    // 消息区
24            Constraint::Length(5), // 输入区
25            Constraint::Length(1), // 操作提示栏(始终可见)
26        ])
27        .split(size);
28
29    // ========== 标题栏 ==========
30    draw_title_bar(f, chunks[0], app);
31
32    // ========== 消息区 ==========
33    if app.mode == ChatMode::Help {
34        draw_help(f, chunks[1], app);
35    } else if app.mode == ChatMode::SelectModel {
36        draw_model_selector(f, chunks[1], app);
37    } else if app.mode == ChatMode::Config {
38        draw_config_screen(f, chunks[1], app);
39    } else {
40        draw_messages(f, chunks[1], app);
41    }
42
43    // ========== 输入区 ==========
44    draw_input(f, chunks[2], app);
45
46    // ========== 底部操作提示栏(始终可见)==========
47    draw_hint_bar(f, chunks[3], app);
48
49    // ========== Toast 弹窗覆盖层(右上角)==========
50    draw_toast(f, size, app);
51}
52
53/// 绘制标题栏
54pub fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
55    let t = &app.theme;
56    let model_name = app.active_model_name();
57    let msg_count = app.session.messages.len();
58    let loading = if app.is_loading {
59        " ⏳ 思考中..."
60    } else {
61        ""
62    };
63
64    let title_spans = vec![
65        Span::styled(" 💬 ", Style::default().fg(t.title_icon)),
66        Span::styled(
67            "AI Chat",
68            Style::default()
69                .fg(t.text_white)
70                .add_modifier(Modifier::BOLD),
71        ),
72        Span::styled("  │  ", Style::default().fg(t.title_separator)),
73        Span::styled("🤖 ", Style::default()),
74        Span::styled(
75            model_name,
76            Style::default()
77                .fg(t.title_model)
78                .add_modifier(Modifier::BOLD),
79        ),
80        Span::styled("  │  ", Style::default().fg(t.title_separator)),
81        Span::styled(
82            format!("📨 {} 条消息", msg_count),
83            Style::default().fg(t.title_count),
84        ),
85        Span::styled(
86            loading,
87            Style::default()
88                .fg(t.title_loading)
89                .add_modifier(Modifier::BOLD),
90        ),
91    ];
92
93    let title_block = Paragraph::new(Line::from(title_spans)).block(
94        Block::default()
95            .borders(Borders::ALL)
96            .border_type(ratatui::widgets::BorderType::Rounded)
97            .border_style(Style::default().fg(t.border_title))
98            .style(Style::default().bg(t.bg_title)),
99    );
100    f.render_widget(title_block, area);
101}
102
103/// 绘制消息区
104pub fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
105    let t = &app.theme;
106    let block = Block::default()
107        .borders(Borders::ALL)
108        .border_type(ratatui::widgets::BorderType::Rounded)
109        .border_style(Style::default().fg(t.border_message))
110        .title(Span::styled(
111            " 对话记录 ",
112            Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD),
113        ))
114        .title_alignment(ratatui::layout::Alignment::Left)
115        .style(Style::default().bg(t.bg_primary));
116
117    // 空消息时显示欢迎界面
118    if app.session.messages.is_empty() && !app.is_loading {
119        let welcome_lines = vec![
120            Line::from(""),
121            Line::from(""),
122            Line::from(Span::styled(
123                "  ╭──────────────────────────────────────╮",
124                Style::default().fg(t.welcome_border),
125            )),
126            Line::from(Span::styled(
127                "  │                                      │",
128                Style::default().fg(t.welcome_border),
129            )),
130            Line::from(vec![
131                Span::styled("  │     ", Style::default().fg(t.welcome_border)),
132                Span::styled(
133                    "Hi! What can I help you?  ",
134                    Style::default().fg(t.welcome_text),
135                ),
136                Span::styled("     │", Style::default().fg(t.welcome_border)),
137            ]),
138            Line::from(Span::styled(
139                "  │                                      │",
140                Style::default().fg(t.welcome_border),
141            )),
142            Line::from(Span::styled(
143                "  │     Type a message, press Enter      │",
144                Style::default().fg(t.welcome_hint),
145            )),
146            Line::from(Span::styled(
147                "  │                                      │",
148                Style::default().fg(t.welcome_border),
149            )),
150            Line::from(Span::styled(
151                "  ╰──────────────────────────────────────╯",
152                Style::default().fg(t.welcome_border),
153            )),
154        ];
155        let empty = Paragraph::new(welcome_lines).block(block);
156        f.render_widget(empty, area);
157        return;
158    }
159
160    // 内部可用宽度(减去边框和左右各1的 padding)
161    let inner_width = area.width.saturating_sub(4) as usize;
162    // 消息内容最大宽度为可用宽度的 75%
163    let bubble_max_width = (inner_width * 75 / 100).max(20);
164
165    // 计算缓存 key:消息数 + 最后一条消息长度 + 流式内容长度 + is_loading + 气泡宽度 + 浏览模式索引
166    let msg_count = app.session.messages.len();
167    let last_msg_len = app
168        .session
169        .messages
170        .last()
171        .map(|m| m.content.len())
172        .unwrap_or(0);
173    let streaming_len = app.streaming_content.lock().unwrap().len();
174    let current_browse_index = if app.mode == ChatMode::Browse {
175        Some(app.browse_msg_index)
176    } else {
177        None
178    };
179    let cache_hit = if let Some(ref cache) = app.msg_lines_cache {
180        cache.msg_count == msg_count
181            && cache.last_msg_len == last_msg_len
182            && cache.streaming_len == streaming_len
183            && cache.is_loading == app.is_loading
184            && cache.bubble_max_width == bubble_max_width
185            && cache.browse_index == current_browse_index
186    } else {
187        false
188    };
189
190    if !cache_hit {
191        // 缓存未命中,增量构建渲染行
192        let old_cache = app.msg_lines_cache.take();
193        let (new_lines, new_msg_start_lines, new_per_msg, new_stable_lines, new_stable_offset) =
194            build_message_lines_incremental(app, inner_width, bubble_max_width, old_cache.as_ref());
195        app.msg_lines_cache = Some(MsgLinesCache {
196            msg_count,
197            last_msg_len,
198            streaming_len,
199            is_loading: app.is_loading,
200            bubble_max_width,
201            browse_index: current_browse_index,
202            lines: new_lines,
203            msg_start_lines: new_msg_start_lines,
204            per_msg_lines: new_per_msg,
205            streaming_stable_lines: new_stable_lines,
206            streaming_stable_offset: new_stable_offset,
207        });
208    }
209
210    // 从缓存中借用 lines(零拷贝)
211    let cached = app.msg_lines_cache.as_ref().unwrap();
212    let all_lines = &cached.lines;
213    let total_lines = all_lines.len() as u16;
214
215    // 渲染边框
216    f.render_widget(block, area);
217
218    // 计算内部区域(去掉边框)
219    let inner = area.inner(ratatui::layout::Margin {
220        vertical: 1,
221        horizontal: 1,
222    });
223    let visible_height = inner.height;
224    let max_scroll = total_lines.saturating_sub(visible_height);
225
226    // 自动滚动到底部(非浏览模式下)
227    if app.mode != ChatMode::Browse {
228        if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
229            app.scroll_offset = max_scroll;
230            // 已经在底部,恢复自动滚动
231            app.auto_scroll = true;
232        }
233    } else {
234        // 浏览模式:自动滚动到选中消息的位置
235        if let Some(target_line) = cached
236            .msg_start_lines
237            .iter()
238            .find(|(idx, _)| *idx == app.browse_msg_index)
239            .map(|(_, line)| *line as u16)
240        {
241            // 确保选中消息在可视区域内
242            if target_line < app.scroll_offset {
243                app.scroll_offset = target_line;
244            } else if target_line >= app.scroll_offset + visible_height {
245                app.scroll_offset = target_line.saturating_sub(visible_height / 3);
246            }
247            // 限制滚动范围
248            if app.scroll_offset > max_scroll {
249                app.scroll_offset = max_scroll;
250            }
251        }
252    }
253
254    // 填充内部背景色(避免空白行没有背景)
255    let bg_fill = Block::default().style(Style::default().bg(app.theme.bg_primary));
256    f.render_widget(bg_fill, inner);
257
258    // 只渲染可见区域的行(逐行借用缓存,clone 单行开销极小)
259    let start = app.scroll_offset as usize;
260    let end = (start + visible_height as usize).min(all_lines.len());
261    let msg_area_bg = Style::default().bg(app.theme.bg_primary);
262    for (i, line_idx) in (start..end).enumerate() {
263        let line = &all_lines[line_idx];
264        let y = inner.y + i as u16;
265        let line_area = Rect::new(inner.x, y, inner.width, 1);
266        // 使用 Paragraph 渲染单行,设置背景色确保行尾空余区域颜色一致
267        let p = Paragraph::new(line.clone()).style(msg_area_bg);
268        f.render_widget(p, line_area);
269    }
270}
271
272/// 查找流式内容中最后一个安全的段落边界(双换行),
273/// 但要排除代码块内部的双换行(未闭合的 ``` 之后的内容不能拆分)。
274
275pub fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
276    let t = &app.theme;
277    // 输入区可用宽度(减去边框2 + prompt 4)
278    let usable_width = area.width.saturating_sub(2 + 4) as usize;
279
280    let chars: Vec<char> = app.input.chars().collect();
281
282    // 计算光标之前文本的显示宽度,决定是否需要水平滚动
283    let before_all: String = chars[..app.cursor_pos].iter().collect();
284    let before_width = display_width(&before_all);
285
286    // 如果光标超出可视范围,从光标附近开始显示
287    let scroll_offset_chars = if before_width >= usable_width {
288        // 往回找到一个合适的起始字符位置
289        let target_width = before_width.saturating_sub(usable_width / 2);
290        let mut w = 0;
291        let mut skip = 0;
292        for (i, &ch) in chars.iter().enumerate() {
293            if w >= target_width {
294                skip = i;
295                break;
296            }
297            w += char_width(ch);
298        }
299        skip
300    } else {
301        0
302    };
303
304    // 截取可见部分的字符
305    let visible_chars = &chars[scroll_offset_chars..];
306    let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
307
308    let before: String = visible_chars[..cursor_in_visible].iter().collect();
309    let cursor_ch = if cursor_in_visible < visible_chars.len() {
310        visible_chars[cursor_in_visible].to_string()
311    } else {
312        " ".to_string()
313    };
314    let after: String = if cursor_in_visible < visible_chars.len() {
315        visible_chars[cursor_in_visible + 1..].iter().collect()
316    } else {
317        String::new()
318    };
319
320    let prompt_style = if app.is_loading {
321        Style::default().fg(t.input_prompt_loading)
322    } else {
323        Style::default().fg(t.input_prompt)
324    };
325    let prompt_text = if app.is_loading { " .. " } else { " >  " };
326
327    // 构建多行输入显示(手动换行)
328    let full_visible = format!("{}{}{}", before, cursor_ch, after);
329    let inner_height = area.height.saturating_sub(2) as usize; // 减去边框
330    let wrapped_lines = wrap_text(&full_visible, usable_width);
331
332    // 找到光标所在的行索引
333    let before_len = before.chars().count();
334    let cursor_len = cursor_ch.chars().count();
335    let cursor_global_pos = before_len; // 光标在全部可见字符中的位置
336    let mut cursor_line_idx: usize = 0;
337    {
338        let mut cumulative = 0usize;
339        for (li, wl) in wrapped_lines.iter().enumerate() {
340            let line_char_count = wl.chars().count();
341            if cumulative + line_char_count > cursor_global_pos {
342                cursor_line_idx = li;
343                break;
344            }
345            cumulative += line_char_count;
346            cursor_line_idx = li; // 光标恰好在最后一行末尾
347        }
348    }
349
350    // 计算行滚动:确保光标所在行在可见区域内
351    let line_scroll = if wrapped_lines.len() <= inner_height {
352        0
353    } else if cursor_line_idx < inner_height {
354        0
355    } else {
356        // 让光标行显示在可见区域的最后一行
357        cursor_line_idx.saturating_sub(inner_height - 1)
358    };
359
360    // 构建带光标高亮的行
361    let mut display_lines: Vec<Line> = Vec::new();
362    let mut char_offset: usize = 0;
363    // 跳过滚动行的字符数
364    for wl in wrapped_lines.iter().take(line_scroll) {
365        char_offset += wl.chars().count();
366    }
367
368    for (_line_idx, wl) in wrapped_lines
369        .iter()
370        .skip(line_scroll)
371        .enumerate()
372        .take(inner_height.max(1))
373    {
374        let mut spans: Vec<Span> = Vec::new();
375        if _line_idx == 0 && line_scroll == 0 {
376            spans.push(Span::styled(prompt_text, prompt_style));
377        } else {
378            spans.push(Span::styled("    ", Style::default())); // 对齐 prompt
379        }
380
381        // 对该行的每个字符分配样式
382        let line_chars: Vec<char> = wl.chars().collect();
383        let mut seg_start = 0;
384        for (ci, &ch) in line_chars.iter().enumerate() {
385            let global_idx = char_offset + ci;
386            let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
387
388            if is_cursor {
389                // 先把 cursor 前的部分输出
390                if ci > seg_start {
391                    let seg: String = line_chars[seg_start..ci].iter().collect();
392                    spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
393                }
394                spans.push(Span::styled(
395                    ch.to_string(),
396                    Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
397                ));
398                seg_start = ci + 1;
399            }
400        }
401        // 输出剩余部分
402        if seg_start < line_chars.len() {
403            let seg: String = line_chars[seg_start..].iter().collect();
404            spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
405        }
406
407        char_offset += line_chars.len();
408        display_lines.push(Line::from(spans));
409    }
410
411    if display_lines.is_empty() {
412        display_lines.push(Line::from(vec![
413            Span::styled(prompt_text, prompt_style),
414            Span::styled(" ", Style::default().fg(t.cursor_fg).bg(t.cursor_bg)),
415        ]));
416    }
417
418    let input_widget = Paragraph::new(display_lines).block(
419        Block::default()
420            .borders(Borders::ALL)
421            .border_type(ratatui::widgets::BorderType::Rounded)
422            .border_style(if app.is_loading {
423                Style::default().fg(t.border_input_loading)
424            } else {
425                Style::default().fg(t.border_input)
426            })
427            .title(Span::styled(" 输入消息 ", Style::default().fg(t.text_dim)))
428            .style(Style::default().bg(t.bg_input)),
429    );
430
431    f.render_widget(input_widget, area);
432
433    // 设置终端光标位置,确保中文输入法 IME 候选窗口在正确位置
434    // 计算光标在渲染后的坐标
435    if !app.is_loading {
436        let prompt_w: u16 = 4; // prompt 宽度
437        let border_left: u16 = 1; // 左边框
438
439        // 光标在当前显示行中的列偏移
440        let cursor_col_in_line = {
441            let mut col = 0usize;
442            let mut char_count = 0usize;
443            // 跳过 line_scroll 之前的字符
444            let mut skip_chars = 0usize;
445            for wl in wrapped_lines.iter().take(line_scroll) {
446                skip_chars += wl.chars().count();
447            }
448            // 找到光标在当前行的列
449            for wl in wrapped_lines.iter().skip(line_scroll) {
450                let line_len = wl.chars().count();
451                if skip_chars + char_count + line_len > cursor_global_pos {
452                    // 光标在这一行
453                    let pos_in_line = cursor_global_pos - (skip_chars + char_count);
454                    col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
455                    break;
456                }
457                char_count += line_len;
458            }
459            col as u16
460        };
461
462        // 光标在显示行中的行偏移
463        let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
464
465        let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
466        let cursor_y = area.y + 1 + cursor_row_in_display; // +1 跳过上边框
467
468        // 确保光标在区域内
469        if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
470            f.set_cursor_position((cursor_x, cursor_y));
471        }
472    }
473}
474
475/// 绘制底部操作提示栏(始终可见)
476pub fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
477    let t = &app.theme;
478    let hints = match app.mode {
479        ChatMode::Chat => {
480            vec![
481                ("Enter", "发送"),
482                ("↑↓", "滚动"),
483                ("Ctrl+T", "切换模型"),
484                ("Ctrl+L", "清空"),
485                ("Ctrl+Y", "复制"),
486                ("Ctrl+B", "浏览"),
487                ("Ctrl+S", "流式切换"),
488                ("Ctrl+E", "配置"),
489                ("?/F1", "帮助"),
490                ("Esc", "退出"),
491            ]
492        }
493        ChatMode::SelectModel => {
494            vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
495        }
496        ChatMode::Browse => {
497            vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
498        }
499        ChatMode::Help => {
500            vec![("任意键", "返回")]
501        }
502        ChatMode::Config => {
503            vec![
504                ("↑↓", "切换字段"),
505                ("Enter", "编辑"),
506                ("Tab", "切换 Provider"),
507                ("a", "新增"),
508                ("d", "删除"),
509                ("Esc", "保存返回"),
510            ]
511        }
512    };
513
514    let mut spans: Vec<Span> = Vec::new();
515    spans.push(Span::styled(" ", Style::default()));
516    for (i, (key, desc)) in hints.iter().enumerate() {
517        if i > 0 {
518            spans.push(Span::styled("  │  ", Style::default().fg(t.hint_separator)));
519        }
520        spans.push(Span::styled(
521            format!(" {} ", key),
522            Style::default().fg(t.hint_key_fg).bg(t.hint_key_bg),
523        ));
524        spans.push(Span::styled(
525            format!(" {}", desc),
526            Style::default().fg(t.hint_desc),
527        ));
528    }
529
530    let hint_bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_primary));
531    f.render_widget(hint_bar, area);
532}
533
534/// 绘制 Toast 弹窗(右上角浮层)
535pub fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
536    let t = &app.theme;
537    if let Some((ref msg, is_error, _)) = app.toast {
538        let text_width = display_width(msg);
539        // toast 宽度 = 文字宽度 + 左右 padding(各2) + emoji(2) + border(2)
540        let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
541        let toast_height: u16 = 3;
542
543        // 定位到右上角
544        let x = area.width.saturating_sub(toast_width + 1);
545        let y: u16 = 1;
546
547        if x + toast_width <= area.width && y + toast_height <= area.height {
548            let toast_area = Rect::new(x, y, toast_width, toast_height);
549
550            // 先清空区域背景
551            let clear = Block::default().style(Style::default().bg(if is_error {
552                t.toast_error_bg
553            } else {
554                t.toast_success_bg
555            }));
556            f.render_widget(clear, toast_area);
557
558            let (icon, border_color, text_color) = if is_error {
559                ("❌", t.toast_error_border, t.toast_error_text)
560            } else {
561                ("✅", t.toast_success_border, t.toast_success_text)
562            };
563
564            let toast_widget = Paragraph::new(Line::from(vec![
565                Span::styled(format!(" {} ", icon), Style::default()),
566                Span::styled(msg.as_str(), Style::default().fg(text_color)),
567            ]))
568            .block(
569                Block::default()
570                    .borders(Borders::ALL)
571                    .border_type(ratatui::widgets::BorderType::Rounded)
572                    .border_style(Style::default().fg(border_color))
573                    .style(Style::default().bg(if is_error {
574                        t.toast_error_bg
575                    } else {
576                        t.toast_success_bg
577                    })),
578            );
579            f.render_widget(toast_widget, toast_area);
580        }
581    }
582}
583
584/// 绘制模型选择界面
585pub fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
586    let t = &app.theme;
587    let items: Vec<ListItem> = app
588        .agent_config
589        .providers
590        .iter()
591        .enumerate()
592        .map(|(i, p)| {
593            let is_active = i == app.agent_config.active_index;
594            let marker = if is_active { " ● " } else { " ○ " };
595            let style = if is_active {
596                Style::default()
597                    .fg(t.model_sel_active)
598                    .add_modifier(Modifier::BOLD)
599            } else {
600                Style::default().fg(t.model_sel_inactive)
601            };
602            let detail = format!("{}{}  ({})", marker, p.name, p.model);
603            ListItem::new(Line::from(Span::styled(detail, style)))
604        })
605        .collect();
606
607    let list = List::new(items)
608        .block(
609            Block::default()
610                .borders(Borders::ALL)
611                .border_type(ratatui::widgets::BorderType::Rounded)
612                .border_style(Style::default().fg(t.model_sel_border))
613                .title(Span::styled(
614                    " 🔄 选择模型 ",
615                    Style::default()
616                        .fg(t.model_sel_title)
617                        .add_modifier(Modifier::BOLD),
618                ))
619                .style(Style::default().bg(t.bg_title)),
620        )
621        .highlight_style(
622            Style::default()
623                .bg(t.model_sel_highlight_bg)
624                .fg(t.text_white)
625                .add_modifier(Modifier::BOLD),
626        )
627        .highlight_symbol("  ▸ ");
628
629    f.render_stateful_widget(list, area, &mut app.model_list_state);
630}
631
632/// 绘制帮助界面
633pub fn draw_help(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
634    let t = &app.theme;
635    let separator = Line::from(Span::styled(
636        "  ─────────────────────────────────────────",
637        Style::default().fg(t.separator),
638    ));
639
640    let help_lines = vec![
641        Line::from(""),
642        Line::from(Span::styled(
643            "  📖 快捷键帮助",
644            Style::default()
645                .fg(t.help_title)
646                .add_modifier(Modifier::BOLD),
647        )),
648        Line::from(""),
649        separator.clone(),
650        Line::from(""),
651        Line::from(vec![
652            Span::styled(
653                "  Enter        ",
654                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
655            ),
656            Span::styled("发送消息", Style::default().fg(t.help_desc)),
657        ]),
658        Line::from(vec![
659            Span::styled(
660                "  ↑ / ↓        ",
661                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
662            ),
663            Span::styled("滚动对话记录", Style::default().fg(t.help_desc)),
664        ]),
665        Line::from(vec![
666            Span::styled(
667                "  ← / →        ",
668                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
669            ),
670            Span::styled("移动输入光标", Style::default().fg(t.help_desc)),
671        ]),
672        Line::from(vec![
673            Span::styled(
674                "  Ctrl+T       ",
675                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
676            ),
677            Span::styled("切换模型", Style::default().fg(t.help_desc)),
678        ]),
679        Line::from(vec![
680            Span::styled(
681                "  Ctrl+L       ",
682                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
683            ),
684            Span::styled("清空对话历史", Style::default().fg(t.help_desc)),
685        ]),
686        Line::from(vec![
687            Span::styled(
688                "  Ctrl+Y       ",
689                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
690            ),
691            Span::styled("复制最后一条 AI 回复", Style::default().fg(t.help_desc)),
692        ]),
693        Line::from(vec![
694            Span::styled(
695                "  Ctrl+B       ",
696                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
697            ),
698            Span::styled(
699                "浏览消息 (↑↓选择, y/Enter复制)",
700                Style::default().fg(t.help_desc),
701            ),
702        ]),
703        Line::from(vec![
704            Span::styled(
705                "  Ctrl+S       ",
706                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
707            ),
708            Span::styled("切换流式/整体输出", Style::default().fg(t.help_desc)),
709        ]),
710        Line::from(vec![
711            Span::styled(
712                "  Ctrl+E       ",
713                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
714            ),
715            Span::styled("打开配置界面", Style::default().fg(t.help_desc)),
716        ]),
717        Line::from(vec![
718            Span::styled(
719                "  Esc / Ctrl+C ",
720                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
721            ),
722            Span::styled("退出对话", Style::default().fg(t.help_desc)),
723        ]),
724        Line::from(vec![
725            Span::styled(
726                "  ? / F1       ",
727                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
728            ),
729            Span::styled("显示 / 关闭此帮助", Style::default().fg(t.help_desc)),
730        ]),
731        Line::from(""),
732        separator,
733        Line::from(""),
734        Line::from(Span::styled(
735            "  📁 配置文件:",
736            Style::default()
737                .fg(t.help_title)
738                .add_modifier(Modifier::BOLD),
739        )),
740        Line::from(Span::styled(
741            format!("     {}", agent_config_path().display()),
742            Style::default().fg(t.help_path),
743        )),
744    ];
745
746    let help_block = Block::default()
747        .borders(Borders::ALL)
748        .border_type(ratatui::widgets::BorderType::Rounded)
749        .border_style(Style::default().fg(t.border_title))
750        .title(Span::styled(
751            " 帮助 (按任意键返回) ",
752            Style::default().fg(t.text_dim),
753        ))
754        .style(Style::default().bg(t.help_bg));
755    let help_widget = Paragraph::new(help_lines).block(help_block);
756    f.render_widget(help_widget, area);
757}
758
759/// 对话模式按键处理,返回 true 表示退出
760
761pub fn draw_config_screen(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
762    let t = &app.theme;
763    let bg = t.bg_title;
764    let total_provider_fields = CONFIG_FIELDS.len();
765
766    let mut lines: Vec<Line> = Vec::new();
767    lines.push(Line::from(""));
768
769    // 标题
770    lines.push(Line::from(vec![Span::styled(
771        "  ⚙️  模型配置",
772        Style::default()
773            .fg(t.config_title)
774            .add_modifier(Modifier::BOLD),
775    )]));
776    lines.push(Line::from(""));
777
778    // Provider 标签栏
779    let provider_count = app.agent_config.providers.len();
780    if provider_count > 0 {
781        let mut tab_spans: Vec<Span> = vec![Span::styled("  ", Style::default())];
782        for (i, p) in app.agent_config.providers.iter().enumerate() {
783            let is_current = i == app.config_provider_idx;
784            let is_active = i == app.agent_config.active_index;
785            let marker = if is_active { "● " } else { "○ " };
786            let label = format!(" {}{} ", marker, p.name);
787            if is_current {
788                tab_spans.push(Span::styled(
789                    label,
790                    Style::default()
791                        .fg(t.config_tab_active_fg)
792                        .bg(t.config_tab_active_bg)
793                        .add_modifier(Modifier::BOLD),
794                ));
795            } else {
796                tab_spans.push(Span::styled(
797                    label,
798                    Style::default().fg(t.config_tab_inactive),
799                ));
800            }
801            if i < provider_count - 1 {
802                tab_spans.push(Span::styled(" │ ", Style::default().fg(t.separator)));
803            }
804        }
805        tab_spans.push(Span::styled(
806            "    (● = 活跃模型, Tab 切换, s 设为活跃)",
807            Style::default().fg(t.config_dim),
808        ));
809        lines.push(Line::from(tab_spans));
810    } else {
811        lines.push(Line::from(Span::styled(
812            "  (无 Provider,按 a 新增)",
813            Style::default().fg(t.config_toggle_off),
814        )));
815    }
816    lines.push(Line::from(""));
817
818    // 分隔线
819    lines.push(Line::from(Span::styled(
820        "  ─────────────────────────────────────────",
821        Style::default().fg(t.separator),
822    )));
823    lines.push(Line::from(""));
824
825    // Provider 字段
826    if provider_count > 0 {
827        lines.push(Line::from(Span::styled(
828            "  📦 Provider 配置",
829            Style::default()
830                .fg(t.config_section)
831                .add_modifier(Modifier::BOLD),
832        )));
833        lines.push(Line::from(""));
834
835        for i in 0..total_provider_fields {
836            let is_selected = app.config_field_idx == i;
837            let label = config_field_label(i);
838            let value = if app.config_editing && is_selected {
839                app.config_edit_buf.clone()
840            } else {
841                config_field_value(app, i)
842            };
843
844            let pointer = if is_selected { "  ▸ " } else { "    " };
845            let pointer_style = if is_selected {
846                Style::default().fg(t.config_pointer)
847            } else {
848                Style::default()
849            };
850
851            let label_style = if is_selected {
852                Style::default()
853                    .fg(t.config_label_selected)
854                    .add_modifier(Modifier::BOLD)
855            } else {
856                Style::default().fg(t.config_label)
857            };
858
859            let value_style = if app.config_editing && is_selected {
860                Style::default().fg(t.text_white).bg(t.config_edit_bg)
861            } else if is_selected {
862                Style::default().fg(t.text_white)
863            } else {
864                if CONFIG_FIELDS[i] == "api_key" {
865                    Style::default().fg(t.config_api_key)
866                } else {
867                    Style::default().fg(t.config_value)
868                }
869            };
870
871            let edit_indicator = if app.config_editing && is_selected {
872                " ✏️"
873            } else {
874                ""
875            };
876
877            lines.push(Line::from(vec![
878                Span::styled(pointer, pointer_style),
879                Span::styled(format!("{:<10}", label), label_style),
880                Span::styled("  ", Style::default()),
881                Span::styled(
882                    if value.is_empty() {
883                        "(空)".to_string()
884                    } else {
885                        value
886                    },
887                    value_style,
888                ),
889                Span::styled(edit_indicator, Style::default()),
890            ]));
891        }
892    }
893
894    lines.push(Line::from(""));
895    // 分隔线
896    lines.push(Line::from(Span::styled(
897        "  ─────────────────────────────────────────",
898        Style::default().fg(t.separator),
899    )));
900    lines.push(Line::from(""));
901
902    // 全局配置
903    lines.push(Line::from(Span::styled(
904        "  🌐 全局配置",
905        Style::default()
906            .fg(t.config_section)
907            .add_modifier(Modifier::BOLD),
908    )));
909    lines.push(Line::from(""));
910
911    for i in 0..CONFIG_GLOBAL_FIELDS.len() {
912        let field_idx = total_provider_fields + i;
913        let is_selected = app.config_field_idx == field_idx;
914        let label = config_field_label(field_idx);
915        let value = if app.config_editing && is_selected {
916            app.config_edit_buf.clone()
917        } else {
918            config_field_value(app, field_idx)
919        };
920
921        let pointer = if is_selected { "  ▸ " } else { "    " };
922        let pointer_style = if is_selected {
923            Style::default().fg(t.config_pointer)
924        } else {
925            Style::default()
926        };
927
928        let label_style = if is_selected {
929            Style::default()
930                .fg(t.config_label_selected)
931                .add_modifier(Modifier::BOLD)
932        } else {
933            Style::default().fg(t.config_label)
934        };
935
936        let value_style = if app.config_editing && is_selected {
937            Style::default().fg(t.text_white).bg(t.config_edit_bg)
938        } else if is_selected {
939            Style::default().fg(t.text_white)
940        } else {
941            Style::default().fg(t.config_value)
942        };
943
944        let edit_indicator = if app.config_editing && is_selected {
945            " ✏️"
946        } else {
947            ""
948        };
949
950        // stream_mode 和 theme 用 toggle 样式
951        if CONFIG_GLOBAL_FIELDS[i] == "stream_mode" {
952            let toggle_on = app.agent_config.stream_mode;
953            let toggle_style = if toggle_on {
954                Style::default()
955                    .fg(t.config_toggle_on)
956                    .add_modifier(Modifier::BOLD)
957            } else {
958                Style::default().fg(t.config_toggle_off)
959            };
960            let toggle_text = if toggle_on {
961                "● 开启"
962            } else {
963                "○ 关闭"
964            };
965
966            lines.push(Line::from(vec![
967                Span::styled(pointer, pointer_style),
968                Span::styled(format!("{:<10}", label), label_style),
969                Span::styled("  ", Style::default()),
970                Span::styled(toggle_text, toggle_style),
971                Span::styled(
972                    if is_selected { "  (Enter 切换)" } else { "" },
973                    Style::default().fg(t.config_dim),
974                ),
975            ]));
976        } else if CONFIG_GLOBAL_FIELDS[i] == "theme" {
977            // 主题字段用特殊 toggle 样式
978            let theme_name = app.agent_config.theme.display_name();
979            lines.push(Line::from(vec![
980                Span::styled(pointer, pointer_style),
981                Span::styled(format!("{:<10}", label), label_style),
982                Span::styled("  ", Style::default()),
983                Span::styled(
984                    format!("🎨 {}", theme_name),
985                    Style::default()
986                        .fg(t.config_toggle_on)
987                        .add_modifier(Modifier::BOLD),
988                ),
989                Span::styled(
990                    if is_selected { "  (Enter 切换)" } else { "" },
991                    Style::default().fg(t.config_dim),
992                ),
993            ]));
994        } else {
995            lines.push(Line::from(vec![
996                Span::styled(pointer, pointer_style),
997                Span::styled(format!("{:<10}", label), label_style),
998                Span::styled("  ", Style::default()),
999                Span::styled(
1000                    if value.is_empty() {
1001                        "(空)".to_string()
1002                    } else {
1003                        value
1004                    },
1005                    value_style,
1006                ),
1007                Span::styled(edit_indicator, Style::default()),
1008            ]));
1009        }
1010    }
1011
1012    lines.push(Line::from(""));
1013    lines.push(Line::from(""));
1014
1015    // 操作提示
1016    lines.push(Line::from(Span::styled(
1017        "  ─────────────────────────────────────────",
1018        Style::default().fg(t.separator),
1019    )));
1020    lines.push(Line::from(""));
1021    lines.push(Line::from(vec![
1022        Span::styled("    ", Style::default()),
1023        Span::styled(
1024            "↑↓/jk",
1025            Style::default()
1026                .fg(t.config_hint_key)
1027                .add_modifier(Modifier::BOLD),
1028        ),
1029        Span::styled(" 切换字段  ", Style::default().fg(t.config_hint_desc)),
1030        Span::styled(
1031            "Enter",
1032            Style::default()
1033                .fg(t.config_hint_key)
1034                .add_modifier(Modifier::BOLD),
1035        ),
1036        Span::styled(" 编辑  ", Style::default().fg(t.config_hint_desc)),
1037        Span::styled(
1038            "Tab/←→",
1039            Style::default()
1040                .fg(t.config_hint_key)
1041                .add_modifier(Modifier::BOLD),
1042        ),
1043        Span::styled(" 切换 Provider  ", Style::default().fg(t.config_hint_desc)),
1044        Span::styled(
1045            "a",
1046            Style::default()
1047                .fg(t.config_hint_key)
1048                .add_modifier(Modifier::BOLD),
1049        ),
1050        Span::styled(" 新增  ", Style::default().fg(t.config_hint_desc)),
1051        Span::styled(
1052            "d",
1053            Style::default()
1054                .fg(t.config_hint_key)
1055                .add_modifier(Modifier::BOLD),
1056        ),
1057        Span::styled(" 删除  ", Style::default().fg(t.config_hint_desc)),
1058        Span::styled(
1059            "s",
1060            Style::default()
1061                .fg(t.config_hint_key)
1062                .add_modifier(Modifier::BOLD),
1063        ),
1064        Span::styled(" 设为活跃  ", Style::default().fg(t.config_hint_desc)),
1065        Span::styled(
1066            "Esc",
1067            Style::default()
1068                .fg(t.config_hint_key)
1069                .add_modifier(Modifier::BOLD),
1070        ),
1071        Span::styled(" 保存返回", Style::default().fg(t.config_hint_desc)),
1072    ]));
1073
1074    let content = Paragraph::new(lines)
1075        .block(
1076            Block::default()
1077                .borders(Borders::ALL)
1078                .border_type(ratatui::widgets::BorderType::Rounded)
1079                .border_style(Style::default().fg(t.border_config))
1080                .title(Span::styled(
1081                    " ⚙️  模型配置编辑 ",
1082                    Style::default()
1083                        .fg(t.config_label_selected)
1084                        .add_modifier(Modifier::BOLD),
1085                ))
1086                .style(Style::default().bg(bg)),
1087        )
1088        .scroll((0, 0));
1089    f.render_widget(content, area);
1090}