Skip to main content

j_cli/command/chat/ui/
chat.rs

1use super::super::app::{ChatApp, ChatMode, MsgLinesCache};
2use super::super::model::agent_config_path;
3use super::super::render::{build_message_lines_incremental, char_width, display_width, wrap_text};
4use super::archive::{draw_archive_confirm, draw_archive_list};
5use super::config::draw_config_screen;
6use ratatui::{
7    layout::{Constraint, Direction, Layout, Rect},
8    style::{Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, List, ListItem, Paragraph},
11};
12
13pub fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
14    let size = f.area();
15
16    // 整体背景
17    let bg = Block::default().style(Style::default().bg(app.theme.bg_primary));
18    f.render_widget(bg, size);
19
20    let chunks = Layout::default()
21        .direction(Direction::Vertical)
22        .constraints([
23            Constraint::Length(3), // 标题栏
24            Constraint::Min(5),    // 消息区
25            Constraint::Length(5), // 输入区
26            Constraint::Length(1), // 操作提示栏(始终可见)
27        ])
28        .split(size);
29
30    // ========== 标题栏 ==========
31    draw_title_bar(f, chunks[0], app);
32
33    // ========== 消息区 ==========
34    if app.mode == ChatMode::Help {
35        draw_help(f, chunks[1], app);
36    } else if app.mode == ChatMode::SelectModel {
37        draw_model_selector(f, chunks[1], app);
38    } else if app.mode == ChatMode::Config {
39        draw_config_screen(f, chunks[1], app);
40    } else if app.mode == ChatMode::ArchiveConfirm {
41        draw_archive_confirm(f, chunks[1], app);
42    } else if app.mode == ChatMode::ArchiveList {
43        draw_archive_list(f, chunks[1], app);
44    } else {
45        draw_messages(f, chunks[1], app);
46    }
47
48    // ========== 输入区 ==========
49    draw_input(f, chunks[2], app);
50
51    // ========== 底部操作提示栏(始终可见)==========
52    draw_hint_bar(f, chunks[3], app);
53
54    // ========== Toast 弹窗覆盖层(右上角)==========
55    draw_toast(f, size, app);
56}
57
58/// 绘制标题栏
59pub fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
60    let t = &app.theme;
61    let model_name = app.active_model_name();
62    let msg_count = app.session.messages.len();
63    let loading = if app.is_loading {
64        " ⏳ 思考中..."
65    } else {
66        ""
67    };
68
69    let title_spans = vec![
70        Span::styled(" 💬 ", Style::default().fg(t.title_icon)),
71        Span::styled(
72            "AI Chat",
73            Style::default()
74                .fg(t.text_white)
75                .add_modifier(Modifier::BOLD),
76        ),
77        Span::styled("  │  ", Style::default().fg(t.title_separator)),
78        Span::styled("🤖 ", Style::default()),
79        Span::styled(
80            model_name,
81            Style::default()
82                .fg(t.title_model)
83                .add_modifier(Modifier::BOLD),
84        ),
85        Span::styled("  │  ", Style::default().fg(t.title_separator)),
86        Span::styled(
87            format!("📨 {} 条消息", msg_count),
88            Style::default().fg(t.title_count),
89        ),
90        Span::styled(
91            loading,
92            Style::default()
93                .fg(t.title_loading)
94                .add_modifier(Modifier::BOLD),
95        ),
96    ];
97
98    let title_block = Paragraph::new(Line::from(title_spans)).block(
99        Block::default()
100            .borders(Borders::ALL)
101            .border_type(ratatui::widgets::BorderType::Rounded)
102            .border_style(Style::default().fg(t.border_title))
103            .style(Style::default().bg(t.bg_title)),
104    );
105    f.render_widget(title_block, area);
106}
107
108/// 绘制消息区
109pub fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
110    let t = &app.theme;
111    let block = Block::default()
112        .borders(Borders::ALL)
113        .border_type(ratatui::widgets::BorderType::Rounded)
114        .border_style(Style::default().fg(t.border_message))
115        .title(Span::styled(
116            " 对话记录 ",
117            Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD),
118        ))
119        .title_alignment(ratatui::layout::Alignment::Left)
120        .style(Style::default().bg(t.bg_primary));
121
122    // 空消息时显示欢迎界面
123    if app.session.messages.is_empty() && !app.is_loading {
124        let welcome_lines = vec![
125            Line::from(""),
126            Line::from(""),
127            Line::from(Span::styled(
128                "  ╭──────────────────────────────────────╮",
129                Style::default().fg(t.welcome_border),
130            )),
131            Line::from(Span::styled(
132                "  │                                      │",
133                Style::default().fg(t.welcome_border),
134            )),
135            Line::from(vec![
136                Span::styled("  │     ", Style::default().fg(t.welcome_border)),
137                Span::styled(
138                    "Hi! What can I help you?  ",
139                    Style::default().fg(t.welcome_text),
140                ),
141                Span::styled("     │", Style::default().fg(t.welcome_border)),
142            ]),
143            Line::from(Span::styled(
144                "  │                                      │",
145                Style::default().fg(t.welcome_border),
146            )),
147            Line::from(Span::styled(
148                "  │     Type a message, press Enter      │",
149                Style::default().fg(t.welcome_hint),
150            )),
151            Line::from(Span::styled(
152                "  │                                      │",
153                Style::default().fg(t.welcome_border),
154            )),
155            Line::from(Span::styled(
156                "  ╰──────────────────────────────────────╯",
157                Style::default().fg(t.welcome_border),
158            )),
159        ];
160        let empty = Paragraph::new(welcome_lines).block(block);
161        f.render_widget(empty, area);
162        return;
163    }
164
165    // 内部可用宽度(减去边框和左右各1的 padding)
166    let inner_width = area.width.saturating_sub(4) as usize;
167    // 消息内容最大宽度为可用宽度的 75%
168    let bubble_max_width = (inner_width * 75 / 100).max(20);
169
170    let msg_count = app.session.messages.len();
171    let last_msg_len = app
172        .session
173        .messages
174        .last()
175        .map(|m| m.content.len())
176        .unwrap_or(0);
177    let streaming_len = app.streaming_content.lock().unwrap().len();
178    let current_browse_index = if app.mode == ChatMode::Browse {
179        Some(app.browse_msg_index)
180    } else {
181        None
182    };
183    let cache_hit = if let Some(ref cache) = app.msg_lines_cache {
184        cache.msg_count == msg_count
185            && cache.last_msg_len == last_msg_len
186            && cache.streaming_len == streaming_len
187            && cache.is_loading == app.is_loading
188            && cache.bubble_max_width == bubble_max_width
189            && cache.browse_index == current_browse_index
190    } else {
191        false
192    };
193
194    if !cache_hit {
195        let old_cache = app.msg_lines_cache.take();
196        let (new_lines, new_msg_start_lines, new_per_msg, new_stable_lines, new_stable_offset) =
197            build_message_lines_incremental(app, inner_width, bubble_max_width, old_cache.as_ref());
198        app.msg_lines_cache = Some(MsgLinesCache {
199            msg_count,
200            last_msg_len,
201            streaming_len,
202            is_loading: app.is_loading,
203            bubble_max_width,
204            browse_index: current_browse_index,
205            lines: new_lines,
206            msg_start_lines: new_msg_start_lines,
207            per_msg_lines: new_per_msg,
208            streaming_stable_lines: new_stable_lines,
209            streaming_stable_offset: new_stable_offset,
210        });
211    }
212
213    let cached = app.msg_lines_cache.as_ref().unwrap();
214    let all_lines = &cached.lines;
215    let total_lines = all_lines.len() as u16;
216
217    f.render_widget(block, area);
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    if app.mode != ChatMode::Browse {
227        if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
228            app.scroll_offset = max_scroll;
229            app.auto_scroll = true;
230        }
231    } else {
232        if let Some(msg_start) = cached
233            .msg_start_lines
234            .iter()
235            .find(|(idx, _)| *idx == app.browse_msg_index)
236            .map(|(_, line)| *line as u16)
237        {
238            let msg_line_count = cached
239                .per_msg_lines
240                .get(app.browse_msg_index)
241                .map(|c| c.lines.len())
242                .unwrap_or(1) as u16;
243            let msg_max_scroll = msg_line_count.saturating_sub(visible_height);
244            if app.browse_scroll_offset > msg_max_scroll {
245                app.browse_scroll_offset = msg_max_scroll;
246            }
247            app.scroll_offset = (msg_start + app.browse_scroll_offset).min(max_scroll);
248        }
249    }
250
251    let bg_fill = Block::default().style(Style::default().bg(app.theme.bg_primary));
252    f.render_widget(bg_fill, inner);
253
254    let start = app.scroll_offset as usize;
255    let end = (start + visible_height as usize).min(all_lines.len());
256    let msg_area_bg = Style::default().bg(app.theme.bg_primary);
257    for (i, line_idx) in (start..end).enumerate() {
258        let line = &all_lines[line_idx];
259        let y = inner.y + i as u16;
260        let line_area = Rect::new(inner.x, y, inner.width, 1);
261        let p = Paragraph::new(line.clone()).style(msg_area_bg);
262        f.render_widget(p, line_area);
263    }
264}
265
266pub fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
267    let t = &app.theme;
268    let usable_width = area.width.saturating_sub(2 + 4) as usize;
269
270    let chars: Vec<char> = app.input.chars().collect();
271
272    let before_all: String = chars[..app.cursor_pos].iter().collect();
273    let before_width = display_width(&before_all);
274
275    let scroll_offset_chars = if before_width >= usable_width {
276        let target_width = before_width.saturating_sub(usable_width / 2);
277        let mut w = 0;
278        let mut skip = 0;
279        for (i, &ch) in chars.iter().enumerate() {
280            if w >= target_width {
281                skip = i;
282                break;
283            }
284            w += char_width(ch);
285        }
286        skip
287    } else {
288        0
289    };
290
291    let visible_chars = &chars[scroll_offset_chars..];
292    let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
293
294    let before: String = visible_chars[..cursor_in_visible].iter().collect();
295    let cursor_ch = if cursor_in_visible < visible_chars.len() {
296        visible_chars[cursor_in_visible].to_string()
297    } else {
298        " ".to_string()
299    };
300    let after: String = if cursor_in_visible < visible_chars.len() {
301        visible_chars[cursor_in_visible + 1..].iter().collect()
302    } else {
303        String::new()
304    };
305
306    let prompt_style = if app.is_loading {
307        Style::default().fg(t.input_prompt_loading)
308    } else {
309        Style::default().fg(t.input_prompt)
310    };
311    let prompt_text = if app.is_loading { " .. " } else { " >  " };
312
313    let full_visible = format!("{}{}{}", before, cursor_ch, after);
314    let inner_height = area.height.saturating_sub(2) as usize;
315    let wrapped_lines = wrap_text(&full_visible, usable_width);
316
317    let before_len = before.chars().count();
318    let cursor_len = cursor_ch.chars().count();
319    let cursor_global_pos = before_len;
320    let mut cursor_line_idx: usize = 0;
321    {
322        let mut cumulative = 0usize;
323        for (li, wl) in wrapped_lines.iter().enumerate() {
324            let line_char_count = wl.chars().count();
325            if cumulative + line_char_count > cursor_global_pos {
326                cursor_line_idx = li;
327                break;
328            }
329            cumulative += line_char_count;
330            cursor_line_idx = li;
331        }
332    }
333
334    let line_scroll = if wrapped_lines.len() <= inner_height {
335        0
336    } else if cursor_line_idx < inner_height {
337        0
338    } else {
339        cursor_line_idx.saturating_sub(inner_height - 1)
340    };
341
342    let mut display_lines: Vec<Line> = Vec::new();
343    let mut char_offset: usize = 0;
344    for wl in wrapped_lines.iter().take(line_scroll) {
345        char_offset += wl.chars().count();
346    }
347
348    for (_line_idx, wl) in wrapped_lines
349        .iter()
350        .skip(line_scroll)
351        .enumerate()
352        .take(inner_height.max(1))
353    {
354        let mut spans: Vec<Span> = Vec::new();
355        if _line_idx == 0 && line_scroll == 0 {
356            spans.push(Span::styled(prompt_text, prompt_style));
357        } else {
358            spans.push(Span::styled("    ", Style::default()));
359        }
360
361        let line_chars: Vec<char> = wl.chars().collect();
362        let mut seg_start = 0;
363        for (ci, &ch) in line_chars.iter().enumerate() {
364            let global_idx = char_offset + ci;
365            let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
366
367            if is_cursor {
368                if ci > seg_start {
369                    let seg: String = line_chars[seg_start..ci].iter().collect();
370                    spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
371                }
372                spans.push(Span::styled(
373                    ch.to_string(),
374                    Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
375                ));
376                seg_start = ci + 1;
377            }
378        }
379        if seg_start < line_chars.len() {
380            let seg: String = line_chars[seg_start..].iter().collect();
381            spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
382        }
383
384        char_offset += line_chars.len();
385        display_lines.push(Line::from(spans));
386    }
387
388    if display_lines.is_empty() {
389        display_lines.push(Line::from(vec![
390            Span::styled(prompt_text, prompt_style),
391            Span::styled(" ", Style::default().fg(t.cursor_fg).bg(t.cursor_bg)),
392        ]));
393    }
394
395    let input_widget = Paragraph::new(display_lines).block(
396        Block::default()
397            .borders(Borders::ALL)
398            .border_type(ratatui::widgets::BorderType::Rounded)
399            .border_style(if app.is_loading {
400                Style::default().fg(t.border_input_loading)
401            } else {
402                Style::default().fg(t.border_input)
403            })
404            .title(Span::styled(" 输入消息 ", Style::default().fg(t.text_dim)))
405            .style(Style::default().bg(t.bg_input)),
406    );
407
408    f.render_widget(input_widget, area);
409
410    if !app.is_loading {
411        let prompt_w: u16 = 4;
412        let border_left: u16 = 1;
413
414        let cursor_col_in_line = {
415            let mut col = 0usize;
416            let mut char_count = 0usize;
417            let mut skip_chars = 0usize;
418            for wl in wrapped_lines.iter().take(line_scroll) {
419                skip_chars += wl.chars().count();
420            }
421            for wl in wrapped_lines.iter().skip(line_scroll) {
422                let line_len = wl.chars().count();
423                if skip_chars + char_count + line_len > cursor_global_pos {
424                    let pos_in_line = cursor_global_pos - (skip_chars + char_count);
425                    col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
426                    break;
427                }
428                char_count += line_len;
429            }
430            col as u16
431        };
432
433        let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
434        let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
435        let cursor_y = area.y + 1 + cursor_row_in_display;
436
437        if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
438            f.set_cursor_position((cursor_x, cursor_y));
439        }
440    }
441}
442
443/// 绘制底部操作提示栏(始终可见)
444pub fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
445    let t = &app.theme;
446    let hints = match app.mode {
447        ChatMode::Chat => vec![
448            ("Enter", "发送"),
449            ("↑↓", "滚动"),
450            ("Ctrl+T", "切换模型"),
451            ("Ctrl+L", "归档"),
452            ("Ctrl+R", "还原"),
453            ("Ctrl+Y", "复制"),
454            ("Ctrl+B", "浏览"),
455            ("Ctrl+S", "流式切换"),
456            ("Ctrl+E", "配置"),
457            ("?/F1", "帮助"),
458            ("Esc", "退出"),
459        ],
460        ChatMode::SelectModel => vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")],
461        ChatMode::Browse => vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")],
462        ChatMode::Help => vec![("任意键", "返回")],
463        ChatMode::Config => vec![
464            ("↑↓", "切换字段"),
465            ("Enter", "编辑"),
466            ("Tab", "切换 Provider"),
467            ("a", "新增"),
468            ("d", "删除"),
469            ("Esc", "保存返回"),
470        ],
471        ChatMode::ArchiveConfirm => {
472            if app.archive_editing_name {
473                vec![("Enter", "确认"), ("Esc", "取消")]
474            } else {
475                vec![
476                    ("Enter", "默认名称归档"),
477                    ("n", "自定义名称"),
478                    ("Esc", "取消"),
479                ]
480            }
481        }
482        ChatMode::ArchiveList => {
483            if app.restore_confirm_needed {
484                vec![("y/Enter", "确认还原"), ("Esc", "取消")]
485            } else {
486                vec![
487                    ("↑↓/jk", "选择"),
488                    ("Enter", "还原"),
489                    ("d", "删除"),
490                    ("Esc", "返回"),
491                ]
492            }
493        }
494    };
495
496    let mut spans: Vec<Span> = Vec::new();
497    spans.push(Span::styled(" ", Style::default()));
498    for (i, (key, desc)) in hints.iter().enumerate() {
499        if i > 0 {
500            spans.push(Span::styled("  │  ", Style::default().fg(t.hint_separator)));
501        }
502        spans.push(Span::styled(
503            format!(" {} ", key),
504            Style::default().fg(t.hint_key_fg).bg(t.hint_key_bg),
505        ));
506        spans.push(Span::styled(
507            format!(" {}", desc),
508            Style::default().fg(t.hint_desc),
509        ));
510    }
511
512    let hint_bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_primary));
513    f.render_widget(hint_bar, area);
514}
515
516/// 绘制 Toast 弹窗(右上角浮层)
517pub fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
518    let t = &app.theme;
519    if let Some((ref msg, is_error, _)) = app.toast {
520        let text_width = display_width(msg);
521        let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
522        let toast_height: u16 = 3;
523
524        let x = area.width.saturating_sub(toast_width + 1);
525        let y: u16 = 1;
526
527        if x + toast_width <= area.width && y + toast_height <= area.height {
528            let toast_area = Rect::new(x, y, toast_width, toast_height);
529
530            let clear = Block::default().style(Style::default().bg(if is_error {
531                t.toast_error_bg
532            } else {
533                t.toast_success_bg
534            }));
535            f.render_widget(clear, toast_area);
536
537            let (icon, border_color, text_color) = if is_error {
538                ("❌", t.toast_error_border, t.toast_error_text)
539            } else {
540                ("✅", t.toast_success_border, t.toast_success_text)
541            };
542
543            let toast_widget = Paragraph::new(Line::from(vec![
544                Span::styled(format!(" {} ", icon), Style::default()),
545                Span::styled(msg.as_str(), Style::default().fg(text_color)),
546            ]))
547            .block(
548                Block::default()
549                    .borders(Borders::ALL)
550                    .border_type(ratatui::widgets::BorderType::Rounded)
551                    .border_style(Style::default().fg(border_color))
552                    .style(Style::default().bg(if is_error {
553                        t.toast_error_bg
554                    } else {
555                        t.toast_success_bg
556                    })),
557            );
558            f.render_widget(toast_widget, toast_area);
559        }
560    }
561}
562
563/// 绘制模型选择界面
564pub fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
565    let t = &app.theme;
566    let items: Vec<ListItem> = app
567        .agent_config
568        .providers
569        .iter()
570        .enumerate()
571        .map(|(i, p)| {
572            let is_active = i == app.agent_config.active_index;
573            let marker = if is_active { " ● " } else { " ○ " };
574            let style = if is_active {
575                Style::default()
576                    .fg(t.model_sel_active)
577                    .add_modifier(Modifier::BOLD)
578            } else {
579                Style::default().fg(t.model_sel_inactive)
580            };
581            let detail = format!("{}{}  ({})", marker, p.name, p.model);
582            ListItem::new(Line::from(Span::styled(detail, style)))
583        })
584        .collect();
585
586    let list = List::new(items)
587        .block(
588            Block::default()
589                .borders(Borders::ALL)
590                .border_type(ratatui::widgets::BorderType::Rounded)
591                .border_style(Style::default().fg(t.model_sel_border))
592                .title(Span::styled(
593                    " 🔄 选择模型 ",
594                    Style::default()
595                        .fg(t.model_sel_title)
596                        .add_modifier(Modifier::BOLD),
597                ))
598                .style(Style::default().bg(t.bg_title)),
599        )
600        .highlight_style(
601            Style::default()
602                .bg(t.model_sel_highlight_bg)
603                .fg(t.text_white)
604                .add_modifier(Modifier::BOLD),
605        )
606        .highlight_symbol("  ▸ ");
607
608    f.render_stateful_widget(list, area, &mut app.model_list_state);
609}
610
611/// 绘制帮助界面
612pub fn draw_help(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
613    let t = &app.theme;
614    let separator = Line::from(Span::styled(
615        "  ─────────────────────────────────────────",
616        Style::default().fg(t.separator),
617    ));
618
619    let help_lines = vec![
620        Line::from(""),
621        Line::from(Span::styled(
622            "  📖 快捷键帮助",
623            Style::default()
624                .fg(t.help_title)
625                .add_modifier(Modifier::BOLD),
626        )),
627        Line::from(""),
628        separator.clone(),
629        Line::from(""),
630        Line::from(vec![
631            Span::styled(
632                "  Enter        ",
633                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
634            ),
635            Span::styled("发送消息", Style::default().fg(t.help_desc)),
636        ]),
637        Line::from(vec![
638            Span::styled(
639                "  ↑ / ↓        ",
640                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
641            ),
642            Span::styled("滚动对话记录", Style::default().fg(t.help_desc)),
643        ]),
644        Line::from(vec![
645            Span::styled(
646                "  ← / →        ",
647                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
648            ),
649            Span::styled("移动输入光标", Style::default().fg(t.help_desc)),
650        ]),
651        Line::from(vec![
652            Span::styled(
653                "  Ctrl+T       ",
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                "  Ctrl+L       ",
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                "  Ctrl+R       ",
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+Y       ",
675                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
676            ),
677            Span::styled("复制最后一条 AI 回复", Style::default().fg(t.help_desc)),
678        ]),
679        Line::from(vec![
680            Span::styled(
681                "  Ctrl+B       ",
682                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
683            ),
684            Span::styled(
685                "浏览消息 (↑↓选择, y/Enter复制)",
686                Style::default().fg(t.help_desc),
687            ),
688        ]),
689        Line::from(vec![
690            Span::styled(
691                "  Ctrl+S       ",
692                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
693            ),
694            Span::styled("切换流式/整体输出", Style::default().fg(t.help_desc)),
695        ]),
696        Line::from(vec![
697            Span::styled(
698                "  Ctrl+E       ",
699                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
700            ),
701            Span::styled("打开配置界面", Style::default().fg(t.help_desc)),
702        ]),
703        Line::from(vec![
704            Span::styled(
705                "  Esc / Ctrl+C ",
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                "  ? / F1       ",
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(""),
718        separator,
719        Line::from(""),
720        Line::from(Span::styled(
721            "  📁 配置文件:",
722            Style::default()
723                .fg(t.help_title)
724                .add_modifier(Modifier::BOLD),
725        )),
726        Line::from(Span::styled(
727            format!("     {}", agent_config_path().display()),
728            Style::default().fg(t.help_path),
729        )),
730    ];
731
732    let help_block = Block::default()
733        .borders(Borders::ALL)
734        .border_type(ratatui::widgets::BorderType::Rounded)
735        .border_style(Style::default().fg(t.border_title))
736        .title(Span::styled(
737            " 帮助 (按任意键返回) ",
738            Style::default().fg(t.text_dim),
739        ))
740        .style(Style::default().bg(t.help_bg));
741    let help_widget = Paragraph::new(help_lines).block(help_block);
742    f.render_widget(help_widget, area);
743}