1use super::app::{CONFIG_FIELDS, CONFIG_GLOBAL_FIELDS, ChatApp, ChatMode, MsgLinesCache};
2use super::handler::{config_field_label, config_field_value};
3use super::model::agent_config_path;
4use super::render::{build_message_lines_incremental, char_width, display_width, wrap_text};
5use ratatui::{
6 layout::{Constraint, Direction, Layout, Rect},
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
10};
11
12pub fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
13 let size = f.area();
14
15 let bg = Block::default().style(Style::default().bg(app.theme.bg_primary));
17 f.render_widget(bg, size);
18
19 let chunks = Layout::default()
20 .direction(Direction::Vertical)
21 .constraints([
22 Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), Constraint::Length(1), ])
27 .split(size);
28
29 draw_title_bar(f, chunks[0], app);
31
32 if app.mode == ChatMode::Help {
34 draw_help(f, chunks[1], app);
35 } else if app.mode == ChatMode::SelectModel {
36 draw_model_selector(f, chunks[1], app);
37 } else if app.mode == ChatMode::Config {
38 draw_config_screen(f, chunks[1], app);
39 } else if app.mode == ChatMode::ArchiveConfirm {
40 draw_archive_confirm(f, chunks[1], app);
41 } else if app.mode == ChatMode::ArchiveList {
42 draw_archive_list(f, chunks[1], app);
43 } else {
44 draw_messages(f, chunks[1], app);
45 }
46
47 draw_input(f, chunks[2], app);
49
50 draw_hint_bar(f, chunks[3], app);
52
53 draw_toast(f, size, app);
55}
56
57pub fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
59 let t = &app.theme;
60 let model_name = app.active_model_name();
61 let msg_count = app.session.messages.len();
62 let loading = if app.is_loading {
63 " ⏳ 思考中..."
64 } else {
65 ""
66 };
67
68 let title_spans = vec![
69 Span::styled(" 💬 ", Style::default().fg(t.title_icon)),
70 Span::styled(
71 "AI Chat",
72 Style::default()
73 .fg(t.text_white)
74 .add_modifier(Modifier::BOLD),
75 ),
76 Span::styled(" │ ", Style::default().fg(t.title_separator)),
77 Span::styled("🤖 ", Style::default()),
78 Span::styled(
79 model_name,
80 Style::default()
81 .fg(t.title_model)
82 .add_modifier(Modifier::BOLD),
83 ),
84 Span::styled(" │ ", Style::default().fg(t.title_separator)),
85 Span::styled(
86 format!("📨 {} 条消息", msg_count),
87 Style::default().fg(t.title_count),
88 ),
89 Span::styled(
90 loading,
91 Style::default()
92 .fg(t.title_loading)
93 .add_modifier(Modifier::BOLD),
94 ),
95 ];
96
97 let title_block = Paragraph::new(Line::from(title_spans)).block(
98 Block::default()
99 .borders(Borders::ALL)
100 .border_type(ratatui::widgets::BorderType::Rounded)
101 .border_style(Style::default().fg(t.border_title))
102 .style(Style::default().bg(t.bg_title)),
103 );
104 f.render_widget(title_block, area);
105}
106
107pub fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
109 let t = &app.theme;
110 let block = Block::default()
111 .borders(Borders::ALL)
112 .border_type(ratatui::widgets::BorderType::Rounded)
113 .border_style(Style::default().fg(t.border_message))
114 .title(Span::styled(
115 " 对话记录 ",
116 Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD),
117 ))
118 .title_alignment(ratatui::layout::Alignment::Left)
119 .style(Style::default().bg(t.bg_primary));
120
121 if app.session.messages.is_empty() && !app.is_loading {
123 let welcome_lines = vec![
124 Line::from(""),
125 Line::from(""),
126 Line::from(Span::styled(
127 " ╭──────────────────────────────────────╮",
128 Style::default().fg(t.welcome_border),
129 )),
130 Line::from(Span::styled(
131 " │ │",
132 Style::default().fg(t.welcome_border),
133 )),
134 Line::from(vec![
135 Span::styled(" │ ", Style::default().fg(t.welcome_border)),
136 Span::styled(
137 "Hi! What can I help you? ",
138 Style::default().fg(t.welcome_text),
139 ),
140 Span::styled(" │", Style::default().fg(t.welcome_border)),
141 ]),
142 Line::from(Span::styled(
143 " │ │",
144 Style::default().fg(t.welcome_border),
145 )),
146 Line::from(Span::styled(
147 " │ Type a message, press Enter │",
148 Style::default().fg(t.welcome_hint),
149 )),
150 Line::from(Span::styled(
151 " │ │",
152 Style::default().fg(t.welcome_border),
153 )),
154 Line::from(Span::styled(
155 " ╰──────────────────────────────────────╯",
156 Style::default().fg(t.welcome_border),
157 )),
158 ];
159 let empty = Paragraph::new(welcome_lines).block(block);
160 f.render_widget(empty, area);
161 return;
162 }
163
164 let inner_width = area.width.saturating_sub(4) as usize;
166 let bubble_max_width = (inner_width * 75 / 100).max(20);
168
169 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();
197 let (new_lines, new_msg_start_lines, new_per_msg, new_stable_lines, new_stable_offset) =
198 build_message_lines_incremental(app, inner_width, bubble_max_width, old_cache.as_ref());
199 app.msg_lines_cache = Some(MsgLinesCache {
200 msg_count,
201 last_msg_len,
202 streaming_len,
203 is_loading: app.is_loading,
204 bubble_max_width,
205 browse_index: current_browse_index,
206 lines: new_lines,
207 msg_start_lines: new_msg_start_lines,
208 per_msg_lines: new_per_msg,
209 streaming_stable_lines: new_stable_lines,
210 streaming_stable_offset: new_stable_offset,
211 });
212 }
213
214 let cached = app.msg_lines_cache.as_ref().unwrap();
216 let all_lines = &cached.lines;
217 let total_lines = all_lines.len() as u16;
218
219 f.render_widget(block, area);
221
222 let inner = area.inner(ratatui::layout::Margin {
224 vertical: 1,
225 horizontal: 1,
226 });
227 let visible_height = inner.height;
228 let max_scroll = total_lines.saturating_sub(visible_height);
229
230 if app.mode != ChatMode::Browse {
232 if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
233 app.scroll_offset = max_scroll;
234 app.auto_scroll = true;
236 }
237 } else {
238 if let Some(target_line) = cached
240 .msg_start_lines
241 .iter()
242 .find(|(idx, _)| *idx == app.browse_msg_index)
243 .map(|(_, line)| *line as u16)
244 {
245 if target_line < app.scroll_offset {
247 app.scroll_offset = target_line;
248 } else if target_line >= app.scroll_offset + visible_height {
249 app.scroll_offset = target_line.saturating_sub(visible_height / 3);
250 }
251 if app.scroll_offset > max_scroll {
253 app.scroll_offset = max_scroll;
254 }
255 }
256 }
257
258 let bg_fill = Block::default().style(Style::default().bg(app.theme.bg_primary));
260 f.render_widget(bg_fill, inner);
261
262 let start = app.scroll_offset as usize;
264 let end = (start + visible_height as usize).min(all_lines.len());
265 let msg_area_bg = Style::default().bg(app.theme.bg_primary);
266 for (i, line_idx) in (start..end).enumerate() {
267 let line = &all_lines[line_idx];
268 let y = inner.y + i as u16;
269 let line_area = Rect::new(inner.x, y, inner.width, 1);
270 let p = Paragraph::new(line.clone()).style(msg_area_bg);
272 f.render_widget(p, line_area);
273 }
274}
275
276pub fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
280 let t = &app.theme;
281 let usable_width = area.width.saturating_sub(2 + 4) as usize;
283
284 let chars: Vec<char> = app.input.chars().collect();
285
286 let before_all: String = chars[..app.cursor_pos].iter().collect();
288 let before_width = display_width(&before_all);
289
290 let scroll_offset_chars = if before_width >= usable_width {
292 let target_width = before_width.saturating_sub(usable_width / 2);
294 let mut w = 0;
295 let mut skip = 0;
296 for (i, &ch) in chars.iter().enumerate() {
297 if w >= target_width {
298 skip = i;
299 break;
300 }
301 w += char_width(ch);
302 }
303 skip
304 } else {
305 0
306 };
307
308 let visible_chars = &chars[scroll_offset_chars..];
310 let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
311
312 let before: String = visible_chars[..cursor_in_visible].iter().collect();
313 let cursor_ch = if cursor_in_visible < visible_chars.len() {
314 visible_chars[cursor_in_visible].to_string()
315 } else {
316 " ".to_string()
317 };
318 let after: String = if cursor_in_visible < visible_chars.len() {
319 visible_chars[cursor_in_visible + 1..].iter().collect()
320 } else {
321 String::new()
322 };
323
324 let prompt_style = if app.is_loading {
325 Style::default().fg(t.input_prompt_loading)
326 } else {
327 Style::default().fg(t.input_prompt)
328 };
329 let prompt_text = if app.is_loading { " .. " } else { " > " };
330
331 let full_visible = format!("{}{}{}", before, cursor_ch, after);
333 let inner_height = area.height.saturating_sub(2) as usize; let wrapped_lines = wrap_text(&full_visible, usable_width);
335
336 let before_len = before.chars().count();
338 let cursor_len = cursor_ch.chars().count();
339 let cursor_global_pos = before_len; let mut cursor_line_idx: usize = 0;
341 {
342 let mut cumulative = 0usize;
343 for (li, wl) in wrapped_lines.iter().enumerate() {
344 let line_char_count = wl.chars().count();
345 if cumulative + line_char_count > cursor_global_pos {
346 cursor_line_idx = li;
347 break;
348 }
349 cumulative += line_char_count;
350 cursor_line_idx = li; }
352 }
353
354 let line_scroll = if wrapped_lines.len() <= inner_height {
356 0
357 } else if cursor_line_idx < inner_height {
358 0
359 } else {
360 cursor_line_idx.saturating_sub(inner_height - 1)
362 };
363
364 let mut display_lines: Vec<Line> = Vec::new();
366 let mut char_offset: usize = 0;
367 for wl in wrapped_lines.iter().take(line_scroll) {
369 char_offset += wl.chars().count();
370 }
371
372 for (_line_idx, wl) in wrapped_lines
373 .iter()
374 .skip(line_scroll)
375 .enumerate()
376 .take(inner_height.max(1))
377 {
378 let mut spans: Vec<Span> = Vec::new();
379 if _line_idx == 0 && line_scroll == 0 {
380 spans.push(Span::styled(prompt_text, prompt_style));
381 } else {
382 spans.push(Span::styled(" ", Style::default())); }
384
385 let line_chars: Vec<char> = wl.chars().collect();
387 let mut seg_start = 0;
388 for (ci, &ch) in line_chars.iter().enumerate() {
389 let global_idx = char_offset + ci;
390 let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
391
392 if is_cursor {
393 if ci > seg_start {
395 let seg: String = line_chars[seg_start..ci].iter().collect();
396 spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
397 }
398 spans.push(Span::styled(
399 ch.to_string(),
400 Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
401 ));
402 seg_start = ci + 1;
403 }
404 }
405 if seg_start < line_chars.len() {
407 let seg: String = line_chars[seg_start..].iter().collect();
408 spans.push(Span::styled(seg, Style::default().fg(t.text_white)));
409 }
410
411 char_offset += line_chars.len();
412 display_lines.push(Line::from(spans));
413 }
414
415 if display_lines.is_empty() {
416 display_lines.push(Line::from(vec![
417 Span::styled(prompt_text, prompt_style),
418 Span::styled(" ", Style::default().fg(t.cursor_fg).bg(t.cursor_bg)),
419 ]));
420 }
421
422 let input_widget = Paragraph::new(display_lines).block(
423 Block::default()
424 .borders(Borders::ALL)
425 .border_type(ratatui::widgets::BorderType::Rounded)
426 .border_style(if app.is_loading {
427 Style::default().fg(t.border_input_loading)
428 } else {
429 Style::default().fg(t.border_input)
430 })
431 .title(Span::styled(" 输入消息 ", Style::default().fg(t.text_dim)))
432 .style(Style::default().bg(t.bg_input)),
433 );
434
435 f.render_widget(input_widget, area);
436
437 if !app.is_loading {
440 let prompt_w: u16 = 4; let border_left: u16 = 1; let cursor_col_in_line = {
445 let mut col = 0usize;
446 let mut char_count = 0usize;
447 let mut skip_chars = 0usize;
449 for wl in wrapped_lines.iter().take(line_scroll) {
450 skip_chars += wl.chars().count();
451 }
452 for wl in wrapped_lines.iter().skip(line_scroll) {
454 let line_len = wl.chars().count();
455 if skip_chars + char_count + line_len > cursor_global_pos {
456 let pos_in_line = cursor_global_pos - (skip_chars + char_count);
458 col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
459 break;
460 }
461 char_count += line_len;
462 }
463 col as u16
464 };
465
466 let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
468
469 let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
470 let cursor_y = area.y + 1 + cursor_row_in_display; if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
474 f.set_cursor_position((cursor_x, cursor_y));
475 }
476 }
477}
478
479pub fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
481 let t = &app.theme;
482 let hints = match app.mode {
483 ChatMode::Chat => {
484 vec![
485 ("Enter", "发送"),
486 ("↑↓", "滚动"),
487 ("Ctrl+T", "切换模型"),
488 ("Ctrl+L", "归档"),
489 ("Ctrl+R", "还原"),
490 ("Ctrl+Y", "复制"),
491 ("Ctrl+B", "浏览"),
492 ("Ctrl+S", "流式切换"),
493 ("Ctrl+E", "配置"),
494 ("?/F1", "帮助"),
495 ("Esc", "退出"),
496 ]
497 }
498 ChatMode::SelectModel => {
499 vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
500 }
501 ChatMode::Browse => {
502 vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
503 }
504 ChatMode::Help => {
505 vec![("任意键", "返回")]
506 }
507 ChatMode::Config => {
508 vec![
509 ("↑↓", "切换字段"),
510 ("Enter", "编辑"),
511 ("Tab", "切换 Provider"),
512 ("a", "新增"),
513 ("d", "删除"),
514 ("Esc", "保存返回"),
515 ]
516 }
517 ChatMode::ArchiveConfirm => {
518 if app.archive_editing_name {
519 vec![("Enter", "确认"), ("Esc", "取消")]
520 } else {
521 vec![
522 ("Enter", "默认名称归档"),
523 ("n", "自定义名称"),
524 ("Esc", "取消"),
525 ]
526 }
527 }
528 ChatMode::ArchiveList => {
529 if app.restore_confirm_needed {
530 vec![("y/Enter", "确认还原"), ("Esc", "取消")]
531 } else {
532 vec![
533 ("↑↓/jk", "选择"),
534 ("Enter", "还原"),
535 ("d", "删除"),
536 ("Esc", "返回"),
537 ]
538 }
539 }
540 };
541
542 let mut spans: Vec<Span> = Vec::new();
543 spans.push(Span::styled(" ", Style::default()));
544 for (i, (key, desc)) in hints.iter().enumerate() {
545 if i > 0 {
546 spans.push(Span::styled(" │ ", Style::default().fg(t.hint_separator)));
547 }
548 spans.push(Span::styled(
549 format!(" {} ", key),
550 Style::default().fg(t.hint_key_fg).bg(t.hint_key_bg),
551 ));
552 spans.push(Span::styled(
553 format!(" {}", desc),
554 Style::default().fg(t.hint_desc),
555 ));
556 }
557
558 let hint_bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(t.bg_primary));
559 f.render_widget(hint_bar, area);
560}
561
562pub fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
564 let t = &app.theme;
565 if let Some((ref msg, is_error, _)) = app.toast {
566 let text_width = display_width(msg);
567 let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
569 let toast_height: u16 = 3;
570
571 let x = area.width.saturating_sub(toast_width + 1);
573 let y: u16 = 1;
574
575 if x + toast_width <= area.width && y + toast_height <= area.height {
576 let toast_area = Rect::new(x, y, toast_width, toast_height);
577
578 let clear = Block::default().style(Style::default().bg(if is_error {
580 t.toast_error_bg
581 } else {
582 t.toast_success_bg
583 }));
584 f.render_widget(clear, toast_area);
585
586 let (icon, border_color, text_color) = if is_error {
587 ("❌", t.toast_error_border, t.toast_error_text)
588 } else {
589 ("✅", t.toast_success_border, t.toast_success_text)
590 };
591
592 let toast_widget = Paragraph::new(Line::from(vec![
593 Span::styled(format!(" {} ", icon), Style::default()),
594 Span::styled(msg.as_str(), Style::default().fg(text_color)),
595 ]))
596 .block(
597 Block::default()
598 .borders(Borders::ALL)
599 .border_type(ratatui::widgets::BorderType::Rounded)
600 .border_style(Style::default().fg(border_color))
601 .style(Style::default().bg(if is_error {
602 t.toast_error_bg
603 } else {
604 t.toast_success_bg
605 })),
606 );
607 f.render_widget(toast_widget, toast_area);
608 }
609 }
610}
611
612pub fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
614 let t = &app.theme;
615 let items: Vec<ListItem> = app
616 .agent_config
617 .providers
618 .iter()
619 .enumerate()
620 .map(|(i, p)| {
621 let is_active = i == app.agent_config.active_index;
622 let marker = if is_active { " ● " } else { " ○ " };
623 let style = if is_active {
624 Style::default()
625 .fg(t.model_sel_active)
626 .add_modifier(Modifier::BOLD)
627 } else {
628 Style::default().fg(t.model_sel_inactive)
629 };
630 let detail = format!("{}{} ({})", marker, p.name, p.model);
631 ListItem::new(Line::from(Span::styled(detail, style)))
632 })
633 .collect();
634
635 let list = List::new(items)
636 .block(
637 Block::default()
638 .borders(Borders::ALL)
639 .border_type(ratatui::widgets::BorderType::Rounded)
640 .border_style(Style::default().fg(t.model_sel_border))
641 .title(Span::styled(
642 " 🔄 选择模型 ",
643 Style::default()
644 .fg(t.model_sel_title)
645 .add_modifier(Modifier::BOLD),
646 ))
647 .style(Style::default().bg(t.bg_title)),
648 )
649 .highlight_style(
650 Style::default()
651 .bg(t.model_sel_highlight_bg)
652 .fg(t.text_white)
653 .add_modifier(Modifier::BOLD),
654 )
655 .highlight_symbol(" ▸ ");
656
657 f.render_stateful_widget(list, area, &mut app.model_list_state);
658}
659
660pub fn draw_help(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
662 let t = &app.theme;
663 let separator = Line::from(Span::styled(
664 " ─────────────────────────────────────────",
665 Style::default().fg(t.separator),
666 ));
667
668 let help_lines = vec![
669 Line::from(""),
670 Line::from(Span::styled(
671 " 📖 快捷键帮助",
672 Style::default()
673 .fg(t.help_title)
674 .add_modifier(Modifier::BOLD),
675 )),
676 Line::from(""),
677 separator.clone(),
678 Line::from(""),
679 Line::from(vec![
680 Span::styled(
681 " Enter ",
682 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
683 ),
684 Span::styled("发送消息", Style::default().fg(t.help_desc)),
685 ]),
686 Line::from(vec![
687 Span::styled(
688 " ↑ / ↓ ",
689 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
690 ),
691 Span::styled("滚动对话记录", Style::default().fg(t.help_desc)),
692 ]),
693 Line::from(vec![
694 Span::styled(
695 " ← / → ",
696 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
697 ),
698 Span::styled("移动输入光标", Style::default().fg(t.help_desc)),
699 ]),
700 Line::from(vec![
701 Span::styled(
702 " Ctrl+T ",
703 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
704 ),
705 Span::styled("切换模型", Style::default().fg(t.help_desc)),
706 ]),
707 Line::from(vec![
708 Span::styled(
709 " Ctrl+L ",
710 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
711 ),
712 Span::styled("归档当前对话", Style::default().fg(t.help_desc)),
713 ]),
714 Line::from(vec![
715 Span::styled(
716 " Ctrl+R ",
717 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
718 ),
719 Span::styled("还原归档对话", Style::default().fg(t.help_desc)),
720 ]),
721 Line::from(vec![
722 Span::styled(
723 " Ctrl+Y ",
724 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
725 ),
726 Span::styled("复制最后一条 AI 回复", Style::default().fg(t.help_desc)),
727 ]),
728 Line::from(vec![
729 Span::styled(
730 " Ctrl+B ",
731 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
732 ),
733 Span::styled(
734 "浏览消息 (↑↓选择, y/Enter复制)",
735 Style::default().fg(t.help_desc),
736 ),
737 ]),
738 Line::from(vec![
739 Span::styled(
740 " Ctrl+S ",
741 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
742 ),
743 Span::styled("切换流式/整体输出", Style::default().fg(t.help_desc)),
744 ]),
745 Line::from(vec![
746 Span::styled(
747 " Ctrl+E ",
748 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
749 ),
750 Span::styled("打开配置界面", Style::default().fg(t.help_desc)),
751 ]),
752 Line::from(vec![
753 Span::styled(
754 " Esc / Ctrl+C ",
755 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
756 ),
757 Span::styled("退出对话", Style::default().fg(t.help_desc)),
758 ]),
759 Line::from(vec![
760 Span::styled(
761 " ? / F1 ",
762 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
763 ),
764 Span::styled("显示 / 关闭此帮助", Style::default().fg(t.help_desc)),
765 ]),
766 Line::from(""),
767 separator,
768 Line::from(""),
769 Line::from(Span::styled(
770 " 📁 配置文件:",
771 Style::default()
772 .fg(t.help_title)
773 .add_modifier(Modifier::BOLD),
774 )),
775 Line::from(Span::styled(
776 format!(" {}", agent_config_path().display()),
777 Style::default().fg(t.help_path),
778 )),
779 ];
780
781 let help_block = Block::default()
782 .borders(Borders::ALL)
783 .border_type(ratatui::widgets::BorderType::Rounded)
784 .border_style(Style::default().fg(t.border_title))
785 .title(Span::styled(
786 " 帮助 (按任意键返回) ",
787 Style::default().fg(t.text_dim),
788 ))
789 .style(Style::default().bg(t.help_bg));
790 let help_widget = Paragraph::new(help_lines).block(help_block);
791 f.render_widget(help_widget, area);
792}
793
794pub fn draw_config_screen(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
797 let t = &app.theme;
798 let bg = t.bg_title;
799 let total_provider_fields = CONFIG_FIELDS.len();
800
801 let mut lines: Vec<Line> = Vec::new();
802 lines.push(Line::from(""));
803
804 lines.push(Line::from(vec![Span::styled(
806 " ⚙️ 模型配置",
807 Style::default()
808 .fg(t.config_title)
809 .add_modifier(Modifier::BOLD),
810 )]));
811 lines.push(Line::from(""));
812
813 let provider_count = app.agent_config.providers.len();
815 if provider_count > 0 {
816 let mut tab_spans: Vec<Span> = vec![Span::styled(" ", Style::default())];
817 for (i, p) in app.agent_config.providers.iter().enumerate() {
818 let is_current = i == app.config_provider_idx;
819 let is_active = i == app.agent_config.active_index;
820 let marker = if is_active { "● " } else { "○ " };
821 let label = format!(" {}{} ", marker, p.name);
822 if is_current {
823 tab_spans.push(Span::styled(
824 label,
825 Style::default()
826 .fg(t.config_tab_active_fg)
827 .bg(t.config_tab_active_bg)
828 .add_modifier(Modifier::BOLD),
829 ));
830 } else {
831 tab_spans.push(Span::styled(
832 label,
833 Style::default().fg(t.config_tab_inactive),
834 ));
835 }
836 if i < provider_count - 1 {
837 tab_spans.push(Span::styled(" │ ", Style::default().fg(t.separator)));
838 }
839 }
840 tab_spans.push(Span::styled(
841 " (● = 活跃模型, Tab 切换, s 设为活跃)",
842 Style::default().fg(t.config_dim),
843 ));
844 lines.push(Line::from(tab_spans));
845 } else {
846 lines.push(Line::from(Span::styled(
847 " (无 Provider,按 a 新增)",
848 Style::default().fg(t.config_toggle_off),
849 )));
850 }
851 lines.push(Line::from(""));
852
853 lines.push(Line::from(Span::styled(
855 " ─────────────────────────────────────────",
856 Style::default().fg(t.separator),
857 )));
858 lines.push(Line::from(""));
859
860 if provider_count > 0 {
862 lines.push(Line::from(Span::styled(
863 " 📦 Provider 配置",
864 Style::default()
865 .fg(t.config_section)
866 .add_modifier(Modifier::BOLD),
867 )));
868 lines.push(Line::from(""));
869
870 for i in 0..total_provider_fields {
871 let is_selected = app.config_field_idx == i;
872 let label = config_field_label(i);
873 let value = if app.config_editing && is_selected {
874 app.config_edit_buf.clone()
875 } else {
876 config_field_value(app, i)
877 };
878
879 let pointer = if is_selected { " ▸ " } else { " " };
880 let pointer_style = if is_selected {
881 Style::default().fg(t.config_pointer)
882 } else {
883 Style::default()
884 };
885
886 let label_style = if is_selected {
887 Style::default()
888 .fg(t.config_label_selected)
889 .add_modifier(Modifier::BOLD)
890 } else {
891 Style::default().fg(t.config_label)
892 };
893
894 let value_style = if app.config_editing && is_selected {
895 Style::default().fg(t.text_white).bg(t.config_edit_bg)
896 } else if is_selected {
897 Style::default().fg(t.text_white)
898 } else {
899 if CONFIG_FIELDS[i] == "api_key" {
900 Style::default().fg(t.config_api_key)
901 } else {
902 Style::default().fg(t.config_value)
903 }
904 };
905
906 let edit_indicator = if app.config_editing && is_selected {
907 " ✏️"
908 } else {
909 ""
910 };
911
912 lines.push(Line::from(vec![
913 Span::styled(pointer, pointer_style),
914 Span::styled(format!("{:<10}", label), label_style),
915 Span::styled(" ", Style::default()),
916 Span::styled(
917 if value.is_empty() {
918 "(空)".to_string()
919 } else {
920 value
921 },
922 value_style,
923 ),
924 Span::styled(edit_indicator, Style::default()),
925 ]));
926 }
927 }
928
929 lines.push(Line::from(""));
930 lines.push(Line::from(Span::styled(
932 " ─────────────────────────────────────────",
933 Style::default().fg(t.separator),
934 )));
935 lines.push(Line::from(""));
936
937 lines.push(Line::from(Span::styled(
939 " 🌐 全局配置",
940 Style::default()
941 .fg(t.config_section)
942 .add_modifier(Modifier::BOLD),
943 )));
944 lines.push(Line::from(""));
945
946 for i in 0..CONFIG_GLOBAL_FIELDS.len() {
947 let field_idx = total_provider_fields + i;
948 let is_selected = app.config_field_idx == field_idx;
949 let label = config_field_label(field_idx);
950 let value = if app.config_editing && is_selected {
951 app.config_edit_buf.clone()
952 } else {
953 config_field_value(app, field_idx)
954 };
955
956 let pointer = if is_selected { " ▸ " } else { " " };
957 let pointer_style = if is_selected {
958 Style::default().fg(t.config_pointer)
959 } else {
960 Style::default()
961 };
962
963 let label_style = if is_selected {
964 Style::default()
965 .fg(t.config_label_selected)
966 .add_modifier(Modifier::BOLD)
967 } else {
968 Style::default().fg(t.config_label)
969 };
970
971 let value_style = if app.config_editing && is_selected {
972 Style::default().fg(t.text_white).bg(t.config_edit_bg)
973 } else if is_selected {
974 Style::default().fg(t.text_white)
975 } else {
976 Style::default().fg(t.config_value)
977 };
978
979 let edit_indicator = if app.config_editing && is_selected {
980 " ✏️"
981 } else {
982 ""
983 };
984
985 if CONFIG_GLOBAL_FIELDS[i] == "stream_mode" {
987 let toggle_on = app.agent_config.stream_mode;
988 let toggle_style = if toggle_on {
989 Style::default()
990 .fg(t.config_toggle_on)
991 .add_modifier(Modifier::BOLD)
992 } else {
993 Style::default().fg(t.config_toggle_off)
994 };
995 let toggle_text = if toggle_on {
996 "● 开启"
997 } else {
998 "○ 关闭"
999 };
1000
1001 lines.push(Line::from(vec![
1002 Span::styled(pointer, pointer_style),
1003 Span::styled(format!("{:<10}", label), label_style),
1004 Span::styled(" ", Style::default()),
1005 Span::styled(toggle_text, toggle_style),
1006 Span::styled(
1007 if is_selected { " (Enter 切换)" } else { "" },
1008 Style::default().fg(t.config_dim),
1009 ),
1010 ]));
1011 } else if CONFIG_GLOBAL_FIELDS[i] == "theme" {
1012 let theme_name = app.agent_config.theme.display_name();
1014 lines.push(Line::from(vec![
1015 Span::styled(pointer, pointer_style),
1016 Span::styled(format!("{:<10}", label), label_style),
1017 Span::styled(" ", Style::default()),
1018 Span::styled(
1019 format!("🎨 {}", theme_name),
1020 Style::default()
1021 .fg(t.config_toggle_on)
1022 .add_modifier(Modifier::BOLD),
1023 ),
1024 Span::styled(
1025 if is_selected { " (Enter 切换)" } else { "" },
1026 Style::default().fg(t.config_dim),
1027 ),
1028 ]));
1029 } else {
1030 lines.push(Line::from(vec![
1031 Span::styled(pointer, pointer_style),
1032 Span::styled(format!("{:<10}", label), label_style),
1033 Span::styled(" ", Style::default()),
1034 Span::styled(
1035 if value.is_empty() {
1036 "(空)".to_string()
1037 } else {
1038 value
1039 },
1040 value_style,
1041 ),
1042 Span::styled(edit_indicator, Style::default()),
1043 ]));
1044 }
1045 }
1046
1047 lines.push(Line::from(""));
1048 lines.push(Line::from(""));
1049
1050 lines.push(Line::from(Span::styled(
1052 " ─────────────────────────────────────────",
1053 Style::default().fg(t.separator),
1054 )));
1055 lines.push(Line::from(""));
1056 lines.push(Line::from(vec![
1057 Span::styled(" ", Style::default()),
1058 Span::styled(
1059 "↑↓/jk",
1060 Style::default()
1061 .fg(t.config_hint_key)
1062 .add_modifier(Modifier::BOLD),
1063 ),
1064 Span::styled(" 切换字段 ", Style::default().fg(t.config_hint_desc)),
1065 Span::styled(
1066 "Enter",
1067 Style::default()
1068 .fg(t.config_hint_key)
1069 .add_modifier(Modifier::BOLD),
1070 ),
1071 Span::styled(" 编辑 ", Style::default().fg(t.config_hint_desc)),
1072 Span::styled(
1073 "Tab/←→",
1074 Style::default()
1075 .fg(t.config_hint_key)
1076 .add_modifier(Modifier::BOLD),
1077 ),
1078 Span::styled(" 切换 Provider ", Style::default().fg(t.config_hint_desc)),
1079 Span::styled(
1080 "a",
1081 Style::default()
1082 .fg(t.config_hint_key)
1083 .add_modifier(Modifier::BOLD),
1084 ),
1085 Span::styled(" 新增 ", Style::default().fg(t.config_hint_desc)),
1086 Span::styled(
1087 "d",
1088 Style::default()
1089 .fg(t.config_hint_key)
1090 .add_modifier(Modifier::BOLD),
1091 ),
1092 Span::styled(" 删除 ", Style::default().fg(t.config_hint_desc)),
1093 Span::styled(
1094 "s",
1095 Style::default()
1096 .fg(t.config_hint_key)
1097 .add_modifier(Modifier::BOLD),
1098 ),
1099 Span::styled(" 设为活跃 ", Style::default().fg(t.config_hint_desc)),
1100 Span::styled(
1101 "Esc",
1102 Style::default()
1103 .fg(t.config_hint_key)
1104 .add_modifier(Modifier::BOLD),
1105 ),
1106 Span::styled(" 保存返回", Style::default().fg(t.config_hint_desc)),
1107 ]));
1108
1109 let content = Paragraph::new(lines)
1110 .block(
1111 Block::default()
1112 .borders(Borders::ALL)
1113 .border_type(ratatui::widgets::BorderType::Rounded)
1114 .border_style(Style::default().fg(t.border_config))
1115 .title(Span::styled(
1116 " ⚙️ 模型配置编辑 ",
1117 Style::default()
1118 .fg(t.config_label_selected)
1119 .add_modifier(Modifier::BOLD),
1120 ))
1121 .style(Style::default().bg(bg)),
1122 )
1123 .scroll((0, 0));
1124 f.render_widget(content, area);
1125}
1126
1127pub fn draw_archive_confirm(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
1129 let t = &app.theme;
1130 let mut lines: Vec<Line> = Vec::new();
1131
1132 lines.push(Line::from(""));
1133 lines.push(Line::from(Span::styled(
1134 " 📦 归档当前对话",
1135 Style::default()
1136 .fg(t.help_title)
1137 .add_modifier(Modifier::BOLD),
1138 )));
1139 lines.push(Line::from(""));
1140 lines.push(Line::from(Span::styled(
1141 " ─────────────────────────────────────────",
1142 Style::default().fg(t.separator),
1143 )));
1144 lines.push(Line::from(""));
1145 lines.push(Line::from(Span::styled(
1146 " 即将归档当前对话,归档后当前会话将被清空。",
1147 Style::default().fg(t.text_dim),
1148 )));
1149 lines.push(Line::from(""));
1150
1151 if app.archive_editing_name {
1152 lines.push(Line::from(Span::styled(
1154 " 请输入归档名称:",
1155 Style::default().fg(t.text_white),
1156 )));
1157 lines.push(Line::from(""));
1158
1159 let name_with_cursor = if app.archive_custom_name.is_empty() {
1160 vec![Span::styled(
1161 " ",
1162 Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
1163 )]
1164 } else {
1165 let chars: Vec<char> = app.archive_custom_name.chars().collect();
1166 let mut spans: Vec<Span> = Vec::new();
1167 for (i, &ch) in chars.iter().enumerate() {
1168 if i == app.archive_edit_cursor {
1169 spans.push(Span::styled(
1170 ch.to_string(),
1171 Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
1172 ));
1173 } else {
1174 spans.push(Span::styled(
1175 ch.to_string(),
1176 Style::default().fg(t.text_white),
1177 ));
1178 }
1179 }
1180 if app.archive_edit_cursor >= chars.len() {
1182 spans.push(Span::styled(
1183 " ",
1184 Style::default().fg(t.cursor_fg).bg(t.cursor_bg),
1185 ));
1186 }
1187 spans
1188 };
1189
1190 lines.push(Line::from(vec![
1191 Span::styled(" ", Style::default()),
1192 Span::styled(
1193 format!("archive-{}", chrono::Local::now().format("%Y-%m-%d")),
1194 Style::default().fg(t.text_dim),
1195 ),
1196 ]));
1197 lines.push(Line::from(
1198 std::iter::once(Span::styled(" ", Style::default()))
1199 .chain(name_with_cursor.into_iter())
1200 .collect::<Vec<_>>(),
1201 ));
1202 lines.push(Line::from(""));
1203 lines.push(Line::from(Span::styled(
1204 " 提示:留空则使用默认名称(如 archive-2026-02-25)",
1205 Style::default().fg(t.text_dim),
1206 )));
1207 lines.push(Line::from(""));
1208 lines.push(Line::from(Span::styled(
1209 " ─────────────────────────────────────────",
1210 Style::default().fg(t.separator),
1211 )));
1212 lines.push(Line::from(""));
1213 lines.push(Line::from(vec![
1214 Span::styled(" ", Style::default()),
1215 Span::styled(
1216 "Enter",
1217 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1218 ),
1219 Span::styled(" 确认归档", Style::default().fg(t.help_desc)),
1220 ]));
1221 lines.push(Line::from(vec![
1222 Span::styled(" ", Style::default()),
1223 Span::styled(
1224 "Esc",
1225 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1226 ),
1227 Span::styled(" 取消", Style::default().fg(t.help_desc)),
1228 ]));
1229 } else {
1230 lines.push(Line::from(vec![
1232 Span::styled(" 默认名称:", Style::default().fg(t.text_dim)),
1233 Span::styled(
1234 &app.archive_default_name,
1235 Style::default()
1236 .fg(t.config_toggle_on)
1237 .add_modifier(Modifier::BOLD),
1238 ),
1239 ]));
1240 lines.push(Line::from(""));
1241 lines.push(Line::from(Span::styled(
1242 " ─────────────────────────────────────────",
1243 Style::default().fg(t.separator),
1244 )));
1245 lines.push(Line::from(""));
1246 lines.push(Line::from(vec![
1247 Span::styled(" ", Style::default()),
1248 Span::styled(
1249 "Enter",
1250 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1251 ),
1252 Span::styled(" 使用默认名称归档", Style::default().fg(t.help_desc)),
1253 ]));
1254 lines.push(Line::from(vec![
1255 Span::styled(" ", Style::default()),
1256 Span::styled(
1257 "n",
1258 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1259 ),
1260 Span::styled(" 自定义名称", Style::default().fg(t.help_desc)),
1261 ]));
1262 lines.push(Line::from(vec![
1263 Span::styled(" ", Style::default()),
1264 Span::styled(
1265 "d",
1266 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1267 ),
1268 Span::styled(" 仅清空不归档", Style::default().fg(t.help_desc)),
1269 ]));
1270 lines.push(Line::from(vec![
1271 Span::styled(" ", Style::default()),
1272 Span::styled(
1273 "Esc",
1274 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1275 ),
1276 Span::styled(" 取消", Style::default().fg(t.help_desc)),
1277 ]));
1278 }
1279
1280 let block = Block::default()
1281 .borders(Borders::ALL)
1282 .border_type(ratatui::widgets::BorderType::Rounded)
1283 .border_style(Style::default().fg(t.border_title))
1284 .title(Span::styled(" 归档确认 ", Style::default().fg(t.text_dim)))
1285 .style(Style::default().bg(t.help_bg));
1286 let widget = Paragraph::new(lines).block(block);
1287 f.render_widget(widget, area);
1288}
1289
1290pub fn draw_archive_list(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
1292 let t = &app.theme;
1293
1294 if app.restore_confirm_needed {
1296 let mut lines: Vec<Line> = Vec::new();
1297 lines.push(Line::from(""));
1298 lines.push(Line::from(Span::styled(
1299 " ⚠️ 确认还原",
1300 Style::default()
1301 .fg(t.toast_error_text)
1302 .add_modifier(Modifier::BOLD),
1303 )));
1304 lines.push(Line::from(""));
1305 lines.push(Line::from(Span::styled(
1306 " 当前对话未归档,还原将丢失当前对话内容!",
1307 Style::default().fg(t.text_white),
1308 )));
1309 lines.push(Line::from(""));
1310 lines.push(Line::from(Span::styled(
1311 " ─────────────────────────────────────────",
1312 Style::default().fg(t.separator),
1313 )));
1314 lines.push(Line::from(""));
1315 if let Some(archive) = app.archives.get(app.archive_list_index) {
1316 lines.push(Line::from(vec![
1317 Span::styled(" 将还原归档:", Style::default().fg(t.text_dim)),
1318 Span::styled(
1319 &archive.name,
1320 Style::default()
1321 .fg(t.config_toggle_on)
1322 .add_modifier(Modifier::BOLD),
1323 ),
1324 ]));
1325 }
1326 lines.push(Line::from(""));
1327 lines.push(Line::from(vec![
1328 Span::styled(" ", Style::default()),
1329 Span::styled(
1330 "y/Enter",
1331 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1332 ),
1333 Span::styled(" 确认还原", Style::default().fg(t.help_desc)),
1334 ]));
1335 lines.push(Line::from(vec![
1336 Span::styled(" ", Style::default()),
1337 Span::styled(
1338 "Esc",
1339 Style::default().fg(t.help_key).add_modifier(Modifier::BOLD),
1340 ),
1341 Span::styled(" 取消", Style::default().fg(t.help_desc)),
1342 ]));
1343
1344 let block = Block::default()
1345 .borders(Borders::ALL)
1346 .border_type(ratatui::widgets::BorderType::Rounded)
1347 .border_style(Style::default().fg(t.toast_error_border))
1348 .title(Span::styled(" 还原确认 ", Style::default().fg(t.text_dim)))
1349 .style(Style::default().bg(t.help_bg));
1350 let widget = Paragraph::new(lines).block(block);
1351 f.render_widget(widget, area);
1352 return;
1353 }
1354
1355 if app.archives.is_empty() {
1357 let lines = vec![
1358 Line::from(""),
1359 Line::from(""),
1360 Line::from(Span::styled(
1361 " 📦 暂无归档对话",
1362 Style::default().fg(t.text_dim).add_modifier(Modifier::BOLD),
1363 )),
1364 Line::from(""),
1365 Line::from(Span::styled(
1366 " 按 Ctrl+L 归档当前对话",
1367 Style::default().fg(t.text_dim),
1368 )),
1369 Line::from(""),
1370 Line::from(Span::styled(
1371 " 按 Esc 返回聊天",
1372 Style::default().fg(t.text_dim),
1373 )),
1374 ];
1375
1376 let block = Block::default()
1377 .borders(Borders::ALL)
1378 .border_type(ratatui::widgets::BorderType::Rounded)
1379 .border_style(Style::default().fg(t.border_title))
1380 .title(Span::styled(" 归档列表 ", Style::default().fg(t.text_dim)))
1381 .style(Style::default().bg(t.help_bg));
1382 let widget = Paragraph::new(lines).block(block);
1383 f.render_widget(widget, area);
1384 return;
1385 }
1386
1387 let items: Vec<ListItem> = app
1389 .archives
1390 .iter()
1391 .enumerate()
1392 .map(|(i, archive)| {
1393 let is_selected = i == app.archive_list_index;
1394 let marker = if is_selected { " ▸ " } else { " " };
1395 let msg_count = archive.messages.len();
1396
1397 let created_at = archive
1399 .created_at
1400 .split('T')
1401 .next()
1402 .unwrap_or(&archive.created_at);
1403
1404 let style = if is_selected {
1405 Style::default()
1406 .fg(t.model_sel_active)
1407 .add_modifier(Modifier::BOLD)
1408 } else {
1409 Style::default().fg(t.model_sel_inactive)
1410 };
1411
1412 let detail = format!(
1413 "{}{} 📨 {} 条消息 📅 {}",
1414 marker, archive.name, msg_count, created_at
1415 );
1416 ListItem::new(Line::from(Span::styled(detail, style)))
1417 })
1418 .collect();
1419
1420 let list = List::new(items)
1421 .block(
1422 Block::default()
1423 .borders(Borders::ALL)
1424 .border_type(ratatui::widgets::BorderType::Rounded)
1425 .border_style(Style::default().fg(t.model_sel_border))
1426 .title(Span::styled(
1427 " 📦 归档列表 (Enter 还原, d 删除, Esc 返回) ",
1428 Style::default()
1429 .fg(t.model_sel_title)
1430 .add_modifier(Modifier::BOLD),
1431 ))
1432 .style(Style::default().bg(t.bg_title)),
1433 )
1434 .highlight_style(
1435 Style::default()
1436 .bg(t.model_sel_highlight_bg)
1437 .fg(t.text_white)
1438 .add_modifier(Modifier::BOLD),
1439 )
1440 .highlight_symbol("");
1441
1442 let mut list_state = ListState::default();
1444 list_state.select(Some(app.archive_list_index));
1445 f.render_stateful_widget(list, area, &mut list_state);
1446}