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 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), Constraint::Min(5), Constraint::Length(5), Constraint::Length(1), ])
29 .split(size);
30
31 draw_title_bar(f, chunks[0], app);
33
34 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 draw_input(f, chunks[2], app);
53
54 draw_hint_bar(f, chunks[3], app);
56
57 draw_toast(f, size, app);
59
60 if app.at_popup_active {
62 draw_at_popup(f, chunks[2], app);
63 }
64}
65
66pub 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 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
126pub 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 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 let inner_width = area.width.saturating_sub(4) as usize;
185 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 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 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 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 if ci > seg_start {
413 let seg: String = line_chars[seg_start..ci].iter().collect();
414 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 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
527pub 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
602pub 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
649pub 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
697pub 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
831pub 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); let popup_height = (item_count as u16) + 2; 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; 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
889fn 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 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}