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