Skip to main content

j_cli/command/chat/ui/
chat.rs

1use super::super::app::{ChatApp, ChatMode, MsgLinesCache, ToolExecStatus};
2use super::super::handler::get_filtered_skills;
3use super::super::model::agent_config_path;
4use super::super::render::{build_message_lines_incremental, char_width, display_width, wrap_text};
5use super::archive::{draw_archive_confirm, draw_archive_list};
6use super::config::draw_config_screen;
7use ratatui::{
8    layout::{Constraint, Direction, Layout, Rect},
9    style::{Modifier, Style},
10    text::{Line, Span},
11    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
12};
13
14pub fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
15    let size = f.area();
16
17    // 整体背景
18    let bg = Block::default().style(Style::default().bg(app.theme.bg_primary));
19    f.render_widget(bg, size);
20
21    let chunks = Layout::default()
22        .direction(Direction::Vertical)
23        .constraints([
24            Constraint::Length(3), // 标题栏
25            Constraint::Min(5),    // 消息区
26            Constraint::Length(5), // 输入区
27            Constraint::Length(1), // 操作提示栏(始终可见)
28        ])
29        .split(size);
30
31    // ========== 标题栏 ==========
32    draw_title_bar(f, chunks[0], app);
33
34    // ========== 消息区 ==========
35    if app.mode == ChatMode::Help {
36        draw_help(f, chunks[1], app);
37    } else if app.mode == ChatMode::SelectModel {
38        draw_model_selector(f, chunks[1], app);
39    } else if app.mode == ChatMode::Config {
40        draw_config_screen(f, chunks[1], app);
41    } else if app.mode == ChatMode::ArchiveConfirm {
42        draw_archive_confirm(f, chunks[1], app);
43    } else if app.mode == ChatMode::ArchiveList {
44        draw_archive_list(f, chunks[1], app);
45    } else if app.mode == ChatMode::ToolConfirm {
46        draw_messages(f, chunks[1], app);
47    } else {
48        draw_messages(f, chunks[1], app);
49    }
50
51    // ========== 输入区 ==========
52    draw_input(f, chunks[2], app);
53
54    // ========== 底部操作提示栏(始终可见)==========
55    draw_hint_bar(f, chunks[3], app);
56
57    // ========== Toast 弹窗覆盖层(右上角)==========
58    draw_toast(f, size, app);
59
60    // ========== @ 补全弹窗覆盖层 ==========
61    if app.at_popup_active {
62        draw_at_popup(f, chunks[2], app);
63    }
64}
65
66/// 绘制标题栏
67pub fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
68    let t = &app.theme;
69    let model_name = app.active_model_name();
70    let msg_count = app.session.messages.len();
71    let loading = if app.is_loading {
72        // 如果有活跃工具调用,显示工具名
73        let tool_info = app
74            .active_tool_calls
75            .iter()
76            .find(|tc| matches!(tc.status, ToolExecStatus::Executing))
77            .map(|tc| format!(" 🔧 执行 {}...", tc.tool_name));
78        if let Some(info) = tool_info {
79            info
80        } else {
81            " ⏳ 思考中...".to_string()
82        }
83    } else {
84        String::new()
85    };
86
87    let title_spans = vec![
88        Span::styled(" 💬 ", Style::default().fg(t.title_icon)),
89        Span::styled(
90            "AI Chat",
91            Style::default()
92                .fg(t.text_white)
93                .add_modifier(Modifier::BOLD),
94        ),
95        Span::styled("  │  ", Style::default().fg(t.title_separator)),
96        Span::styled("🤖 ", Style::default()),
97        Span::styled(
98            model_name,
99            Style::default()
100                .fg(t.title_model)
101                .add_modifier(Modifier::BOLD),
102        ),
103        Span::styled("  │  ", Style::default().fg(t.title_separator)),
104        Span::styled(
105            format!("📨 {} 条消息", msg_count),
106            Style::default().fg(t.title_count),
107        ),
108        Span::styled(
109            loading,
110            Style::default()
111                .fg(t.title_loading)
112                .add_modifier(Modifier::BOLD),
113        ),
114    ];
115
116    let title_block = Paragraph::new(Line::from(title_spans)).block(
117        Block::default()
118            .borders(Borders::ALL)
119            .border_type(ratatui::widgets::BorderType::Rounded)
120            .border_style(Style::default().fg(t.border_title))
121            .style(Style::default().bg(t.bg_title)),
122    );
123    f.render_widget(title_block, area);
124}
125
126/// 绘制消息区
127pub fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
128    let t = &app.theme;
129    let block = Block::default()
130        .borders(Borders::ALL)
131        .border_type(ratatui::widgets::BorderType::Rounded)
132        .border_style(Style::default().fg(t.border_message))
133        .title(Span::styled(
134            " 对话记录 ",
135            Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD),
136        ))
137        .title_alignment(ratatui::layout::Alignment::Left)
138        .style(Style::default().bg(t.bg_primary));
139
140    // 空消息时显示欢迎界面
141    if app.session.messages.is_empty() && !app.is_loading {
142        let welcome_lines = vec![
143            Line::from(""),
144            Line::from(""),
145            Line::from(Span::styled(
146                "  ╭──────────────────────────────────────╮",
147                Style::default().fg(t.welcome_border),
148            )),
149            Line::from(Span::styled(
150                "  │                                      │",
151                Style::default().fg(t.welcome_border),
152            )),
153            Line::from(vec![
154                Span::styled("  │     ", Style::default().fg(t.welcome_border)),
155                Span::styled(
156                    "Hi! What can I help you?  ",
157                    Style::default().fg(t.welcome_text),
158                ),
159                Span::styled("     │", Style::default().fg(t.welcome_border)),
160            ]),
161            Line::from(Span::styled(
162                "  │                                      │",
163                Style::default().fg(t.welcome_border),
164            )),
165            Line::from(Span::styled(
166                "  │     Type a message, press Enter      │",
167                Style::default().fg(t.welcome_hint),
168            )),
169            Line::from(Span::styled(
170                "  │                                      │",
171                Style::default().fg(t.welcome_border),
172            )),
173            Line::from(Span::styled(
174                "  ╰──────────────────────────────────────╯",
175                Style::default().fg(t.welcome_border),
176            )),
177        ];
178        let empty = Paragraph::new(welcome_lines).block(block);
179        f.render_widget(empty, area);
180        return;
181    }
182
183    // 内部可用宽度(减去边框和左右各1的 padding)
184    let inner_width = area.width.saturating_sub(4) as usize;
185    // 消息内容最大宽度为可用宽度的 75%
186    let bubble_max_width = (inner_width * 75 / 100).max(20);
187
188    let msg_count = app.session.messages.len();
189    let last_msg_len = app
190        .session
191        .messages
192        .last()
193        .map(|m| m.content.len())
194        .unwrap_or(0);
195    let streaming_len = app.streaming_content.lock().unwrap().len();
196    let current_browse_index = if app.mode == ChatMode::Browse {
197        Some(app.browse_msg_index)
198    } else {
199        None
200    };
201    let current_tool_confirm_idx = if app.mode == ChatMode::ToolConfirm {
202        Some(app.pending_tool_idx)
203    } else {
204        None
205    };
206    let cache_hit = if let Some(ref cache) = app.msg_lines_cache {
207        cache.msg_count == msg_count
208            && cache.last_msg_len == last_msg_len
209            && cache.streaming_len == streaming_len
210            && cache.is_loading == app.is_loading
211            && cache.bubble_max_width == bubble_max_width
212            && cache.browse_index == current_browse_index
213            && cache.tool_confirm_idx == current_tool_confirm_idx
214    } else {
215        false
216    };
217
218    if !cache_hit {
219        let old_cache = app.msg_lines_cache.take();
220        let (new_lines, new_msg_start_lines, new_per_msg, new_stable_lines, new_stable_offset) =
221            build_message_lines_incremental(app, inner_width, bubble_max_width, old_cache.as_ref());
222        app.msg_lines_cache = Some(MsgLinesCache {
223            msg_count,
224            last_msg_len,
225            streaming_len,
226            is_loading: app.is_loading,
227            bubble_max_width,
228            browse_index: current_browse_index,
229            tool_confirm_idx: current_tool_confirm_idx,
230            lines: new_lines,
231            msg_start_lines: new_msg_start_lines,
232            per_msg_lines: new_per_msg,
233            streaming_stable_lines: new_stable_lines,
234            streaming_stable_offset: new_stable_offset,
235        });
236    }
237
238    let cached = app.msg_lines_cache.as_ref().unwrap();
239    let all_lines = &cached.lines;
240    let total_lines = all_lines.len() as u16;
241
242    f.render_widget(block, area);
243
244    let inner = area.inner(ratatui::layout::Margin {
245        vertical: 1,
246        horizontal: 1,
247    });
248    let visible_height = inner.height;
249    let max_scroll = total_lines.saturating_sub(visible_height);
250
251    if app.mode != ChatMode::Browse {
252        if app.mode == ChatMode::ToolConfirm {
253            // ToolConfirm 模式下强制滚动到底部,确保确认区可见
254            app.scroll_offset = max_scroll;
255            app.auto_scroll = true;
256        } else if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
257            app.scroll_offset = max_scroll;
258            app.auto_scroll = true;
259        }
260    } else {
261        if let Some(msg_start) = cached
262            .msg_start_lines
263            .iter()
264            .find(|(idx, _)| *idx == app.browse_msg_index)
265            .map(|(_, line)| *line as u16)
266        {
267            let msg_line_count = cached
268                .per_msg_lines
269                .get(app.browse_msg_index)
270                .map(|c| c.lines.len())
271                .unwrap_or(1) as u16;
272            let msg_max_scroll = msg_line_count.saturating_sub(visible_height);
273            if app.browse_scroll_offset > msg_max_scroll {
274                app.browse_scroll_offset = msg_max_scroll;
275            }
276            app.scroll_offset = (msg_start + app.browse_scroll_offset).min(max_scroll);
277        }
278    }
279
280    let bg_fill = Block::default().style(Style::default().bg(app.theme.bg_primary));
281    f.render_widget(bg_fill, inner);
282
283    let start = app.scroll_offset as usize;
284    let end = (start + visible_height as usize).min(all_lines.len());
285    let msg_area_bg = Style::default().bg(app.theme.bg_primary);
286    for (i, line_idx) in (start..end).enumerate() {
287        let line = &all_lines[line_idx];
288        let y = inner.y + i as u16;
289        let line_area = Rect::new(inner.x, y, inner.width, 1);
290        let p = Paragraph::new(line.clone()).style(msg_area_bg);
291        f.render_widget(p, line_area);
292    }
293}
294
295pub fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
296    let t = &app.theme;
297    let usable_width = area.width.saturating_sub(2 + 4) as usize;
298
299    let chars: Vec<char> = app.input.chars().collect();
300
301    let before_all: String = chars[..app.cursor_pos].iter().collect();
302    let before_width = display_width(&before_all);
303
304    let scroll_offset_chars = if before_width >= usable_width {
305        let target_width = before_width.saturating_sub(usable_width / 2);
306        let mut w = 0;
307        let mut skip = 0;
308        for (i, &ch) in chars.iter().enumerate() {
309            if w >= target_width {
310                skip = i;
311                break;
312            }
313            w += char_width(ch);
314        }
315        skip
316    } else {
317        0
318    };
319
320    let visible_chars = &chars[scroll_offset_chars..];
321    let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
322
323    let before: String = visible_chars[..cursor_in_visible].iter().collect();
324    let cursor_ch = if cursor_in_visible < visible_chars.len() {
325        visible_chars[cursor_in_visible].to_string()
326    } else {
327        " ".to_string()
328    };
329    let after: String = if cursor_in_visible < visible_chars.len() {
330        visible_chars[cursor_in_visible + 1..].iter().collect()
331    } else {
332        String::new()
333    };
334
335    let prompt_style = if app.is_loading {
336        Style::default().fg(t.input_prompt_loading)
337    } else {
338        Style::default().fg(t.input_prompt)
339    };
340    let prompt_text = if app.is_loading { " .. " } else { " >  " };
341
342    let full_visible = format!("{}{}{}", before, cursor_ch, after);
343    let inner_height = area.height.saturating_sub(2) as usize;
344    let wrapped_lines = wrap_text(&full_visible, usable_width);
345
346    let before_len = before.chars().count();
347    let cursor_len = cursor_ch.chars().count();
348    let cursor_global_pos = before_len;
349    let mut cursor_line_idx: usize = 0;
350    {
351        let mut cumulative = 0usize;
352        for (li, wl) in wrapped_lines.iter().enumerate() {
353            let line_char_count = wl.chars().count();
354            if cumulative + line_char_count > cursor_global_pos {
355                cursor_line_idx = li;
356                break;
357            }
358            cumulative += line_char_count;
359            cursor_line_idx = li;
360        }
361    }
362
363    let line_scroll = if wrapped_lines.len() <= inner_height {
364        0
365    } else if cursor_line_idx < inner_height {
366        0
367    } else {
368        cursor_line_idx.saturating_sub(inner_height - 1)
369    };
370
371    // 计算 @mention 高亮范围(基于全局 char index, 相对于 input 起始)
372    let skill_names: Vec<String> = app
373        .loaded_skills
374        .iter()
375        .map(|s| s.frontmatter.name.clone())
376        .collect();
377    let mention_ranges = find_at_mention_ranges(&app.input, &skill_names);
378    // 转换为相对于 scroll_offset_chars 的偏移
379    let mention_style = Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD);
380
381    let mut display_lines: Vec<Line> = Vec::new();
382    let mut char_offset: usize = 0;
383    for wl in wrapped_lines.iter().take(line_scroll) {
384        char_offset += wl.chars().count();
385    }
386
387    for (_line_idx, wl) in wrapped_lines
388        .iter()
389        .skip(line_scroll)
390        .enumerate()
391        .take(inner_height.max(1))
392    {
393        let mut spans: Vec<Span> = Vec::new();
394        if _line_idx == 0 && line_scroll == 0 {
395            spans.push(Span::styled(prompt_text, prompt_style));
396        } else {
397            spans.push(Span::styled("    ", Style::default()));
398        }
399
400        let line_chars: Vec<char> = wl.chars().collect();
401        let mut seg_start = 0;
402        for (ci, &ch) in line_chars.iter().enumerate() {
403            let global_idx = scroll_offset_chars + char_offset + ci;
404            let visible_idx = char_offset + ci;
405            let is_cursor = visible_idx >= before_len && visible_idx < before_len + cursor_len;
406            let is_mention = mention_ranges
407                .iter()
408                .any(|&(s, e)| global_idx >= s && global_idx < e);
409
410            if is_cursor || (is_mention && !is_cursor) {
411                // flush normal or mention segment before this char
412                if ci > seg_start {
413                    let seg: String = line_chars[seg_start..ci].iter().collect();
414                    // check if previous segment was in mention range
415                    let prev_global = scroll_offset_chars + char_offset + seg_start;
416                    let prev_is_mention = mention_ranges
417                        .iter()
418                        .any(|&(s, e)| prev_global >= s && prev_global < e);
419                    let seg_style = if prev_is_mention {
420                        mention_style
421                    } else {
422                        Style::default().fg(t.text_white)
423                    };
424                    spans.push(Span::styled(seg, seg_style));
425                }
426                if is_cursor {
427                    spans.push(Span::styled(
428                        ch.to_string(),
429                        Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
430                    ));
431                } else {
432                    spans.push(Span::styled(ch.to_string(), mention_style));
433                }
434                seg_start = ci + 1;
435            } else if ci > seg_start {
436                // check if we just transitioned from mention to non-mention
437                let prev_global = scroll_offset_chars + char_offset + (ci - 1);
438                let prev_is_mention = mention_ranges
439                    .iter()
440                    .any(|&(s, e)| prev_global >= s && prev_global < e);
441                let curr_is_mention = is_mention;
442                if prev_is_mention != curr_is_mention {
443                    let seg: String = line_chars[seg_start..ci].iter().collect();
444                    let seg_style = if prev_is_mention {
445                        mention_style
446                    } else {
447                        Style::default().fg(t.text_white)
448                    };
449                    spans.push(Span::styled(seg, seg_style));
450                    seg_start = ci;
451                }
452            }
453        }
454        if seg_start < line_chars.len() {
455            let seg: String = line_chars[seg_start..].iter().collect();
456            let seg_global = scroll_offset_chars + char_offset + seg_start;
457            let seg_is_mention = mention_ranges
458                .iter()
459                .any(|&(s, e)| seg_global >= s && seg_global < e);
460            let seg_style = if seg_is_mention {
461                mention_style
462            } else {
463                Style::default().fg(t.text_white)
464            };
465            spans.push(Span::styled(seg, seg_style));
466        }
467
468        char_offset += line_chars.len();
469        display_lines.push(Line::from(spans));
470    }
471
472    if display_lines.is_empty() {
473        display_lines.push(Line::from(vec![
474            Span::styled(prompt_text, prompt_style),
475            Span::styled(" ", Style::default().fg(t.cursor_fg).bg(t.cursor_bg)),
476        ]));
477    }
478
479    let input_widget = Paragraph::new(display_lines).block(
480        Block::default()
481            .borders(Borders::ALL)
482            .border_type(ratatui::widgets::BorderType::Rounded)
483            .border_style(if app.is_loading {
484                Style::default().fg(t.border_input_loading)
485            } else {
486                Style::default().fg(t.border_input)
487            })
488            .title(Span::styled(" 输入消息 ", Style::default().fg(t.text_dim)))
489            .style(Style::default().bg(t.bg_input)),
490    );
491
492    f.render_widget(input_widget, area);
493
494    if !app.is_loading {
495        let prompt_w: u16 = 4;
496        let border_left: u16 = 1;
497
498        let cursor_col_in_line = {
499            let mut col = 0usize;
500            let mut char_count = 0usize;
501            let mut skip_chars = 0usize;
502            for wl in wrapped_lines.iter().take(line_scroll) {
503                skip_chars += wl.chars().count();
504            }
505            for wl in wrapped_lines.iter().skip(line_scroll) {
506                let line_len = wl.chars().count();
507                if skip_chars + char_count + line_len > cursor_global_pos {
508                    let pos_in_line = cursor_global_pos - (skip_chars + char_count);
509                    col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
510                    break;
511                }
512                char_count += line_len;
513            }
514            col as u16
515        };
516
517        let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
518        let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
519        let cursor_y = area.y + 1 + cursor_row_in_display;
520
521        if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
522            f.set_cursor_position((cursor_x, cursor_y));
523        }
524    }
525}
526
527/// 绘制底部操作提示栏(始终可见)
528pub fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
529    let t = &app.theme;
530    let hints = match app.mode {
531        ChatMode::Chat => vec![
532            ("Enter", "发送"),
533            ("↑↓", "滚动"),
534            ("@", "技能"),
535            ("Ctrl+T", "切换模型"),
536            ("Ctrl+L", "归档"),
537            ("Ctrl+R", "还原"),
538            ("Ctrl+Y", "复制"),
539            ("Ctrl+B", "浏览"),
540            ("Ctrl+S", "流式切换"),
541            ("Ctrl+E", "配置"),
542            ("?/F1", "帮助"),
543            ("Esc", "退出"),
544        ],
545        ChatMode::SelectModel => vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")],
546        ChatMode::Browse => vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")],
547        ChatMode::Help => vec![("任意键", "返回")],
548        ChatMode::Config => vec![
549            ("↑↓", "切换字段"),
550            ("Enter", "编辑"),
551            ("Tab", "切换 Provider"),
552            ("a", "新增"),
553            ("d", "删除"),
554            ("Esc", "保存返回"),
555        ],
556        ChatMode::ArchiveConfirm => {
557            if app.archive_editing_name {
558                vec![("Enter", "确认"), ("Esc", "取消")]
559            } else {
560                vec![
561                    ("Enter", "默认名称归档"),
562                    ("n", "自定义名称"),
563                    ("Esc", "取消"),
564                ]
565            }
566        }
567        ChatMode::ArchiveList => {
568            if app.restore_confirm_needed {
569                vec![("y/Enter", "确认还原"), ("Esc", "取消")]
570            } else {
571                vec![
572                    ("↑↓/jk", "选择"),
573                    ("Enter", "还原"),
574                    ("d", "删除"),
575                    ("Esc", "返回"),
576                ]
577            }
578        }
579        ChatMode::ToolConfirm => vec![("Y", "执行工具"), ("N/Esc", "拒绝")],
580    };
581
582    let mut spans: Vec<Span> = Vec::new();
583    spans.push(Span::styled(" ", Style::default()));
584    for (i, (key, desc)) in hints.iter().enumerate() {
585        if i > 0 {
586            spans.push(Span::styled("  │  ", Style::default().fg(t.hint_separator)));
587        }
588        spans.push(Span::styled(
589            format!(" {} ", key),
590            Style::default().fg(t.hint_key_fg).bg(t.hint_key_bg),
591        ));
592        spans.push(Span::styled(
593            format!(" {}", desc),
594            Style::default().fg(t.hint_desc),
595        ));
596    }
597
598    let hint_bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_primary));
599    f.render_widget(hint_bar, area);
600}
601
602/// 绘制 Toast 弹窗(右上角浮层)
603pub fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
604    let t = &app.theme;
605    if let Some((ref msg, is_error, _)) = app.toast {
606        let text_width = display_width(msg);
607        let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
608        let toast_height: u16 = 3;
609
610        let x = area.width.saturating_sub(toast_width + 1);
611        let y: u16 = 1;
612
613        if x + toast_width <= area.width && y + toast_height <= area.height {
614            let toast_area = Rect::new(x, y, toast_width, toast_height);
615
616            let clear = Block::default().style(Style::default().bg(if is_error {
617                t.toast_error_bg
618            } else {
619                t.toast_success_bg
620            }));
621            f.render_widget(clear, toast_area);
622
623            let (icon, border_color, text_color) = if is_error {
624                ("❌", t.toast_error_border, t.toast_error_text)
625            } else {
626                ("✅", t.toast_success_border, t.toast_success_text)
627            };
628
629            let toast_widget = Paragraph::new(Line::from(vec![
630                Span::styled(format!(" {} ", icon), Style::default()),
631                Span::styled(msg.as_str(), Style::default().fg(text_color)),
632            ]))
633            .block(
634                Block::default()
635                    .borders(Borders::ALL)
636                    .border_type(ratatui::widgets::BorderType::Rounded)
637                    .border_style(Style::default().fg(border_color))
638                    .style(Style::default().bg(if is_error {
639                        t.toast_error_bg
640                    } else {
641                        t.toast_success_bg
642                    })),
643            );
644            f.render_widget(toast_widget, toast_area);
645        }
646    }
647}
648
649/// 绘制模型选择界面
650pub fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
651    let t = &app.theme;
652    let items: Vec<ListItem> = app
653        .agent_config
654        .providers
655        .iter()
656        .enumerate()
657        .map(|(i, p)| {
658            let is_active = i == app.agent_config.active_index;
659            let marker = if is_active { " ● " } else { " ○ " };
660            let style = if is_active {
661                Style::default()
662                    .fg(t.model_sel_active)
663                    .add_modifier(Modifier::BOLD)
664            } else {
665                Style::default().fg(t.model_sel_inactive)
666            };
667            let detail = format!("{}{}  ({})", marker, p.name, p.model);
668            ListItem::new(Line::from(Span::styled(detail, style)))
669        })
670        .collect();
671
672    let list = List::new(items)
673        .block(
674            Block::default()
675                .borders(Borders::ALL)
676                .border_type(ratatui::widgets::BorderType::Rounded)
677                .border_style(Style::default().fg(t.model_sel_border))
678                .title(Span::styled(
679                    " 🔄 选择模型 ",
680                    Style::default()
681                        .fg(t.model_sel_title)
682                        .add_modifier(Modifier::BOLD),
683                ))
684                .style(Style::default().bg(t.bg_title)),
685        )
686        .highlight_style(
687            Style::default()
688                .bg(t.model_sel_highlight_bg)
689                .fg(t.text_white)
690                .add_modifier(Modifier::BOLD),
691        )
692        .highlight_symbol("  ▸ ");
693
694    f.render_stateful_widget(list, area, &mut app.model_list_state);
695}
696
697/// 绘制帮助界面
698pub fn draw_help(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
699    let t = &app.theme;
700    let separator = Line::from(Span::styled(
701        "  ─────────────────────────────────────────",
702        Style::default().fg(t.separator),
703    ));
704
705    let help_lines = vec![
706        Line::from(""),
707        Line::from(Span::styled(
708            "  📖 快捷键帮助",
709            Style::default()
710                .fg(t.help_title)
711                .add_modifier(Modifier::BOLD),
712        )),
713        Line::from(""),
714        separator.clone(),
715        Line::from(""),
716        Line::from(vec![
717            Span::styled(
718                "  Enter        ",
719                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
720            ),
721            Span::styled("发送消息", Style::default().fg(t.help_desc)),
722        ]),
723        Line::from(vec![
724            Span::styled(
725                "  ↑ / ↓        ",
726                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
727            ),
728            Span::styled("滚动对话记录", Style::default().fg(t.help_desc)),
729        ]),
730        Line::from(vec![
731            Span::styled(
732                "  ← / →        ",
733                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
734            ),
735            Span::styled("移动输入光标", Style::default().fg(t.help_desc)),
736        ]),
737        Line::from(vec![
738            Span::styled(
739                "  Ctrl+T       ",
740                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
741            ),
742            Span::styled("切换模型", Style::default().fg(t.help_desc)),
743        ]),
744        Line::from(vec![
745            Span::styled(
746                "  Ctrl+L       ",
747                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
748            ),
749            Span::styled("归档当前对话", Style::default().fg(t.help_desc)),
750        ]),
751        Line::from(vec![
752            Span::styled(
753                "  Ctrl+R       ",
754                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
755            ),
756            Span::styled("还原归档对话", Style::default().fg(t.help_desc)),
757        ]),
758        Line::from(vec![
759            Span::styled(
760                "  Ctrl+Y       ",
761                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
762            ),
763            Span::styled("复制最后一条 AI 回复", Style::default().fg(t.help_desc)),
764        ]),
765        Line::from(vec![
766            Span::styled(
767                "  Ctrl+B       ",
768                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
769            ),
770            Span::styled(
771                "浏览消息 (↑↓选择, y/Enter复制)",
772                Style::default().fg(t.help_desc),
773            ),
774        ]),
775        Line::from(vec![
776            Span::styled(
777                "  Ctrl+S       ",
778                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
779            ),
780            Span::styled("切换流式/整体输出", Style::default().fg(t.help_desc)),
781        ]),
782        Line::from(vec![
783            Span::styled(
784                "  Ctrl+E       ",
785                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
786            ),
787            Span::styled("打开配置界面", Style::default().fg(t.help_desc)),
788        ]),
789        Line::from(vec![
790            Span::styled(
791                "  Esc / Ctrl+C ",
792                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
793            ),
794            Span::styled("退出对话", Style::default().fg(t.help_desc)),
795        ]),
796        Line::from(vec![
797            Span::styled(
798                "  ? / F1       ",
799                Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
800            ),
801            Span::styled("显示 / 关闭此帮助", Style::default().fg(t.help_desc)),
802        ]),
803        Line::from(""),
804        separator,
805        Line::from(""),
806        Line::from(Span::styled(
807            "  📁 配置文件:",
808            Style::default()
809                .fg(t.help_title)
810                .add_modifier(Modifier::BOLD),
811        )),
812        Line::from(Span::styled(
813            format!("     {}", agent_config_path().display()),
814            Style::default().fg(t.help_path),
815        )),
816    ];
817
818    let help_block = Block::default()
819        .borders(Borders::ALL)
820        .border_type(ratatui::widgets::BorderType::Rounded)
821        .border_style(Style::default().fg(t.border_title))
822        .title(Span::styled(
823            " 帮助 (按任意键返回) ",
824            Style::default().fg(t.text_dim),
825        ))
826        .style(Style::default().bg(t.help_bg));
827    let help_widget = Paragraph::new(help_lines).block(help_block);
828    f.render_widget(help_widget, area);
829}
830
831/// 绘制 @ 补全弹窗(输入区域上方浮动)
832pub fn draw_at_popup(f: &mut ratatui::Frame, input_area: Rect, app: &ChatApp) {
833    let t = &app.theme;
834    let filtered = get_filtered_skills(app);
835    if filtered.is_empty() {
836        return;
837    }
838
839    let item_count = filtered.len().min(8); // 最多显示 8 项
840    let popup_height = (item_count as u16) + 2; // 加上边框
841    let popup_width = filtered
842        .iter()
843        .map(|n| display_width(&format!("  @{}  ", n)))
844        .max()
845        .unwrap_or(20)
846        .max(16)
847        .min(input_area.width.saturating_sub(4) as usize) as u16
848        + 2; // 加边框
849
850    // 弹窗位置:输入区域上方
851    let x = input_area.x + 1;
852    let y = input_area.y.saturating_sub(popup_height);
853
854    let popup_area = Rect::new(x, y, popup_width, popup_height);
855
856    let items: Vec<ListItem> = filtered
857        .iter()
858        .enumerate()
859        .take(item_count)
860        .map(|(i, name)| {
861            let style = if i == app.at_popup_selected {
862                Style::default()
863                    .bg(t.model_sel_highlight_bg)
864                    .fg(t.text_white)
865                    .add_modifier(Modifier::BOLD)
866            } else {
867                Style::default().fg(t.label_ai)
868            };
869            ListItem::new(Line::from(Span::styled(format!("  @{}  ", name), style)))
870        })
871        .collect();
872
873    let list = List::new(items).block(
874        Block::default()
875            .borders(Borders::ALL)
876            .border_type(ratatui::widgets::BorderType::Rounded)
877            .border_style(Style::default().fg(t.border_title))
878            .title(Span::styled(
879                " Skills ",
880                Style::default().fg(t.label_ai).add_modifier(Modifier::BOLD),
881            ))
882            .style(Style::default().bg(t.bg_title)),
883    );
884
885    f.render_widget(Clear, popup_area);
886    f.render_widget(list, popup_area);
887}
888
889/// 查找输入文本中所有 @mention 的字符范围 (start_char_idx, end_char_idx)
890fn find_at_mention_ranges(text: &str, skill_names: &[String]) -> Vec<(usize, usize)> {
891    let mut ranges = Vec::new();
892    let chars: Vec<char> = text.chars().collect();
893    let len = chars.len();
894    let mut i = 0;
895
896    while i < len {
897        if chars[i] == '@' {
898            let valid_start = i == 0 || chars[i - 1].is_whitespace();
899            if valid_start {
900                let rest: String = chars[i + 1..].iter().collect();
901                let mut best_len = 0usize;
902                for name in skill_names {
903                    if rest.starts_with(name.as_str()) {
904                        let after_pos = name.len();
905                        let is_boundary = if after_pos >= rest.len() {
906                            true
907                        } else {
908                            let next_ch = rest.chars().nth(after_pos).unwrap();
909                            next_ch.is_whitespace() || !next_ch.is_alphanumeric()
910                        };
911                        if is_boundary && name.len() > best_len {
912                            best_len = name.len();
913                        }
914                    }
915                }
916                if best_len > 0 {
917                    // @ + name
918                    ranges.push((i, i + 1 + best_len));
919                    i += 1 + best_len;
920                    continue;
921                }
922            }
923        }
924        i += 1;
925    }
926
927    ranges
928}