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::{Color, 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(Color::Rgb(22, 22, 30)));
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]);
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 model_name = app.active_model_name();
56    let msg_count = app.session.messages.len();
57    let loading = if app.is_loading {
58        " ⏳ 思考中..."
59    } else {
60        ""
61    };
62
63    let title_spans = vec![
64        Span::styled(" 💬 ", Style::default().fg(Color::Rgb(120, 180, 255))),
65        Span::styled(
66            "AI Chat",
67            Style::default()
68                .fg(Color::White)
69                .add_modifier(Modifier::BOLD),
70        ),
71        Span::styled("  │  ", Style::default().fg(Color::Rgb(60, 60, 80))),
72        Span::styled("🤖 ", Style::default()),
73        Span::styled(
74            model_name,
75            Style::default()
76                .fg(Color::Rgb(160, 220, 160))
77                .add_modifier(Modifier::BOLD),
78        ),
79        Span::styled("  │  ", Style::default().fg(Color::Rgb(60, 60, 80))),
80        Span::styled(
81            format!("📨 {} 条消息", msg_count),
82            Style::default().fg(Color::Rgb(180, 180, 200)),
83        ),
84        Span::styled(
85            loading,
86            Style::default()
87                .fg(Color::Rgb(255, 200, 80))
88                .add_modifier(Modifier::BOLD),
89        ),
90    ];
91
92    let title_block = Paragraph::new(Line::from(title_spans)).block(
93        Block::default()
94            .borders(Borders::ALL)
95            .border_type(ratatui::widgets::BorderType::Rounded)
96            .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
97            .style(Style::default().bg(Color::Rgb(28, 28, 40))),
98    );
99    f.render_widget(title_block, area);
100}
101
102/// 绘制消息区
103pub fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
104    let block = Block::default()
105        .borders(Borders::ALL)
106        .border_type(ratatui::widgets::BorderType::Rounded)
107        .border_style(Style::default().fg(Color::Rgb(50, 55, 70)))
108        .title(Span::styled(
109            " 对话记录 ",
110            Style::default()
111                .fg(Color::Rgb(140, 140, 170))
112                .add_modifier(Modifier::BOLD),
113        ))
114        .title_alignment(ratatui::layout::Alignment::Left)
115        .style(Style::default().bg(Color::Rgb(22, 22, 30)));
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(Color::Rgb(60, 70, 90)),
125            )),
126            Line::from(Span::styled(
127                "  │                                      │",
128                Style::default().fg(Color::Rgb(60, 70, 90)),
129            )),
130            Line::from(vec![
131                Span::styled("  │     ", Style::default().fg(Color::Rgb(60, 70, 90))),
132                Span::styled(
133                    "Hi! What can I help you?  ",
134                    Style::default().fg(Color::Rgb(120, 140, 180)),
135                ),
136                Span::styled("     │", Style::default().fg(Color::Rgb(60, 70, 90))),
137            ]),
138            Line::from(Span::styled(
139                "  │                                      │",
140                Style::default().fg(Color::Rgb(60, 70, 90)),
141            )),
142            Line::from(Span::styled(
143                "  │     Type a message, press Enter      │",
144                Style::default().fg(Color::Rgb(80, 90, 110)),
145            )),
146            Line::from(Span::styled(
147                "  │                                      │",
148                Style::default().fg(Color::Rgb(60, 70, 90)),
149            )),
150            Line::from(Span::styled(
151                "  ╰──────────────────────────────────────╯",
152                Style::default().fg(Color::Rgb(60, 70, 90)),
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(Color::Rgb(22, 22, 30)));
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    for (i, line_idx) in (start..end).enumerate() {
262        let line = &all_lines[line_idx];
263        let y = inner.y + i as u16;
264        let line_area = Rect::new(inner.x, y, inner.width, 1);
265        // 使用 Paragraph 渲染单行(clone 单行开销很小)
266        let p = Paragraph::new(line.clone());
267        f.render_widget(p, line_area);
268    }
269}
270
271/// 查找流式内容中最后一个安全的段落边界(双换行),
272/// 但要排除代码块内部的双换行(未闭合的 ``` 之后的内容不能拆分)。
273
274pub fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
275    // 输入区可用宽度(减去边框2 + prompt 4)
276    let usable_width = area.width.saturating_sub(2 + 4) as usize;
277
278    let chars: Vec<char> = app.input.chars().collect();
279
280    // 计算光标之前文本的显示宽度,决定是否需要水平滚动
281    let before_all: String = chars[..app.cursor_pos].iter().collect();
282    let before_width = display_width(&before_all);
283
284    // 如果光标超出可视范围,从光标附近开始显示
285    let scroll_offset_chars = if before_width >= usable_width {
286        // 往回找到一个合适的起始字符位置
287        let target_width = before_width.saturating_sub(usable_width / 2);
288        let mut w = 0;
289        let mut skip = 0;
290        for (i, &ch) in chars.iter().enumerate() {
291            if w >= target_width {
292                skip = i;
293                break;
294            }
295            w += char_width(ch);
296        }
297        skip
298    } else {
299        0
300    };
301
302    // 截取可见部分的字符
303    let visible_chars = &chars[scroll_offset_chars..];
304    let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
305
306    let before: String = visible_chars[..cursor_in_visible].iter().collect();
307    let cursor_ch = if cursor_in_visible < visible_chars.len() {
308        visible_chars[cursor_in_visible].to_string()
309    } else {
310        " ".to_string()
311    };
312    let after: String = if cursor_in_visible < visible_chars.len() {
313        visible_chars[cursor_in_visible + 1..].iter().collect()
314    } else {
315        String::new()
316    };
317
318    let prompt_style = if app.is_loading {
319        Style::default().fg(Color::Rgb(255, 200, 80))
320    } else {
321        Style::default().fg(Color::Rgb(100, 200, 130))
322    };
323    let prompt_text = if app.is_loading { " .. " } else { " >  " };
324
325    // 构建多行输入显示(手动换行)
326    let full_visible = format!("{}{}{}", before, cursor_ch, after);
327    let inner_height = area.height.saturating_sub(2) as usize; // 减去边框
328    let wrapped_lines = wrap_text(&full_visible, usable_width);
329
330    // 找到光标所在的行索引
331    let before_len = before.chars().count();
332    let cursor_len = cursor_ch.chars().count();
333    let cursor_global_pos = before_len; // 光标在全部可见字符中的位置
334    let mut cursor_line_idx: usize = 0;
335    {
336        let mut cumulative = 0usize;
337        for (li, wl) in wrapped_lines.iter().enumerate() {
338            let line_char_count = wl.chars().count();
339            if cumulative + line_char_count > cursor_global_pos {
340                cursor_line_idx = li;
341                break;
342            }
343            cumulative += line_char_count;
344            cursor_line_idx = li; // 光标恰好在最后一行末尾
345        }
346    }
347
348    // 计算行滚动:确保光标所在行在可见区域内
349    let line_scroll = if wrapped_lines.len() <= inner_height {
350        0
351    } else if cursor_line_idx < inner_height {
352        0
353    } else {
354        // 让光标行显示在可见区域的最后一行
355        cursor_line_idx.saturating_sub(inner_height - 1)
356    };
357
358    // 构建带光标高亮的行
359    let mut display_lines: Vec<Line> = Vec::new();
360    let mut char_offset: usize = 0;
361    // 跳过滚动行的字符数
362    for wl in wrapped_lines.iter().take(line_scroll) {
363        char_offset += wl.chars().count();
364    }
365
366    for (_line_idx, wl) in wrapped_lines
367        .iter()
368        .skip(line_scroll)
369        .enumerate()
370        .take(inner_height.max(1))
371    {
372        let mut spans: Vec<Span> = Vec::new();
373        if _line_idx == 0 && line_scroll == 0 {
374            spans.push(Span::styled(prompt_text, prompt_style));
375        } else {
376            spans.push(Span::styled("    ", Style::default())); // 对齐 prompt
377        }
378
379        // 对该行的每个字符分配样式
380        let line_chars: Vec<char> = wl.chars().collect();
381        let mut seg_start = 0;
382        for (ci, &ch) in line_chars.iter().enumerate() {
383            let global_idx = char_offset + ci;
384            let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
385
386            if is_cursor {
387                // 先把 cursor 前的部分输出
388                if ci > seg_start {
389                    let seg: String = line_chars[seg_start..ci].iter().collect();
390                    spans.push(Span::styled(seg, Style::default().fg(Color::White)));
391                }
392                spans.push(Span::styled(
393                    ch.to_string(),
394                    Style::default()
395                        .fg(Color::Rgb(22, 22, 30))
396                        .bg(Color::Rgb(200, 210, 240)),
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(Color::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(
415                " ",
416                Style::default()
417                    .fg(Color::Rgb(22, 22, 30))
418                    .bg(Color::Rgb(200, 210, 240)),
419            ),
420        ]));
421    }
422
423    let input_widget = Paragraph::new(display_lines).block(
424        Block::default()
425            .borders(Borders::ALL)
426            .border_type(ratatui::widgets::BorderType::Rounded)
427            .border_style(if app.is_loading {
428                Style::default().fg(Color::Rgb(120, 100, 50))
429            } else {
430                Style::default().fg(Color::Rgb(60, 100, 80))
431            })
432            .title(Span::styled(
433                " 输入消息 ",
434                Style::default().fg(Color::Rgb(140, 140, 170)),
435            ))
436            .style(Style::default().bg(Color::Rgb(26, 26, 38))),
437    );
438
439    f.render_widget(input_widget, area);
440
441    // 设置终端光标位置,确保中文输入法 IME 候选窗口在正确位置
442    // 计算光标在渲染后的坐标
443    if !app.is_loading {
444        let prompt_w: u16 = 4; // prompt 宽度
445        let border_left: u16 = 1; // 左边框
446
447        // 光标在当前显示行中的列偏移
448        let cursor_col_in_line = {
449            let mut col = 0usize;
450            let mut char_count = 0usize;
451            // 跳过 line_scroll 之前的字符
452            let mut skip_chars = 0usize;
453            for wl in wrapped_lines.iter().take(line_scroll) {
454                skip_chars += wl.chars().count();
455            }
456            // 找到光标在当前行的列
457            for wl in wrapped_lines.iter().skip(line_scroll) {
458                let line_len = wl.chars().count();
459                if skip_chars + char_count + line_len > cursor_global_pos {
460                    // 光标在这一行
461                    let pos_in_line = cursor_global_pos - (skip_chars + char_count);
462                    col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
463                    break;
464                }
465                char_count += line_len;
466            }
467            col as u16
468        };
469
470        // 光标在显示行中的行偏移
471        let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
472
473        let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
474        let cursor_y = area.y + 1 + cursor_row_in_display; // +1 跳过上边框
475
476        // 确保光标在区域内
477        if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
478            f.set_cursor_position((cursor_x, cursor_y));
479        }
480    }
481}
482
483/// 绘制底部操作提示栏(始终可见)
484pub fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
485    let hints = match app.mode {
486        ChatMode::Chat => {
487            vec![
488                ("Enter", "发送"),
489                ("↑↓", "滚动"),
490                ("Ctrl+T", "切换模型"),
491                ("Ctrl+L", "清空"),
492                ("Ctrl+Y", "复制"),
493                ("Ctrl+B", "浏览"),
494                ("Ctrl+S", "流式切换"),
495                ("Ctrl+E", "配置"),
496                ("?/F1", "帮助"),
497                ("Esc", "退出"),
498            ]
499        }
500        ChatMode::SelectModel => {
501            vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
502        }
503        ChatMode::Browse => {
504            vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
505        }
506        ChatMode::Help => {
507            vec![("任意键", "返回")]
508        }
509        ChatMode::Config => {
510            vec![
511                ("↑↓", "切换字段"),
512                ("Enter", "编辑"),
513                ("Tab", "切换 Provider"),
514                ("a", "新增"),
515                ("d", "删除"),
516                ("Esc", "保存返回"),
517            ]
518        }
519    };
520
521    let mut spans: Vec<Span> = Vec::new();
522    spans.push(Span::styled(" ", Style::default()));
523    for (i, (key, desc)) in hints.iter().enumerate() {
524        if i > 0 {
525            spans.push(Span::styled(
526                "  │  ",
527                Style::default().fg(Color::Rgb(50, 50, 65)),
528            ));
529        }
530        spans.push(Span::styled(
531            format!(" {} ", key),
532            Style::default()
533                .fg(Color::Rgb(22, 22, 30))
534                .bg(Color::Rgb(100, 110, 140)),
535        ));
536        spans.push(Span::styled(
537            format!(" {}", desc),
538            Style::default().fg(Color::Rgb(120, 120, 150)),
539        ));
540    }
541
542    let hint_bar =
543        Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(22, 22, 30)));
544    f.render_widget(hint_bar, area);
545}
546
547/// 绘制 Toast 弹窗(右上角浮层)
548pub fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
549    if let Some((ref msg, is_error, _)) = app.toast {
550        let text_width = display_width(msg);
551        // toast 宽度 = 文字宽度 + 左右 padding(各2) + emoji(2) + border(2)
552        let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
553        let toast_height: u16 = 3;
554
555        // 定位到右上角
556        let x = area.width.saturating_sub(toast_width + 1);
557        let y: u16 = 1;
558
559        if x + toast_width <= area.width && y + toast_height <= area.height {
560            let toast_area = Rect::new(x, y, toast_width, toast_height);
561
562            // 先清空区域背景
563            let clear = Block::default().style(Style::default().bg(if is_error {
564                Color::Rgb(60, 20, 20)
565            } else {
566                Color::Rgb(20, 50, 30)
567            }));
568            f.render_widget(clear, toast_area);
569
570            let (icon, border_color, text_color) = if is_error {
571                ("❌", Color::Rgb(200, 70, 70), Color::Rgb(255, 130, 130))
572            } else {
573                ("✅", Color::Rgb(60, 160, 80), Color::Rgb(140, 230, 160))
574            };
575
576            let toast_widget = Paragraph::new(Line::from(vec![
577                Span::styled(format!(" {} ", icon), Style::default()),
578                Span::styled(msg.as_str(), Style::default().fg(text_color)),
579            ]))
580            .block(
581                Block::default()
582                    .borders(Borders::ALL)
583                    .border_type(ratatui::widgets::BorderType::Rounded)
584                    .border_style(Style::default().fg(border_color))
585                    .style(Style::default().bg(if is_error {
586                        Color::Rgb(50, 18, 18)
587                    } else {
588                        Color::Rgb(18, 40, 25)
589                    })),
590            );
591            f.render_widget(toast_widget, toast_area);
592        }
593    }
594}
595
596/// 绘制模型选择界面
597pub fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
598    let items: Vec<ListItem> = app
599        .agent_config
600        .providers
601        .iter()
602        .enumerate()
603        .map(|(i, p)| {
604            let is_active = i == app.agent_config.active_index;
605            let marker = if is_active { " ● " } else { " ○ " };
606            let style = if is_active {
607                Style::default()
608                    .fg(Color::Rgb(120, 220, 160))
609                    .add_modifier(Modifier::BOLD)
610            } else {
611                Style::default().fg(Color::Rgb(180, 180, 200))
612            };
613            let detail = format!("{}{}  ({})", marker, p.name, p.model);
614            ListItem::new(Line::from(Span::styled(detail, style)))
615        })
616        .collect();
617
618    let list = List::new(items)
619        .block(
620            Block::default()
621                .borders(Borders::ALL)
622                .border_type(ratatui::widgets::BorderType::Rounded)
623                .border_style(Style::default().fg(Color::Rgb(180, 160, 80)))
624                .title(Span::styled(
625                    " 🔄 选择模型 ",
626                    Style::default()
627                        .fg(Color::Rgb(230, 210, 120))
628                        .add_modifier(Modifier::BOLD),
629                ))
630                .style(Style::default().bg(Color::Rgb(28, 28, 40))),
631        )
632        .highlight_style(
633            Style::default()
634                .bg(Color::Rgb(50, 55, 80))
635                .fg(Color::White)
636                .add_modifier(Modifier::BOLD),
637        )
638        .highlight_symbol("  ▸ ");
639
640    f.render_stateful_widget(list, area, &mut app.model_list_state);
641}
642
643/// 绘制帮助界面
644pub fn draw_help(f: &mut ratatui::Frame, area: Rect) {
645    let separator = Line::from(Span::styled(
646        "  ─────────────────────────────────────────",
647        Style::default().fg(Color::Rgb(50, 55, 70)),
648    ));
649
650    let help_lines = vec![
651        Line::from(""),
652        Line::from(Span::styled(
653            "  📖 快捷键帮助",
654            Style::default()
655                .fg(Color::Rgb(120, 180, 255))
656                .add_modifier(Modifier::BOLD),
657        )),
658        Line::from(""),
659        separator.clone(),
660        Line::from(""),
661        Line::from(vec![
662            Span::styled(
663                "  Enter        ",
664                Style::default()
665                    .fg(Color::Rgb(230, 210, 120))
666                    .add_modifier(Modifier::BOLD),
667            ),
668            Span::styled("发送消息", Style::default().fg(Color::Rgb(200, 200, 220))),
669        ]),
670        Line::from(vec![
671            Span::styled(
672                "  ↑ / ↓        ",
673                Style::default()
674                    .fg(Color::Rgb(230, 210, 120))
675                    .add_modifier(Modifier::BOLD),
676            ),
677            Span::styled(
678                "滚动对话记录",
679                Style::default().fg(Color::Rgb(200, 200, 220)),
680            ),
681        ]),
682        Line::from(vec![
683            Span::styled(
684                "  ← / →        ",
685                Style::default()
686                    .fg(Color::Rgb(230, 210, 120))
687                    .add_modifier(Modifier::BOLD),
688            ),
689            Span::styled(
690                "移动输入光标",
691                Style::default().fg(Color::Rgb(200, 200, 220)),
692            ),
693        ]),
694        Line::from(vec![
695            Span::styled(
696                "  Ctrl+T       ",
697                Style::default()
698                    .fg(Color::Rgb(230, 210, 120))
699                    .add_modifier(Modifier::BOLD),
700            ),
701            Span::styled("切换模型", Style::default().fg(Color::Rgb(200, 200, 220))),
702        ]),
703        Line::from(vec![
704            Span::styled(
705                "  Ctrl+L       ",
706                Style::default()
707                    .fg(Color::Rgb(230, 210, 120))
708                    .add_modifier(Modifier::BOLD),
709            ),
710            Span::styled(
711                "清空对话历史",
712                Style::default().fg(Color::Rgb(200, 200, 220)),
713            ),
714        ]),
715        Line::from(vec![
716            Span::styled(
717                "  Ctrl+Y       ",
718                Style::default()
719                    .fg(Color::Rgb(230, 210, 120))
720                    .add_modifier(Modifier::BOLD),
721            ),
722            Span::styled(
723                "复制最后一条 AI 回复",
724                Style::default().fg(Color::Rgb(200, 200, 220)),
725            ),
726        ]),
727        Line::from(vec![
728            Span::styled(
729                "  Ctrl+B       ",
730                Style::default()
731                    .fg(Color::Rgb(230, 210, 120))
732                    .add_modifier(Modifier::BOLD),
733            ),
734            Span::styled(
735                "浏览消息 (↑↓选择, y/Enter复制)",
736                Style::default().fg(Color::Rgb(200, 200, 220)),
737            ),
738        ]),
739        Line::from(vec![
740            Span::styled(
741                "  Ctrl+S       ",
742                Style::default()
743                    .fg(Color::Rgb(230, 210, 120))
744                    .add_modifier(Modifier::BOLD),
745            ),
746            Span::styled(
747                "切换流式/整体输出",
748                Style::default().fg(Color::Rgb(200, 200, 220)),
749            ),
750        ]),
751        Line::from(vec![
752            Span::styled(
753                "  Ctrl+E       ",
754                Style::default()
755                    .fg(Color::Rgb(230, 210, 120))
756                    .add_modifier(Modifier::BOLD),
757            ),
758            Span::styled(
759                "打开配置界面",
760                Style::default().fg(Color::Rgb(200, 200, 220)),
761            ),
762        ]),
763        Line::from(vec![
764            Span::styled(
765                "  Esc / Ctrl+C ",
766                Style::default()
767                    .fg(Color::Rgb(230, 210, 120))
768                    .add_modifier(Modifier::BOLD),
769            ),
770            Span::styled("退出对话", Style::default().fg(Color::Rgb(200, 200, 220))),
771        ]),
772        Line::from(vec![
773            Span::styled(
774                "  ? / F1       ",
775                Style::default()
776                    .fg(Color::Rgb(230, 210, 120))
777                    .add_modifier(Modifier::BOLD),
778            ),
779            Span::styled(
780                "显示 / 关闭此帮助",
781                Style::default().fg(Color::Rgb(200, 200, 220)),
782            ),
783        ]),
784        Line::from(""),
785        separator,
786        Line::from(""),
787        Line::from(Span::styled(
788            "  📁 配置文件:",
789            Style::default()
790                .fg(Color::Rgb(120, 180, 255))
791                .add_modifier(Modifier::BOLD),
792        )),
793        Line::from(Span::styled(
794            format!("     {}", agent_config_path().display()),
795            Style::default().fg(Color::Rgb(100, 100, 130)),
796        )),
797    ];
798
799    let help_block = Block::default()
800        .borders(Borders::ALL)
801        .border_type(ratatui::widgets::BorderType::Rounded)
802        .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
803        .title(Span::styled(
804            " 帮助 (按任意键返回) ",
805            Style::default().fg(Color::Rgb(140, 140, 170)),
806        ))
807        .style(Style::default().bg(Color::Rgb(24, 24, 34)));
808    let help_widget = Paragraph::new(help_lines).block(help_block);
809    f.render_widget(help_widget, area);
810}
811
812/// 对话模式按键处理,返回 true 表示退出
813
814pub fn draw_config_screen(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
815    let bg = Color::Rgb(28, 28, 40);
816    let total_provider_fields = CONFIG_FIELDS.len();
817
818    let mut lines: Vec<Line> = Vec::new();
819    lines.push(Line::from(""));
820
821    // 标题
822    lines.push(Line::from(vec![Span::styled(
823        "  ⚙️  模型配置",
824        Style::default()
825            .fg(Color::Rgb(120, 180, 255))
826            .add_modifier(Modifier::BOLD),
827    )]));
828    lines.push(Line::from(""));
829
830    // Provider 标签栏
831    let provider_count = app.agent_config.providers.len();
832    if provider_count > 0 {
833        let mut tab_spans: Vec<Span> = vec![Span::styled("  ", Style::default())];
834        for (i, p) in app.agent_config.providers.iter().enumerate() {
835            let is_current = i == app.config_provider_idx;
836            let is_active = i == app.agent_config.active_index;
837            let marker = if is_active { "● " } else { "○ " };
838            let label = format!(" {}{} ", marker, p.name);
839            if is_current {
840                tab_spans.push(Span::styled(
841                    label,
842                    Style::default()
843                        .fg(Color::Rgb(22, 22, 30))
844                        .bg(Color::Rgb(120, 180, 255))
845                        .add_modifier(Modifier::BOLD),
846                ));
847            } else {
848                tab_spans.push(Span::styled(
849                    label,
850                    Style::default().fg(Color::Rgb(150, 150, 170)),
851                ));
852            }
853            if i < provider_count - 1 {
854                tab_spans.push(Span::styled(
855                    " │ ",
856                    Style::default().fg(Color::Rgb(50, 55, 70)),
857                ));
858            }
859        }
860        tab_spans.push(Span::styled(
861            "    (● = 活跃模型, Tab 切换, s 设为活跃)",
862            Style::default().fg(Color::Rgb(80, 80, 100)),
863        ));
864        lines.push(Line::from(tab_spans));
865    } else {
866        lines.push(Line::from(Span::styled(
867            "  (无 Provider,按 a 新增)",
868            Style::default().fg(Color::Rgb(180, 120, 80)),
869        )));
870    }
871    lines.push(Line::from(""));
872
873    // 分隔线
874    lines.push(Line::from(Span::styled(
875        "  ─────────────────────────────────────────",
876        Style::default().fg(Color::Rgb(50, 55, 70)),
877    )));
878    lines.push(Line::from(""));
879
880    // Provider 字段
881    if provider_count > 0 {
882        lines.push(Line::from(Span::styled(
883            "  📦 Provider 配置",
884            Style::default()
885                .fg(Color::Rgb(160, 220, 160))
886                .add_modifier(Modifier::BOLD),
887        )));
888        lines.push(Line::from(""));
889
890        for i in 0..total_provider_fields {
891            let is_selected = app.config_field_idx == i;
892            let label = config_field_label(i);
893            let value = if app.config_editing && is_selected {
894                // 编辑模式下显示编辑缓冲区
895                app.config_edit_buf.clone()
896            } else {
897                config_field_value(app, i)
898            };
899
900            let pointer = if is_selected { "  ▸ " } else { "    " };
901            let pointer_style = if is_selected {
902                Style::default().fg(Color::Rgb(255, 200, 80))
903            } else {
904                Style::default()
905            };
906
907            let label_style = if is_selected {
908                Style::default()
909                    .fg(Color::Rgb(230, 210, 120))
910                    .add_modifier(Modifier::BOLD)
911            } else {
912                Style::default().fg(Color::Rgb(140, 140, 160))
913            };
914
915            let value_style = if app.config_editing && is_selected {
916                Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
917            } else if is_selected {
918                Style::default().fg(Color::White)
919            } else {
920                // API Key 特殊处理
921                if CONFIG_FIELDS[i] == "api_key" {
922                    Style::default().fg(Color::Rgb(100, 100, 120))
923                } else {
924                    Style::default().fg(Color::Rgb(180, 180, 200))
925                }
926            };
927
928            let edit_indicator = if app.config_editing && is_selected {
929                " ✏️"
930            } else {
931                ""
932            };
933
934            lines.push(Line::from(vec![
935                Span::styled(pointer, pointer_style),
936                Span::styled(format!("{:<10}", label), label_style),
937                Span::styled("  ", Style::default()),
938                Span::styled(
939                    if value.is_empty() {
940                        "(空)".to_string()
941                    } else {
942                        value
943                    },
944                    value_style,
945                ),
946                Span::styled(edit_indicator, Style::default()),
947            ]));
948        }
949    }
950
951    lines.push(Line::from(""));
952    // 分隔线
953    lines.push(Line::from(Span::styled(
954        "  ─────────────────────────────────────────",
955        Style::default().fg(Color::Rgb(50, 55, 70)),
956    )));
957    lines.push(Line::from(""));
958
959    // 全局配置
960    lines.push(Line::from(Span::styled(
961        "  🌐 全局配置",
962        Style::default()
963            .fg(Color::Rgb(160, 220, 160))
964            .add_modifier(Modifier::BOLD),
965    )));
966    lines.push(Line::from(""));
967
968    for i in 0..CONFIG_GLOBAL_FIELDS.len() {
969        let field_idx = total_provider_fields + i;
970        let is_selected = app.config_field_idx == field_idx;
971        let label = config_field_label(field_idx);
972        let value = if app.config_editing && is_selected {
973            app.config_edit_buf.clone()
974        } else {
975            config_field_value(app, field_idx)
976        };
977
978        let pointer = if is_selected { "  ▸ " } else { "    " };
979        let pointer_style = if is_selected {
980            Style::default().fg(Color::Rgb(255, 200, 80))
981        } else {
982            Style::default()
983        };
984
985        let label_style = if is_selected {
986            Style::default()
987                .fg(Color::Rgb(230, 210, 120))
988                .add_modifier(Modifier::BOLD)
989        } else {
990            Style::default().fg(Color::Rgb(140, 140, 160))
991        };
992
993        let value_style = if app.config_editing && is_selected {
994            Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
995        } else if is_selected {
996            Style::default().fg(Color::White)
997        } else {
998            Style::default().fg(Color::Rgb(180, 180, 200))
999        };
1000
1001        let edit_indicator = if app.config_editing && is_selected {
1002            " ✏️"
1003        } else {
1004            ""
1005        };
1006
1007        // stream_mode 用 toggle 样式
1008        if CONFIG_GLOBAL_FIELDS[i] == "stream_mode" {
1009            let toggle_on = app.agent_config.stream_mode;
1010            let toggle_style = if toggle_on {
1011                Style::default()
1012                    .fg(Color::Rgb(120, 220, 160))
1013                    .add_modifier(Modifier::BOLD)
1014            } else {
1015                Style::default().fg(Color::Rgb(200, 100, 100))
1016            };
1017            let toggle_text = if toggle_on {
1018                "● 开启"
1019            } else {
1020                "○ 关闭"
1021            };
1022
1023            lines.push(Line::from(vec![
1024                Span::styled(pointer, pointer_style),
1025                Span::styled(format!("{:<10}", label), label_style),
1026                Span::styled("  ", Style::default()),
1027                Span::styled(toggle_text, toggle_style),
1028                Span::styled(
1029                    if is_selected { "  (Enter 切换)" } else { "" },
1030                    Style::default().fg(Color::Rgb(80, 80, 100)),
1031                ),
1032            ]));
1033        } else {
1034            lines.push(Line::from(vec![
1035                Span::styled(pointer, pointer_style),
1036                Span::styled(format!("{:<10}", label), label_style),
1037                Span::styled("  ", Style::default()),
1038                Span::styled(
1039                    if value.is_empty() {
1040                        "(空)".to_string()
1041                    } else {
1042                        value
1043                    },
1044                    value_style,
1045                ),
1046                Span::styled(edit_indicator, Style::default()),
1047            ]));
1048        }
1049    }
1050
1051    lines.push(Line::from(""));
1052    lines.push(Line::from(""));
1053
1054    // 操作提示
1055    lines.push(Line::from(Span::styled(
1056        "  ─────────────────────────────────────────",
1057        Style::default().fg(Color::Rgb(50, 55, 70)),
1058    )));
1059    lines.push(Line::from(""));
1060    lines.push(Line::from(vec![
1061        Span::styled("    ", Style::default()),
1062        Span::styled(
1063            "↑↓/jk",
1064            Style::default()
1065                .fg(Color::Rgb(230, 210, 120))
1066                .add_modifier(Modifier::BOLD),
1067        ),
1068        Span::styled(
1069            " 切换字段  ",
1070            Style::default().fg(Color::Rgb(120, 120, 150)),
1071        ),
1072        Span::styled(
1073            "Enter",
1074            Style::default()
1075                .fg(Color::Rgb(230, 210, 120))
1076                .add_modifier(Modifier::BOLD),
1077        ),
1078        Span::styled(" 编辑  ", Style::default().fg(Color::Rgb(120, 120, 150))),
1079        Span::styled(
1080            "Tab/←→",
1081            Style::default()
1082                .fg(Color::Rgb(230, 210, 120))
1083                .add_modifier(Modifier::BOLD),
1084        ),
1085        Span::styled(
1086            " 切换 Provider  ",
1087            Style::default().fg(Color::Rgb(120, 120, 150)),
1088        ),
1089        Span::styled(
1090            "a",
1091            Style::default()
1092                .fg(Color::Rgb(230, 210, 120))
1093                .add_modifier(Modifier::BOLD),
1094        ),
1095        Span::styled(" 新增  ", Style::default().fg(Color::Rgb(120, 120, 150))),
1096        Span::styled(
1097            "d",
1098            Style::default()
1099                .fg(Color::Rgb(230, 210, 120))
1100                .add_modifier(Modifier::BOLD),
1101        ),
1102        Span::styled(" 删除  ", Style::default().fg(Color::Rgb(120, 120, 150))),
1103        Span::styled(
1104            "s",
1105            Style::default()
1106                .fg(Color::Rgb(230, 210, 120))
1107                .add_modifier(Modifier::BOLD),
1108        ),
1109        Span::styled(
1110            " 设为活跃  ",
1111            Style::default().fg(Color::Rgb(120, 120, 150)),
1112        ),
1113        Span::styled(
1114            "Esc",
1115            Style::default()
1116                .fg(Color::Rgb(230, 210, 120))
1117                .add_modifier(Modifier::BOLD),
1118        ),
1119        Span::styled(" 保存返回", Style::default().fg(Color::Rgb(120, 120, 150))),
1120    ]));
1121
1122    let content = Paragraph::new(lines)
1123        .block(
1124            Block::default()
1125                .borders(Borders::ALL)
1126                .border_type(ratatui::widgets::BorderType::Rounded)
1127                .border_style(Style::default().fg(Color::Rgb(80, 80, 110)))
1128                .title(Span::styled(
1129                    " ⚙️  模型配置编辑 ",
1130                    Style::default()
1131                        .fg(Color::Rgb(230, 210, 120))
1132                        .add_modifier(Modifier::BOLD),
1133                ))
1134                .style(Style::default().bg(bg)),
1135        )
1136        .scroll((0, 0));
1137    f.render_widget(content, area);
1138}