1use crate::config::YamlConfig;
2use crate::{error, info};
3use async_openai::{
4 Client,
5 config::OpenAIConfig,
6 types::chat::{
7 ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
8 ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs,
9 CreateChatCompletionRequestArgs,
10 },
11};
12use crossterm::{
13 event::{
14 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
15 MouseEventKind,
16 },
17 execute,
18 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
19};
20use futures::StreamExt;
21use ratatui::{
22 Terminal,
23 backend::CrosstermBackend,
24 layout::{Constraint, Direction, Layout, Rect},
25 style::{Color, Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
28};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::io::{self, Write};
32use std::path::PathBuf;
33use std::sync::{Arc, Mutex, mpsc};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ModelProvider {
40 pub name: String,
42 pub api_base: String,
44 pub api_key: String,
46 pub model: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct AgentConfig {
53 #[serde(default)]
55 pub providers: Vec<ModelProvider>,
56 #[serde(default)]
58 pub active_index: usize,
59 #[serde(default)]
61 pub system_prompt: Option<String>,
62 #[serde(default = "default_stream_mode")]
64 pub stream_mode: bool,
65}
66
67fn default_stream_mode() -> bool {
69 true
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ChatMessage {
75 pub role: String, pub content: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct ChatSession {
82 pub messages: Vec<ChatMessage>,
83}
84
85fn agent_data_dir() -> PathBuf {
89 let dir = YamlConfig::data_dir().join("agent").join("data");
90 let _ = fs::create_dir_all(&dir);
91 dir
92}
93
94fn agent_config_path() -> PathBuf {
96 agent_data_dir().join("agent_config.json")
97}
98
99fn chat_history_path() -> PathBuf {
101 agent_data_dir().join("chat_history.json")
102}
103
104fn load_agent_config() -> AgentConfig {
108 let path = agent_config_path();
109 if !path.exists() {
110 return AgentConfig::default();
111 }
112 match fs::read_to_string(&path) {
113 Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
114 error!("❌ 解析 agent_config.json 失败: {}", e);
115 AgentConfig::default()
116 }),
117 Err(e) => {
118 error!("❌ 读取 agent_config.json 失败: {}", e);
119 AgentConfig::default()
120 }
121 }
122}
123
124fn save_agent_config(config: &AgentConfig) -> bool {
126 let path = agent_config_path();
127 if let Some(parent) = path.parent() {
128 let _ = fs::create_dir_all(parent);
129 }
130 match serde_json::to_string_pretty(config) {
131 Ok(json) => match fs::write(&path, json) {
132 Ok(_) => true,
133 Err(e) => {
134 error!("❌ 保存 agent_config.json 失败: {}", e);
135 false
136 }
137 },
138 Err(e) => {
139 error!("❌ 序列化 agent 配置失败: {}", e);
140 false
141 }
142 }
143}
144
145fn load_chat_session() -> ChatSession {
147 let path = chat_history_path();
148 if !path.exists() {
149 return ChatSession::default();
150 }
151 match fs::read_to_string(&path) {
152 Ok(content) => serde_json::from_str(&content).unwrap_or_else(|_| ChatSession::default()),
153 Err(_) => ChatSession::default(),
154 }
155}
156
157fn save_chat_session(session: &ChatSession) -> bool {
159 let path = chat_history_path();
160 if let Some(parent) = path.parent() {
161 let _ = fs::create_dir_all(parent);
162 }
163 match serde_json::to_string_pretty(session) {
164 Ok(json) => fs::write(&path, json).is_ok(),
165 Err(_) => false,
166 }
167}
168
169fn create_openai_client(provider: &ModelProvider) -> Client<OpenAIConfig> {
173 let config = OpenAIConfig::new()
174 .with_api_key(&provider.api_key)
175 .with_api_base(&provider.api_base);
176 Client::with_config(config)
177}
178
179fn to_openai_messages(messages: &[ChatMessage]) -> Vec<ChatCompletionRequestMessage> {
181 messages
182 .iter()
183 .filter_map(|msg| match msg.role.as_str() {
184 "system" => ChatCompletionRequestSystemMessageArgs::default()
185 .content(msg.content.as_str())
186 .build()
187 .ok()
188 .map(ChatCompletionRequestMessage::System),
189 "user" => ChatCompletionRequestUserMessageArgs::default()
190 .content(msg.content.as_str())
191 .build()
192 .ok()
193 .map(ChatCompletionRequestMessage::User),
194 "assistant" => ChatCompletionRequestAssistantMessageArgs::default()
195 .content(msg.content.as_str())
196 .build()
197 .ok()
198 .map(ChatCompletionRequestMessage::Assistant),
199 _ => None,
200 })
201 .collect()
202}
203
204async fn call_openai_stream_async(
207 provider: &ModelProvider,
208 messages: &[ChatMessage],
209 on_chunk: &mut dyn FnMut(&str),
210) -> Result<String, String> {
211 let client = create_openai_client(provider);
212 let openai_messages = to_openai_messages(messages);
213
214 let request = CreateChatCompletionRequestArgs::default()
215 .model(&provider.model)
216 .messages(openai_messages)
217 .build()
218 .map_err(|e| format!("构建请求失败: {}", e))?;
219
220 let mut stream = client
221 .chat()
222 .create_stream(request)
223 .await
224 .map_err(|e| format!("API 请求失败: {}", e))?;
225
226 let mut full_content = String::new();
227
228 while let Some(result) = stream.next().await {
229 match result {
230 Ok(response) => {
231 for choice in &response.choices {
232 if let Some(ref content) = choice.delta.content {
233 full_content.push_str(content);
234 on_chunk(content);
235 }
236 }
237 }
238 Err(e) => {
239 return Err(format!("流式响应错误: {}", e));
240 }
241 }
242 }
243
244 Ok(full_content)
245}
246
247fn call_openai_stream(
249 provider: &ModelProvider,
250 messages: &[ChatMessage],
251 on_chunk: &mut dyn FnMut(&str),
252) -> Result<String, String> {
253 let rt = tokio::runtime::Runtime::new().map_err(|e| format!("创建异步运行时失败: {}", e))?;
254 rt.block_on(call_openai_stream_async(provider, messages, on_chunk))
255}
256
257pub fn handle_chat(content: &[String], _config: &YamlConfig) {
261 let agent_config = load_agent_config();
262
263 if agent_config.providers.is_empty() {
264 info!("⚠️ 尚未配置 LLM 模型提供方。");
265 info!("📁 请编辑配置文件: {}", agent_config_path().display());
266 info!("📝 配置示例:");
267 let example = AgentConfig {
268 providers: vec![ModelProvider {
269 name: "GPT-4o".to_string(),
270 api_base: "https://api.openai.com/v1".to_string(),
271 api_key: "sk-your-api-key".to_string(),
272 model: "gpt-4o".to_string(),
273 }],
274 active_index: 0,
275 system_prompt: Some("你是一个有用的助手。".to_string()),
276 stream_mode: true,
277 };
278 if let Ok(json) = serde_json::to_string_pretty(&example) {
279 println!("{}", json);
280 }
281 if !agent_config_path().exists() {
283 let _ = save_agent_config(&example);
284 info!(
285 "✅ 已自动创建示例配置文件: {}",
286 agent_config_path().display()
287 );
288 info!("📌 请修改其中的 api_key 和其他配置后重新运行 chat 命令");
289 }
290 return;
291 }
292
293 if content.is_empty() {
294 run_chat_tui();
296 return;
297 }
298
299 let message = content.join(" ");
301 let message = message.trim().to_string();
302 if message.is_empty() {
303 error!("⚠️ 消息内容为空");
304 return;
305 }
306
307 let idx = agent_config
308 .active_index
309 .min(agent_config.providers.len() - 1);
310 let provider = &agent_config.providers[idx];
311
312 info!("🤖 [{}] 思考中...", provider.name);
313
314 let mut messages = Vec::new();
315 if let Some(sys) = &agent_config.system_prompt {
316 messages.push(ChatMessage {
317 role: "system".to_string(),
318 content: sys.clone(),
319 });
320 }
321 messages.push(ChatMessage {
322 role: "user".to_string(),
323 content: message,
324 });
325
326 match call_openai_stream(provider, &messages, &mut |chunk| {
327 print!("{}", chunk);
328 let _ = io::stdout().flush();
329 }) {
330 Ok(_) => {
331 println!(); }
333 Err(e) => {
334 error!("\n❌ {}", e);
335 }
336 }
337}
338
339enum StreamMsg {
343 Chunk,
345 Done,
347 Error(String),
349}
350
351struct ChatApp {
353 agent_config: AgentConfig,
355 session: ChatSession,
357 input: String,
359 cursor_pos: usize,
361 mode: ChatMode,
363 scroll_offset: u16,
365 is_loading: bool,
367 model_list_state: ListState,
369 toast: Option<(String, bool, std::time::Instant)>,
371 stream_rx: Option<mpsc::Receiver<StreamMsg>>,
373 streaming_content: Arc<Mutex<String>>,
375 msg_lines_cache: Option<MsgLinesCache>,
378 browse_msg_index: usize,
380 last_rendered_streaming_len: usize,
382 last_stream_render_time: std::time::Instant,
384 config_provider_idx: usize,
386 config_field_idx: usize,
388 config_editing: bool,
390 config_edit_buf: String,
392 config_edit_cursor: usize,
394}
395
396struct MsgLinesCache {
398 msg_count: usize,
400 last_msg_len: usize,
402 streaming_len: usize,
404 is_loading: bool,
406 bubble_max_width: usize,
408 browse_index: Option<usize>,
410 lines: Vec<Line<'static>>,
412 msg_start_lines: Vec<(usize, usize)>, per_msg_lines: Vec<PerMsgCache>,
416 streaming_stable_lines: Vec<Line<'static>>,
418 streaming_stable_offset: usize,
420}
421
422struct PerMsgCache {
424 content_len: usize,
426 lines: Vec<Line<'static>>,
428 msg_index: usize,
430}
431
432const TOAST_DURATION_SECS: u64 = 4;
434
435#[derive(PartialEq)]
436enum ChatMode {
437 Chat,
439 SelectModel,
441 Browse,
443 Help,
445 Config,
447}
448
449const CONFIG_FIELDS: &[&str] = &["name", "api_base", "api_key", "model"];
451const CONFIG_GLOBAL_FIELDS: &[&str] = &["system_prompt", "stream_mode"];
453fn config_total_fields() -> usize {
455 CONFIG_FIELDS.len() + CONFIG_GLOBAL_FIELDS.len()
456}
457
458impl ChatApp {
459 fn new() -> Self {
460 let agent_config = load_agent_config();
461 let session = load_chat_session();
462 let mut model_list_state = ListState::default();
463 if !agent_config.providers.is_empty() {
464 model_list_state.select(Some(agent_config.active_index));
465 }
466 Self {
467 agent_config,
468 session,
469 input: String::new(),
470 cursor_pos: 0,
471 mode: ChatMode::Chat,
472 scroll_offset: u16::MAX, is_loading: false,
474 model_list_state,
475 toast: None,
476 stream_rx: None,
477 streaming_content: Arc::new(Mutex::new(String::new())),
478 msg_lines_cache: None,
479 browse_msg_index: 0,
480 last_rendered_streaming_len: 0,
481 last_stream_render_time: std::time::Instant::now(),
482 config_provider_idx: 0,
483 config_field_idx: 0,
484 config_editing: false,
485 config_edit_buf: String::new(),
486 config_edit_cursor: 0,
487 }
488 }
489
490 fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
492 self.toast = Some((msg.into(), is_error, std::time::Instant::now()));
493 }
494
495 fn tick_toast(&mut self) {
497 if let Some((_, _, created)) = &self.toast {
498 if created.elapsed().as_secs() >= TOAST_DURATION_SECS {
499 self.toast = None;
500 }
501 }
502 }
503
504 fn active_provider(&self) -> Option<&ModelProvider> {
506 if self.agent_config.providers.is_empty() {
507 return None;
508 }
509 let idx = self
510 .agent_config
511 .active_index
512 .min(self.agent_config.providers.len() - 1);
513 Some(&self.agent_config.providers[idx])
514 }
515
516 fn active_model_name(&self) -> String {
518 self.active_provider()
519 .map(|p| p.name.clone())
520 .unwrap_or_else(|| "未配置".to_string())
521 }
522
523 fn build_api_messages(&self) -> Vec<ChatMessage> {
525 let mut messages = Vec::new();
526 if let Some(sys) = &self.agent_config.system_prompt {
527 messages.push(ChatMessage {
528 role: "system".to_string(),
529 content: sys.clone(),
530 });
531 }
532 for msg in &self.session.messages {
533 messages.push(msg.clone());
534 }
535 messages
536 }
537
538 fn send_message(&mut self) {
540 let text = self.input.trim().to_string();
541 if text.is_empty() {
542 return;
543 }
544
545 self.session.messages.push(ChatMessage {
547 role: "user".to_string(),
548 content: text,
549 });
550 self.input.clear();
551 self.cursor_pos = 0;
552 self.scroll_offset = u16::MAX;
554
555 let provider = match self.active_provider() {
557 Some(p) => p.clone(),
558 None => {
559 self.show_toast("未配置模型提供方,请先编辑配置文件", true);
560 return;
561 }
562 };
563
564 self.is_loading = true;
565 self.last_rendered_streaming_len = 0;
567 self.last_stream_render_time = std::time::Instant::now();
568 self.msg_lines_cache = None;
569
570 let api_messages = self.build_api_messages();
571
572 {
574 let mut sc = self.streaming_content.lock().unwrap();
575 sc.clear();
576 }
577
578 let (tx, rx) = mpsc::channel::<StreamMsg>();
580 self.stream_rx = Some(rx);
581
582 let streaming_content = Arc::clone(&self.streaming_content);
583
584 let use_stream = self.agent_config.stream_mode;
585
586 std::thread::spawn(move || {
588 let rt = match tokio::runtime::Runtime::new() {
589 Ok(rt) => rt,
590 Err(e) => {
591 let _ = tx.send(StreamMsg::Error(format!("创建异步运行时失败: {}", e)));
592 return;
593 }
594 };
595
596 rt.block_on(async {
597 let client = create_openai_client(&provider);
598 let openai_messages = to_openai_messages(&api_messages);
599
600 let request = match CreateChatCompletionRequestArgs::default()
601 .model(&provider.model)
602 .messages(openai_messages)
603 .build()
604 {
605 Ok(req) => req,
606 Err(e) => {
607 let _ = tx.send(StreamMsg::Error(format!("构建请求失败: {}", e)));
608 return;
609 }
610 };
611
612 if use_stream {
613 let mut stream = match client.chat().create_stream(request).await {
615 Ok(s) => s,
616 Err(e) => {
617 let _ = tx.send(StreamMsg::Error(format!("API 请求失败: {}", e)));
618 return;
619 }
620 };
621
622 while let Some(result) = stream.next().await {
623 match result {
624 Ok(response) => {
625 for choice in &response.choices {
626 if let Some(ref content) = choice.delta.content {
627 {
629 let mut sc = streaming_content.lock().unwrap();
630 sc.push_str(content);
631 }
632 let _ = tx.send(StreamMsg::Chunk);
633 }
634 }
635 }
636 Err(e) => {
637 let _ = tx.send(StreamMsg::Error(format!("流式响应错误: {}", e)));
638 return;
639 }
640 }
641 }
642 } else {
643 match client.chat().create(request).await {
645 Ok(response) => {
646 if let Some(choice) = response.choices.first() {
647 if let Some(ref content) = choice.message.content {
648 {
649 let mut sc = streaming_content.lock().unwrap();
650 sc.push_str(content);
651 }
652 let _ = tx.send(StreamMsg::Chunk);
653 }
654 }
655 }
656 Err(e) => {
657 let _ = tx.send(StreamMsg::Error(format!("API 请求失败: {}", e)));
658 return;
659 }
660 }
661 }
662
663 let _ = tx.send(StreamMsg::Done);
664
665 let _ = tx.send(StreamMsg::Done);
666 });
667 });
668 }
669
670 fn poll_stream(&mut self) {
672 if self.stream_rx.is_none() {
673 return;
674 }
675
676 let mut finished = false;
677 let mut had_error = false;
678
679 if let Some(ref rx) = self.stream_rx {
681 loop {
682 match rx.try_recv() {
683 Ok(StreamMsg::Chunk) => {
684 self.scroll_offset = u16::MAX;
686 }
687 Ok(StreamMsg::Done) => {
688 finished = true;
689 break;
690 }
691 Ok(StreamMsg::Error(e)) => {
692 self.show_toast(format!("请求失败: {}", e), true);
693 had_error = true;
694 finished = true;
695 break;
696 }
697 Err(mpsc::TryRecvError::Empty) => break,
698 Err(mpsc::TryRecvError::Disconnected) => {
699 finished = true;
700 break;
701 }
702 }
703 }
704 }
705
706 if finished {
707 self.stream_rx = None;
708 self.is_loading = false;
709 self.last_rendered_streaming_len = 0;
711 self.msg_lines_cache = None;
713
714 if !had_error {
715 let content = {
717 let sc = self.streaming_content.lock().unwrap();
718 sc.clone()
719 };
720 if !content.is_empty() {
721 self.session.messages.push(ChatMessage {
722 role: "assistant".to_string(),
723 content,
724 });
725 self.streaming_content.lock().unwrap().clear();
727 self.show_toast("回复完成 ✓", false);
728 }
729 self.scroll_offset = u16::MAX;
730 } else {
731 self.streaming_content.lock().unwrap().clear();
733 }
734
735 let _ = save_chat_session(&self.session);
737 }
738 }
739
740 fn clear_session(&mut self) {
742 self.session.messages.clear();
743 self.scroll_offset = 0;
744 self.msg_lines_cache = None; let _ = save_chat_session(&self.session);
746 self.show_toast("对话已清空", false);
747 }
748
749 fn switch_model(&mut self) {
751 if let Some(sel) = self.model_list_state.selected() {
752 self.agent_config.active_index = sel;
753 let _ = save_agent_config(&self.agent_config);
754 let name = self.active_model_name();
755 self.show_toast(format!("已切换到: {}", name), false);
756 }
757 self.mode = ChatMode::Chat;
758 }
759
760 fn scroll_up(&mut self) {
762 self.scroll_offset = self.scroll_offset.saturating_sub(3);
763 }
764
765 fn scroll_down(&mut self) {
767 self.scroll_offset = self.scroll_offset.saturating_add(3);
768 }
769}
770
771fn run_chat_tui() {
773 match run_chat_tui_internal() {
774 Ok(_) => {}
775 Err(e) => {
776 error!("❌ Chat TUI 启动失败: {}", e);
777 }
778 }
779}
780
781fn run_chat_tui_internal() -> io::Result<()> {
782 terminal::enable_raw_mode()?;
783 let mut stdout = io::stdout();
784 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
785
786 let backend = CrosstermBackend::new(stdout);
787 let mut terminal = Terminal::new(backend)?;
788
789 let mut app = ChatApp::new();
790
791 if app.agent_config.providers.is_empty() {
792 terminal::disable_raw_mode()?;
793 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
794 info!("⚠️ 尚未配置 LLM 模型提供方,请先运行 j chat 查看配置说明。");
795 return Ok(());
796 }
797
798 let mut needs_redraw = true; loop {
801 let had_toast = app.toast.is_some();
803 app.tick_toast();
804 if had_toast && app.toast.is_none() {
805 needs_redraw = true;
806 }
807
808 let was_loading = app.is_loading;
810 app.poll_stream();
811 if app.is_loading {
813 let current_len = app.streaming_content.lock().unwrap().len();
814 let bytes_delta = current_len.saturating_sub(app.last_rendered_streaming_len);
815 let time_elapsed = app.last_stream_render_time.elapsed();
816 if bytes_delta >= 200
818 || time_elapsed >= std::time::Duration::from_millis(200)
819 || current_len == 0
820 {
821 needs_redraw = true;
822 }
823 } else if was_loading {
824 needs_redraw = true;
826 }
827
828 if needs_redraw {
830 terminal.draw(|f| draw_chat_ui(f, &mut app))?;
831 needs_redraw = false;
832 if app.is_loading {
834 app.last_rendered_streaming_len = app.streaming_content.lock().unwrap().len();
835 app.last_stream_render_time = std::time::Instant::now();
836 }
837 }
838
839 let poll_timeout = if app.is_loading {
841 std::time::Duration::from_millis(150)
842 } else {
843 std::time::Duration::from_millis(1000)
844 };
845
846 if event::poll(poll_timeout)? {
847 let mut should_break = false;
849 loop {
850 let evt = event::read()?;
851 match evt {
852 Event::Key(key) => {
853 needs_redraw = true;
854 match app.mode {
855 ChatMode::Chat => {
856 if handle_chat_mode(&mut app, key) {
857 should_break = true;
858 break;
859 }
860 }
861 ChatMode::SelectModel => handle_select_model(&mut app, key),
862 ChatMode::Browse => handle_browse_mode(&mut app, key),
863 ChatMode::Help => {
864 app.mode = ChatMode::Chat;
865 }
866 ChatMode::Config => handle_config_mode(&mut app, key),
867 }
868 }
869 Event::Mouse(mouse) => match mouse.kind {
870 MouseEventKind::ScrollUp => {
871 app.scroll_up();
872 needs_redraw = true;
873 }
874 MouseEventKind::ScrollDown => {
875 app.scroll_down();
876 needs_redraw = true;
877 }
878 _ => {}
879 },
880 Event::Resize(_, _) => {
881 needs_redraw = true;
882 }
883 _ => {}
884 }
885 if !event::poll(std::time::Duration::ZERO)? {
887 break;
888 }
889 }
890 if should_break {
891 break;
892 }
893 }
894 }
895
896 let _ = save_chat_session(&app.session);
898
899 terminal::disable_raw_mode()?;
900 execute!(
901 terminal.backend_mut(),
902 LeaveAlternateScreen,
903 DisableMouseCapture
904 )?;
905 Ok(())
906}
907
908fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
910 let size = f.area();
911
912 let bg = Block::default().style(Style::default().bg(Color::Rgb(22, 22, 30)));
914 f.render_widget(bg, size);
915
916 let chunks = Layout::default()
917 .direction(Direction::Vertical)
918 .constraints([
919 Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), Constraint::Length(1), ])
924 .split(size);
925
926 draw_title_bar(f, chunks[0], app);
928
929 if app.mode == ChatMode::Help {
931 draw_help(f, chunks[1]);
932 } else if app.mode == ChatMode::SelectModel {
933 draw_model_selector(f, chunks[1], app);
934 } else if app.mode == ChatMode::Config {
935 draw_config_screen(f, chunks[1], app);
936 } else {
937 draw_messages(f, chunks[1], app);
938 }
939
940 draw_input(f, chunks[2], app);
942
943 draw_hint_bar(f, chunks[3], app);
945
946 draw_toast(f, size, app);
948}
949
950fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
952 let model_name = app.active_model_name();
953 let msg_count = app.session.messages.len();
954 let loading = if app.is_loading {
955 " ⏳ 思考中..."
956 } else {
957 ""
958 };
959
960 let title_spans = vec![
961 Span::styled(" 💬 ", Style::default().fg(Color::Rgb(120, 180, 255))),
962 Span::styled(
963 "AI Chat",
964 Style::default()
965 .fg(Color::White)
966 .add_modifier(Modifier::BOLD),
967 ),
968 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 60, 80))),
969 Span::styled("🤖 ", Style::default()),
970 Span::styled(
971 model_name,
972 Style::default()
973 .fg(Color::Rgb(160, 220, 160))
974 .add_modifier(Modifier::BOLD),
975 ),
976 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 60, 80))),
977 Span::styled(
978 format!("📨 {} 条消息", msg_count),
979 Style::default().fg(Color::Rgb(180, 180, 200)),
980 ),
981 Span::styled(
982 loading,
983 Style::default()
984 .fg(Color::Rgb(255, 200, 80))
985 .add_modifier(Modifier::BOLD),
986 ),
987 ];
988
989 let title_block = Paragraph::new(Line::from(title_spans)).block(
990 Block::default()
991 .borders(Borders::ALL)
992 .border_type(ratatui::widgets::BorderType::Rounded)
993 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
994 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
995 );
996 f.render_widget(title_block, area);
997}
998
999fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
1001 let block = Block::default()
1002 .borders(Borders::ALL)
1003 .border_type(ratatui::widgets::BorderType::Rounded)
1004 .border_style(Style::default().fg(Color::Rgb(50, 55, 70)))
1005 .title(Span::styled(
1006 " 对话记录 ",
1007 Style::default()
1008 .fg(Color::Rgb(140, 140, 170))
1009 .add_modifier(Modifier::BOLD),
1010 ))
1011 .title_alignment(ratatui::layout::Alignment::Left)
1012 .style(Style::default().bg(Color::Rgb(22, 22, 30)));
1013
1014 if app.session.messages.is_empty() && !app.is_loading {
1016 let welcome_lines = vec![
1017 Line::from(""),
1018 Line::from(""),
1019 Line::from(Span::styled(
1020 " ╭──────────────────────────────────────╮",
1021 Style::default().fg(Color::Rgb(60, 70, 90)),
1022 )),
1023 Line::from(Span::styled(
1024 " │ │",
1025 Style::default().fg(Color::Rgb(60, 70, 90)),
1026 )),
1027 Line::from(vec![
1028 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 70, 90))),
1029 Span::styled(
1030 "Hi! What can I help you? ",
1031 Style::default().fg(Color::Rgb(120, 140, 180)),
1032 ),
1033 Span::styled(" │", Style::default().fg(Color::Rgb(60, 70, 90))),
1034 ]),
1035 Line::from(Span::styled(
1036 " │ │",
1037 Style::default().fg(Color::Rgb(60, 70, 90)),
1038 )),
1039 Line::from(Span::styled(
1040 " │ Type a message, press Enter │",
1041 Style::default().fg(Color::Rgb(80, 90, 110)),
1042 )),
1043 Line::from(Span::styled(
1044 " │ │",
1045 Style::default().fg(Color::Rgb(60, 70, 90)),
1046 )),
1047 Line::from(Span::styled(
1048 " ╰──────────────────────────────────────╯",
1049 Style::default().fg(Color::Rgb(60, 70, 90)),
1050 )),
1051 ];
1052 let empty = Paragraph::new(welcome_lines).block(block);
1053 f.render_widget(empty, area);
1054 return;
1055 }
1056
1057 let inner_width = area.width.saturating_sub(4) as usize;
1059 let bubble_max_width = (inner_width * 75 / 100).max(20);
1061
1062 let msg_count = app.session.messages.len();
1064 let last_msg_len = app
1065 .session
1066 .messages
1067 .last()
1068 .map(|m| m.content.len())
1069 .unwrap_or(0);
1070 let streaming_len = app.streaming_content.lock().unwrap().len();
1071 let current_browse_index = if app.mode == ChatMode::Browse {
1072 Some(app.browse_msg_index)
1073 } else {
1074 None
1075 };
1076 let cache_hit = if let Some(ref cache) = app.msg_lines_cache {
1077 cache.msg_count == msg_count
1078 && cache.last_msg_len == last_msg_len
1079 && cache.streaming_len == streaming_len
1080 && cache.is_loading == app.is_loading
1081 && cache.bubble_max_width == bubble_max_width
1082 && cache.browse_index == current_browse_index
1083 } else {
1084 false
1085 };
1086
1087 if !cache_hit {
1088 let old_cache = app.msg_lines_cache.take();
1090 let (new_lines, new_msg_start_lines, new_per_msg, new_stable_lines, new_stable_offset) =
1091 build_message_lines_incremental(app, inner_width, bubble_max_width, old_cache.as_ref());
1092 app.msg_lines_cache = Some(MsgLinesCache {
1093 msg_count,
1094 last_msg_len,
1095 streaming_len,
1096 is_loading: app.is_loading,
1097 bubble_max_width,
1098 browse_index: current_browse_index,
1099 lines: new_lines,
1100 msg_start_lines: new_msg_start_lines,
1101 per_msg_lines: new_per_msg,
1102 streaming_stable_lines: new_stable_lines,
1103 streaming_stable_offset: new_stable_offset,
1104 });
1105 }
1106
1107 let cached = app.msg_lines_cache.as_ref().unwrap();
1109 let all_lines = &cached.lines;
1110 let total_lines = all_lines.len() as u16;
1111
1112 f.render_widget(block, area);
1114
1115 let inner = area.inner(ratatui::layout::Margin {
1117 vertical: 1,
1118 horizontal: 1,
1119 });
1120 let visible_height = inner.height;
1121 let max_scroll = total_lines.saturating_sub(visible_height);
1122
1123 if app.mode != ChatMode::Browse {
1125 if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
1126 app.scroll_offset = max_scroll;
1127 }
1128 } else {
1129 if let Some(target_line) = cached
1131 .msg_start_lines
1132 .iter()
1133 .find(|(idx, _)| *idx == app.browse_msg_index)
1134 .map(|(_, line)| *line as u16)
1135 {
1136 if target_line < app.scroll_offset {
1138 app.scroll_offset = target_line;
1139 } else if target_line >= app.scroll_offset + visible_height {
1140 app.scroll_offset = target_line.saturating_sub(visible_height / 3);
1141 }
1142 if app.scroll_offset > max_scroll {
1144 app.scroll_offset = max_scroll;
1145 }
1146 }
1147 }
1148
1149 let bg_fill = Block::default().style(Style::default().bg(Color::Rgb(22, 22, 30)));
1151 f.render_widget(bg_fill, inner);
1152
1153 let start = app.scroll_offset as usize;
1155 let end = (start + visible_height as usize).min(all_lines.len());
1156 for (i, line_idx) in (start..end).enumerate() {
1157 let line = &all_lines[line_idx];
1158 let y = inner.y + i as u16;
1159 let line_area = Rect::new(inner.x, y, inner.width, 1);
1160 let p = Paragraph::new(line.clone());
1162 f.render_widget(p, line_area);
1163 }
1164}
1165
1166fn find_stable_boundary(content: &str) -> usize {
1169 let mut fence_count = 0usize;
1171 let mut last_safe_boundary = 0usize;
1172 let mut i = 0;
1173 let bytes = content.as_bytes();
1174 while i < bytes.len() {
1175 if i + 2 < bytes.len() && bytes[i] == b'`' && bytes[i + 1] == b'`' && bytes[i + 2] == b'`' {
1177 fence_count += 1;
1178 i += 3;
1179 while i < bytes.len() && bytes[i] != b'\n' {
1181 i += 1;
1182 }
1183 continue;
1184 }
1185 if i + 1 < bytes.len() && bytes[i] == b'\n' && bytes[i + 1] == b'\n' {
1187 if fence_count % 2 == 0 {
1189 last_safe_boundary = i + 2; }
1191 i += 2;
1192 continue;
1193 }
1194 i += 1;
1195 }
1196 last_safe_boundary
1197}
1198
1199fn build_message_lines_incremental(
1204 app: &ChatApp,
1205 inner_width: usize,
1206 bubble_max_width: usize,
1207 old_cache: Option<&MsgLinesCache>,
1208) -> (
1209 Vec<Line<'static>>,
1210 Vec<(usize, usize)>,
1211 Vec<PerMsgCache>,
1212 Vec<Line<'static>>,
1213 usize,
1214) {
1215 struct RenderMsg {
1216 role: String,
1217 content: String,
1218 msg_index: Option<usize>,
1219 }
1220 let mut render_msgs: Vec<RenderMsg> = app
1221 .session
1222 .messages
1223 .iter()
1224 .enumerate()
1225 .map(|(i, m)| RenderMsg {
1226 role: m.role.clone(),
1227 content: m.content.clone(),
1228 msg_index: Some(i),
1229 })
1230 .collect();
1231
1232 let streaming_content_str = if app.is_loading {
1234 let streaming = app.streaming_content.lock().unwrap().clone();
1235 if !streaming.is_empty() {
1236 render_msgs.push(RenderMsg {
1237 role: "assistant".to_string(),
1238 content: streaming.clone(),
1239 msg_index: None,
1240 });
1241 Some(streaming)
1242 } else {
1243 render_msgs.push(RenderMsg {
1244 role: "assistant".to_string(),
1245 content: "◍".to_string(),
1246 msg_index: None,
1247 });
1248 None
1249 }
1250 } else {
1251 None
1252 };
1253
1254 let is_browse_mode = app.mode == ChatMode::Browse;
1255 let mut lines: Vec<Line> = Vec::new();
1256 let mut msg_start_lines: Vec<(usize, usize)> = Vec::new();
1257 let mut per_msg_cache: Vec<PerMsgCache> = Vec::new();
1258
1259 let can_reuse_per_msg = old_cache
1261 .map(|c| c.bubble_max_width == bubble_max_width)
1262 .unwrap_or(false);
1263
1264 for msg in &render_msgs {
1265 let is_selected = is_browse_mode
1266 && msg.msg_index.is_some()
1267 && msg.msg_index.unwrap() == app.browse_msg_index;
1268
1269 if let Some(idx) = msg.msg_index {
1271 msg_start_lines.push((idx, lines.len()));
1272 }
1273
1274 if let Some(idx) = msg.msg_index {
1276 if can_reuse_per_msg {
1277 if let Some(old_c) = old_cache {
1278 if let Some(old_per) = old_c.per_msg_lines.iter().find(|p| p.msg_index == idx) {
1280 let old_was_selected = old_c.browse_index == Some(idx);
1282 if old_per.content_len == msg.content.len()
1283 && old_was_selected == is_selected
1284 {
1285 lines.extend(old_per.lines.iter().cloned());
1287 per_msg_cache.push(PerMsgCache {
1288 content_len: old_per.content_len,
1289 lines: old_per.lines.clone(),
1290 msg_index: idx,
1291 });
1292 continue;
1293 }
1294 }
1295 }
1296 }
1297 }
1298
1299 let msg_lines_start = lines.len();
1301 match msg.role.as_str() {
1302 "user" => {
1303 render_user_msg(
1304 &msg.content,
1305 is_selected,
1306 inner_width,
1307 bubble_max_width,
1308 &mut lines,
1309 );
1310 }
1311 "assistant" => {
1312 if msg.msg_index.is_none() {
1313 } else {
1317 render_assistant_msg(&msg.content, is_selected, bubble_max_width, &mut lines);
1319 }
1320 }
1321 "system" => {
1322 lines.push(Line::from(""));
1323 let wrapped = wrap_text(&msg.content, inner_width.saturating_sub(8));
1324 for wl in wrapped {
1325 lines.push(Line::from(Span::styled(
1326 format!(" {} {}", "sys", wl),
1327 Style::default().fg(Color::Rgb(100, 100, 120)),
1328 )));
1329 }
1330 }
1331 _ => {}
1332 }
1333
1334 if msg.role == "assistant" && msg.msg_index.is_none() {
1336 let bubble_bg = Color::Rgb(38, 38, 52);
1338 let pad_left_w = 3usize;
1339 let pad_right_w = 3usize;
1340 let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
1341 let bubble_total_w = bubble_max_width;
1342
1343 lines.push(Line::from(""));
1345 lines.push(Line::from(Span::styled(
1346 " AI",
1347 Style::default()
1348 .fg(Color::Rgb(120, 220, 160))
1349 .add_modifier(Modifier::BOLD),
1350 )));
1351
1352 lines.push(Line::from(vec![Span::styled(
1354 " ".repeat(bubble_total_w),
1355 Style::default().bg(bubble_bg),
1356 )]));
1357
1358 let (mut stable_lines, mut stable_offset) = if let Some(old_c) = old_cache {
1360 if old_c.bubble_max_width == bubble_max_width {
1361 (
1362 old_c.streaming_stable_lines.clone(),
1363 old_c.streaming_stable_offset,
1364 )
1365 } else {
1366 (Vec::new(), 0)
1367 }
1368 } else {
1369 (Vec::new(), 0)
1370 };
1371
1372 let content = &msg.content;
1373 let boundary = find_stable_boundary(content);
1375
1376 if boundary > stable_offset {
1378 let new_stable_text = &content[stable_offset..boundary];
1380 let new_md_lines = markdown_to_lines(new_stable_text, md_content_w + 2);
1381 for md_line in new_md_lines {
1383 let bubble_line = wrap_md_line_in_bubble(
1384 md_line,
1385 bubble_bg,
1386 pad_left_w,
1387 pad_right_w,
1388 bubble_total_w,
1389 );
1390 stable_lines.push(bubble_line);
1391 }
1392 stable_offset = boundary;
1393 }
1394
1395 lines.extend(stable_lines.iter().cloned());
1397
1398 let tail = &content[boundary..];
1400 if !tail.is_empty() {
1401 let tail_md_lines = markdown_to_lines(tail, md_content_w + 2);
1402 for md_line in tail_md_lines {
1403 let bubble_line = wrap_md_line_in_bubble(
1404 md_line,
1405 bubble_bg,
1406 pad_left_w,
1407 pad_right_w,
1408 bubble_total_w,
1409 );
1410 lines.push(bubble_line);
1411 }
1412 }
1413
1414 lines.push(Line::from(vec![Span::styled(
1416 " ".repeat(bubble_total_w),
1417 Style::default().bg(bubble_bg),
1418 )]));
1419
1420 let _ = (stable_lines.clone(), stable_offset);
1424
1425 } else if let Some(idx) = msg.msg_index {
1427 let msg_lines_end = lines.len();
1429 let this_msg_lines: Vec<Line<'static>> = lines[msg_lines_start..msg_lines_end].to_vec();
1430 per_msg_cache.push(PerMsgCache {
1431 content_len: msg.content.len(),
1432 lines: this_msg_lines,
1433 msg_index: idx,
1434 });
1435 }
1436 }
1437
1438 lines.push(Line::from(""));
1440
1441 let (final_stable_lines, final_stable_offset) = if let Some(ref sc) = streaming_content_str {
1443 let boundary = find_stable_boundary(sc);
1444 let bubble_bg = Color::Rgb(38, 38, 52);
1445 let pad_left_w = 3usize;
1446 let pad_right_w = 3usize;
1447 let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
1448 let bubble_total_w = bubble_max_width;
1449
1450 let (mut s_lines, s_offset) = if let Some(old_c) = old_cache {
1451 if old_c.bubble_max_width == bubble_max_width {
1452 (
1453 old_c.streaming_stable_lines.clone(),
1454 old_c.streaming_stable_offset,
1455 )
1456 } else {
1457 (Vec::new(), 0)
1458 }
1459 } else {
1460 (Vec::new(), 0)
1461 };
1462
1463 if boundary > s_offset {
1464 let new_text = &sc[s_offset..boundary];
1465 let new_md_lines = markdown_to_lines(new_text, md_content_w + 2);
1466 for md_line in new_md_lines {
1467 let bubble_line = wrap_md_line_in_bubble(
1468 md_line,
1469 bubble_bg,
1470 pad_left_w,
1471 pad_right_w,
1472 bubble_total_w,
1473 );
1474 s_lines.push(bubble_line);
1475 }
1476 }
1477 (s_lines, boundary)
1478 } else {
1479 (Vec::new(), 0)
1480 };
1481
1482 (
1483 lines,
1484 msg_start_lines,
1485 per_msg_cache,
1486 final_stable_lines,
1487 final_stable_offset,
1488 )
1489}
1490
1491fn wrap_md_line_in_bubble(
1493 md_line: Line<'static>,
1494 bubble_bg: Color,
1495 pad_left_w: usize,
1496 pad_right_w: usize,
1497 bubble_total_w: usize,
1498) -> Line<'static> {
1499 let pad_left = " ".repeat(pad_left_w);
1500 let pad_right = " ".repeat(pad_right_w);
1501 let mut styled_spans: Vec<Span> = Vec::new();
1502 styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
1503 let mut content_w: usize = 0;
1504 for span in md_line.spans {
1505 let sw = display_width(&span.content);
1506 content_w += sw;
1507 let merged_style = span.style.bg(bubble_bg);
1508 styled_spans.push(Span::styled(span.content.to_string(), merged_style));
1509 }
1510 let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
1511 let fill = target_content_w.saturating_sub(content_w);
1512 if fill > 0 {
1513 styled_spans.push(Span::styled(
1514 " ".repeat(fill),
1515 Style::default().bg(bubble_bg),
1516 ));
1517 }
1518 styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
1519 Line::from(styled_spans)
1520}
1521
1522fn render_user_msg(
1524 content: &str,
1525 is_selected: bool,
1526 inner_width: usize,
1527 bubble_max_width: usize,
1528 lines: &mut Vec<Line<'static>>,
1529) {
1530 lines.push(Line::from(""));
1531 let label = if is_selected { "▶ You " } else { "You " };
1532 let pad = inner_width.saturating_sub(display_width(label) + 2);
1533 lines.push(Line::from(vec![
1534 Span::raw(" ".repeat(pad)),
1535 Span::styled(
1536 label,
1537 Style::default()
1538 .fg(if is_selected {
1539 Color::Rgb(255, 200, 80)
1540 } else {
1541 Color::Rgb(100, 160, 255)
1542 })
1543 .add_modifier(Modifier::BOLD),
1544 ),
1545 ]));
1546 let user_bg = if is_selected {
1547 Color::Rgb(55, 85, 140)
1548 } else {
1549 Color::Rgb(40, 70, 120)
1550 };
1551 let user_pad_lr = 3usize;
1552 let user_content_w = bubble_max_width.saturating_sub(user_pad_lr * 2);
1553 let mut all_wrapped_lines: Vec<String> = Vec::new();
1554 for content_line in content.lines() {
1555 let wrapped = wrap_text(content_line, user_content_w);
1556 all_wrapped_lines.extend(wrapped);
1557 }
1558 if all_wrapped_lines.is_empty() {
1559 all_wrapped_lines.push(String::new());
1560 }
1561 let actual_content_w = all_wrapped_lines
1562 .iter()
1563 .map(|l| display_width(l))
1564 .max()
1565 .unwrap_or(0);
1566 let actual_bubble_w = (actual_content_w + user_pad_lr * 2)
1567 .min(bubble_max_width)
1568 .max(user_pad_lr * 2 + 1);
1569 let actual_inner_content_w = actual_bubble_w.saturating_sub(user_pad_lr * 2);
1570 {
1572 let bubble_text = " ".repeat(actual_bubble_w);
1573 let pad = inner_width.saturating_sub(actual_bubble_w);
1574 lines.push(Line::from(vec![
1575 Span::raw(" ".repeat(pad)),
1576 Span::styled(bubble_text, Style::default().bg(user_bg)),
1577 ]));
1578 }
1579 for wl in &all_wrapped_lines {
1580 let wl_width = display_width(wl);
1581 let fill = actual_inner_content_w.saturating_sub(wl_width);
1582 let text = format!(
1583 "{}{}{}{}",
1584 " ".repeat(user_pad_lr),
1585 wl,
1586 " ".repeat(fill),
1587 " ".repeat(user_pad_lr),
1588 );
1589 let text_width = display_width(&text);
1590 let pad = inner_width.saturating_sub(text_width);
1591 lines.push(Line::from(vec![
1592 Span::raw(" ".repeat(pad)),
1593 Span::styled(text, Style::default().fg(Color::White).bg(user_bg)),
1594 ]));
1595 }
1596 {
1598 let bubble_text = " ".repeat(actual_bubble_w);
1599 let pad = inner_width.saturating_sub(actual_bubble_w);
1600 lines.push(Line::from(vec![
1601 Span::raw(" ".repeat(pad)),
1602 Span::styled(bubble_text, Style::default().bg(user_bg)),
1603 ]));
1604 }
1605}
1606
1607fn render_assistant_msg(
1609 content: &str,
1610 is_selected: bool,
1611 bubble_max_width: usize,
1612 lines: &mut Vec<Line<'static>>,
1613) {
1614 lines.push(Line::from(""));
1615 let ai_label = if is_selected { " ▶ AI" } else { " AI" };
1616 lines.push(Line::from(Span::styled(
1617 ai_label,
1618 Style::default()
1619 .fg(if is_selected {
1620 Color::Rgb(255, 200, 80)
1621 } else {
1622 Color::Rgb(120, 220, 160)
1623 })
1624 .add_modifier(Modifier::BOLD),
1625 )));
1626 let bubble_bg = if is_selected {
1627 Color::Rgb(48, 48, 68)
1628 } else {
1629 Color::Rgb(38, 38, 52)
1630 };
1631 let pad_left_w = 3usize;
1632 let pad_right_w = 3usize;
1633 let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
1634 let md_lines = markdown_to_lines(content, md_content_w + 2);
1635 let bubble_total_w = bubble_max_width;
1636 lines.push(Line::from(vec![Span::styled(
1638 " ".repeat(bubble_total_w),
1639 Style::default().bg(bubble_bg),
1640 )]));
1641 for md_line in md_lines {
1642 let bubble_line =
1643 wrap_md_line_in_bubble(md_line, bubble_bg, pad_left_w, pad_right_w, bubble_total_w);
1644 lines.push(bubble_line);
1645 }
1646 lines.push(Line::from(vec![Span::styled(
1648 " ".repeat(bubble_total_w),
1649 Style::default().bg(bubble_bg),
1650 )]));
1651}
1652
1653fn markdown_to_lines(md: &str, max_width: usize) -> Vec<Line<'static>> {
1657 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1658
1659 let content_width = max_width.saturating_sub(2);
1661
1662 let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
1663 let parser = Parser::new_ext(md, options);
1664
1665 let mut lines: Vec<Line<'static>> = Vec::new();
1666 let mut current_spans: Vec<Span<'static>> = Vec::new();
1667 let mut style_stack: Vec<Style> = vec![Style::default().fg(Color::Rgb(220, 220, 230))];
1668 let mut in_code_block = false;
1669 let mut code_block_content = String::new();
1670 let mut code_block_lang = String::new();
1671 let mut list_depth: usize = 0;
1672 let mut ordered_index: Option<u64> = None;
1673 let mut heading_level: Option<u8> = None;
1674 let mut in_blockquote = false;
1676 let mut in_table = false;
1678 let mut table_rows: Vec<Vec<String>> = Vec::new(); let mut current_row: Vec<String> = Vec::new();
1680 let mut current_cell = String::new();
1681 let mut table_alignments: Vec<pulldown_cmark::Alignment> = Vec::new();
1682
1683 let base_style = Style::default().fg(Color::Rgb(220, 220, 230));
1684
1685 let flush_line = |current_spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
1686 if !current_spans.is_empty() {
1687 lines.push(Line::from(current_spans.drain(..).collect::<Vec<_>>()));
1688 }
1689 };
1690
1691 for event in parser {
1692 match event {
1693 Event::Start(Tag::Heading { level, .. }) => {
1694 flush_line(&mut current_spans, &mut lines);
1695 heading_level = Some(level as u8);
1696 if !lines.is_empty() {
1697 lines.push(Line::from(""));
1698 }
1699 let heading_style = match level as u8 {
1701 1 => Style::default()
1702 .fg(Color::Rgb(100, 180, 255))
1703 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1704 2 => Style::default()
1705 .fg(Color::Rgb(130, 190, 255))
1706 .add_modifier(Modifier::BOLD),
1707 3 => Style::default()
1708 .fg(Color::Rgb(160, 200, 255))
1709 .add_modifier(Modifier::BOLD),
1710 _ => Style::default()
1711 .fg(Color::Rgb(180, 210, 255))
1712 .add_modifier(Modifier::BOLD),
1713 };
1714 style_stack.push(heading_style);
1715 }
1716 Event::End(TagEnd::Heading(level)) => {
1717 flush_line(&mut current_spans, &mut lines);
1718 if (level as u8) <= 2 {
1720 let sep_char = if (level as u8) == 1 { "━" } else { "─" };
1721 lines.push(Line::from(Span::styled(
1722 sep_char.repeat(content_width),
1723 Style::default().fg(Color::Rgb(60, 70, 100)),
1724 )));
1725 }
1726 style_stack.pop();
1727 heading_level = None;
1728 }
1729 Event::Start(Tag::Strong) => {
1730 let current = *style_stack.last().unwrap_or(&base_style);
1731 style_stack.push(current.add_modifier(Modifier::BOLD));
1732 }
1733 Event::End(TagEnd::Strong) => {
1734 style_stack.pop();
1735 }
1736 Event::Start(Tag::Emphasis) => {
1737 let current = *style_stack.last().unwrap_or(&base_style);
1738 style_stack.push(current.add_modifier(Modifier::ITALIC));
1739 }
1740 Event::End(TagEnd::Emphasis) => {
1741 style_stack.pop();
1742 }
1743 Event::Start(Tag::Strikethrough) => {
1744 let current = *style_stack.last().unwrap_or(&base_style);
1745 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
1746 }
1747 Event::End(TagEnd::Strikethrough) => {
1748 style_stack.pop();
1749 }
1750 Event::Start(Tag::CodeBlock(kind)) => {
1751 flush_line(&mut current_spans, &mut lines);
1752 in_code_block = true;
1753 code_block_content.clear();
1754 code_block_lang = match kind {
1755 CodeBlockKind::Fenced(lang) => lang.to_string(),
1756 CodeBlockKind::Indented => String::new(),
1757 };
1758 let label = if code_block_lang.is_empty() {
1760 " code ".to_string()
1761 } else {
1762 format!(" {} ", code_block_lang)
1763 };
1764 let label_w = display_width(&label);
1765 let border_fill = content_width.saturating_sub(2 + label_w);
1766 let top_border = format!("┌─{}{}", label, "─".repeat(border_fill));
1767 lines.push(Line::from(Span::styled(
1768 top_border,
1769 Style::default().fg(Color::Rgb(80, 90, 110)),
1770 )));
1771 }
1772 Event::End(TagEnd::CodeBlock) => {
1773 let code_inner_w = content_width.saturating_sub(4); for code_line in code_block_content.lines() {
1776 let wrapped = wrap_text(code_line, code_inner_w);
1777 for wl in wrapped {
1778 let highlighted = highlight_code_line(&wl, &code_block_lang);
1779 let text_w: usize =
1780 highlighted.iter().map(|s| display_width(&s.content)).sum();
1781 let fill = code_inner_w.saturating_sub(text_w);
1782 let mut spans_vec = Vec::new();
1783 spans_vec.push(Span::styled(
1784 "│ ",
1785 Style::default().fg(Color::Rgb(80, 90, 110)),
1786 ));
1787 for hs in highlighted {
1788 spans_vec.push(Span::styled(
1789 hs.content.to_string(),
1790 hs.style.bg(Color::Rgb(30, 30, 42)),
1791 ));
1792 }
1793 spans_vec.push(Span::styled(
1794 format!("{} │", " ".repeat(fill)),
1795 Style::default()
1796 .fg(Color::Rgb(80, 90, 110))
1797 .bg(Color::Rgb(30, 30, 42)),
1798 ));
1799 lines.push(Line::from(spans_vec));
1800 }
1801 }
1802 let bottom_border = format!("└{}", "─".repeat(content_width.saturating_sub(1)));
1803 lines.push(Line::from(Span::styled(
1804 bottom_border,
1805 Style::default().fg(Color::Rgb(80, 90, 110)),
1806 )));
1807 in_code_block = false;
1808 code_block_content.clear();
1809 code_block_lang.clear();
1810 }
1811 Event::Code(text) => {
1812 if in_table {
1813 current_cell.push('`');
1815 current_cell.push_str(&text);
1816 current_cell.push('`');
1817 } else {
1818 current_spans.push(Span::styled(
1820 format!(" {} ", text),
1821 Style::default()
1822 .fg(Color::Rgb(230, 190, 120))
1823 .bg(Color::Rgb(45, 45, 60)),
1824 ));
1825 }
1826 }
1827 Event::Start(Tag::List(start)) => {
1828 flush_line(&mut current_spans, &mut lines);
1829 list_depth += 1;
1830 ordered_index = start;
1831 }
1832 Event::End(TagEnd::List(_)) => {
1833 flush_line(&mut current_spans, &mut lines);
1834 list_depth = list_depth.saturating_sub(1);
1835 ordered_index = None;
1836 }
1837 Event::Start(Tag::Item) => {
1838 flush_line(&mut current_spans, &mut lines);
1839 let indent = " ".repeat(list_depth);
1840 let bullet = if let Some(ref mut idx) = ordered_index {
1841 let s = format!("{}{}. ", indent, idx);
1842 *idx += 1;
1843 s
1844 } else {
1845 format!("{}- ", indent)
1846 };
1847 current_spans.push(Span::styled(
1848 bullet,
1849 Style::default().fg(Color::Rgb(160, 180, 220)),
1850 ));
1851 }
1852 Event::End(TagEnd::Item) => {
1853 flush_line(&mut current_spans, &mut lines);
1854 }
1855 Event::Start(Tag::Paragraph) => {
1856 if !lines.is_empty() && !in_code_block && heading_level.is_none() {
1857 let last_empty = lines.last().map(|l| l.spans.is_empty()).unwrap_or(false);
1858 if !last_empty {
1859 lines.push(Line::from(""));
1860 }
1861 }
1862 }
1863 Event::End(TagEnd::Paragraph) => {
1864 flush_line(&mut current_spans, &mut lines);
1865 }
1866 Event::Start(Tag::BlockQuote(_)) => {
1867 flush_line(&mut current_spans, &mut lines);
1868 in_blockquote = true;
1869 style_stack.push(Style::default().fg(Color::Rgb(150, 160, 180)));
1870 }
1871 Event::End(TagEnd::BlockQuote(_)) => {
1872 flush_line(&mut current_spans, &mut lines);
1873 in_blockquote = false;
1874 style_stack.pop();
1875 }
1876 Event::Text(text) => {
1877 if in_code_block {
1878 code_block_content.push_str(&text);
1879 } else if in_table {
1880 current_cell.push_str(&text);
1882 } else {
1883 let style = *style_stack.last().unwrap_or(&base_style);
1884 let text_str = text.to_string();
1885
1886 if let Some(level) = heading_level {
1888 let (prefix, prefix_style) = match level {
1889 1 => (
1890 ">> ",
1891 Style::default()
1892 .fg(Color::Rgb(100, 180, 255))
1893 .add_modifier(Modifier::BOLD),
1894 ),
1895 2 => (
1896 ">> ",
1897 Style::default()
1898 .fg(Color::Rgb(130, 190, 255))
1899 .add_modifier(Modifier::BOLD),
1900 ),
1901 3 => (
1902 "> ",
1903 Style::default()
1904 .fg(Color::Rgb(160, 200, 255))
1905 .add_modifier(Modifier::BOLD),
1906 ),
1907 _ => (
1908 "> ",
1909 Style::default()
1910 .fg(Color::Rgb(180, 210, 255))
1911 .add_modifier(Modifier::BOLD),
1912 ),
1913 };
1914 current_spans.push(Span::styled(prefix.to_string(), prefix_style));
1915 heading_level = None; }
1917
1918 let existing_w: usize = current_spans
1920 .iter()
1921 .map(|s| display_width(&s.content))
1922 .sum();
1923
1924 let effective_prefix_w = if in_blockquote { 2 } else { 0 }; let wrap_w = content_width.saturating_sub(effective_prefix_w + existing_w);
1927
1928 for (i, line) in text_str.split('\n').enumerate() {
1929 if i > 0 {
1930 flush_line(&mut current_spans, &mut lines);
1931 if in_blockquote {
1932 current_spans.push(Span::styled(
1933 "| ".to_string(),
1934 Style::default().fg(Color::Rgb(80, 100, 140)),
1935 ));
1936 }
1937 }
1938 if !line.is_empty() {
1939 let effective_wrap = if i == 0 {
1941 wrap_w
1942 } else {
1943 content_width.saturating_sub(effective_prefix_w)
1944 };
1945 let wrapped = wrap_text(line, effective_wrap);
1946 for (j, wl) in wrapped.iter().enumerate() {
1947 if j > 0 {
1948 flush_line(&mut current_spans, &mut lines);
1949 if in_blockquote {
1950 current_spans.push(Span::styled(
1951 "| ".to_string(),
1952 Style::default().fg(Color::Rgb(80, 100, 140)),
1953 ));
1954 }
1955 }
1956 current_spans.push(Span::styled(wl.clone(), style));
1957 }
1958 }
1959 }
1960 }
1961 }
1962 Event::SoftBreak => {
1963 if in_table {
1964 current_cell.push(' ');
1965 } else {
1966 current_spans.push(Span::raw(" "));
1967 }
1968 }
1969 Event::HardBreak => {
1970 if in_table {
1971 current_cell.push(' ');
1972 } else {
1973 flush_line(&mut current_spans, &mut lines);
1974 }
1975 }
1976 Event::Rule => {
1977 flush_line(&mut current_spans, &mut lines);
1978 lines.push(Line::from(Span::styled(
1979 "─".repeat(content_width),
1980 Style::default().fg(Color::Rgb(70, 75, 90)),
1981 )));
1982 }
1983 Event::Start(Tag::Table(alignments)) => {
1985 flush_line(&mut current_spans, &mut lines);
1986 in_table = true;
1987 table_rows.clear();
1988 table_alignments = alignments;
1989 }
1990 Event::End(TagEnd::Table) => {
1991 flush_line(&mut current_spans, &mut lines);
1993 in_table = false;
1994
1995 if !table_rows.is_empty() {
1996 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
1997 if num_cols > 0 {
1998 let mut col_widths: Vec<usize> = vec![0; num_cols];
2000 for row in &table_rows {
2001 for (i, cell) in row.iter().enumerate() {
2002 let w = display_width(cell);
2003 if w > col_widths[i] {
2004 col_widths[i] = w;
2005 }
2006 }
2007 }
2008
2009 let sep_w = num_cols + 1; let pad_w = num_cols * 2; let avail = content_width.saturating_sub(sep_w + pad_w);
2013 let max_col_w = avail * 2 / 3;
2015 for cw in col_widths.iter_mut() {
2016 if *cw > max_col_w {
2017 *cw = max_col_w;
2018 }
2019 }
2020 let total_col_w: usize = col_widths.iter().sum();
2021 if total_col_w > avail && total_col_w > 0 {
2022 let mut remaining = avail;
2024 for (i, cw) in col_widths.iter_mut().enumerate() {
2025 if i == num_cols - 1 {
2026 *cw = remaining.max(1);
2028 } else {
2029 *cw = ((*cw) * avail / total_col_w).max(1);
2030 remaining = remaining.saturating_sub(*cw);
2031 }
2032 }
2033 }
2034
2035 let table_style = Style::default().fg(Color::Rgb(180, 180, 200));
2036 let header_style = Style::default()
2037 .fg(Color::Rgb(120, 180, 255))
2038 .add_modifier(Modifier::BOLD);
2039 let border_style = Style::default().fg(Color::Rgb(60, 70, 100));
2040
2041 let total_col_w_final: usize = col_widths.iter().sum();
2044 let table_row_w = sep_w + pad_w + total_col_w_final;
2045 let table_right_pad = content_width.saturating_sub(table_row_w);
2047
2048 let mut top = String::from("┌");
2050 for (i, cw) in col_widths.iter().enumerate() {
2051 top.push_str(&"─".repeat(cw + 2));
2052 if i < num_cols - 1 {
2053 top.push('┬');
2054 }
2055 }
2056 top.push('┐');
2057 let mut top_spans = vec![Span::styled(top, border_style)];
2059 if table_right_pad > 0 {
2060 top_spans.push(Span::raw(" ".repeat(table_right_pad)));
2061 }
2062 lines.push(Line::from(top_spans));
2063
2064 for (row_idx, row) in table_rows.iter().enumerate() {
2065 let mut row_spans: Vec<Span> = Vec::new();
2067 row_spans.push(Span::styled("│", border_style));
2068 for (i, cw) in col_widths.iter().enumerate() {
2069 let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
2070 let cell_w = display_width(cell_text);
2071 let text = if cell_w > *cw {
2072 let mut t = String::new();
2074 let mut w = 0;
2075 for ch in cell_text.chars() {
2076 let chw = char_width(ch);
2077 if w + chw > *cw {
2078 break;
2079 }
2080 t.push(ch);
2081 w += chw;
2082 }
2083 let fill = cw.saturating_sub(w);
2084 format!(" {}{} ", t, " ".repeat(fill))
2085 } else {
2086 let fill = cw.saturating_sub(cell_w);
2088 let align = table_alignments
2089 .get(i)
2090 .copied()
2091 .unwrap_or(pulldown_cmark::Alignment::None);
2092 match align {
2093 pulldown_cmark::Alignment::Center => {
2094 let left = fill / 2;
2095 let right = fill - left;
2096 format!(
2097 " {}{}{} ",
2098 " ".repeat(left),
2099 cell_text,
2100 " ".repeat(right)
2101 )
2102 }
2103 pulldown_cmark::Alignment::Right => {
2104 format!(" {}{} ", " ".repeat(fill), cell_text)
2105 }
2106 _ => {
2107 format!(" {}{} ", cell_text, " ".repeat(fill))
2108 }
2109 }
2110 };
2111 let style = if row_idx == 0 {
2112 header_style
2113 } else {
2114 table_style
2115 };
2116 row_spans.push(Span::styled(text, style));
2117 row_spans.push(Span::styled("│", border_style));
2118 }
2119 if table_right_pad > 0 {
2121 row_spans.push(Span::raw(" ".repeat(table_right_pad)));
2122 }
2123 lines.push(Line::from(row_spans));
2124
2125 if row_idx == 0 {
2127 let mut sep = String::from("├");
2128 for (i, cw) in col_widths.iter().enumerate() {
2129 sep.push_str(&"─".repeat(cw + 2));
2130 if i < num_cols - 1 {
2131 sep.push('┼');
2132 }
2133 }
2134 sep.push('┤');
2135 let mut sep_spans = vec![Span::styled(sep, border_style)];
2136 if table_right_pad > 0 {
2137 sep_spans.push(Span::raw(" ".repeat(table_right_pad)));
2138 }
2139 lines.push(Line::from(sep_spans));
2140 }
2141 }
2142
2143 let mut bottom = String::from("└");
2145 for (i, cw) in col_widths.iter().enumerate() {
2146 bottom.push_str(&"─".repeat(cw + 2));
2147 if i < num_cols - 1 {
2148 bottom.push('┴');
2149 }
2150 }
2151 bottom.push('┘');
2152 let mut bottom_spans = vec![Span::styled(bottom, border_style)];
2153 if table_right_pad > 0 {
2154 bottom_spans.push(Span::raw(" ".repeat(table_right_pad)));
2155 }
2156 lines.push(Line::from(bottom_spans));
2157 }
2158 }
2159 table_rows.clear();
2160 table_alignments.clear();
2161 }
2162 Event::Start(Tag::TableHead) => {
2163 current_row.clear();
2164 }
2165 Event::End(TagEnd::TableHead) => {
2166 table_rows.push(current_row.clone());
2167 current_row.clear();
2168 }
2169 Event::Start(Tag::TableRow) => {
2170 current_row.clear();
2171 }
2172 Event::End(TagEnd::TableRow) => {
2173 table_rows.push(current_row.clone());
2174 current_row.clear();
2175 }
2176 Event::Start(Tag::TableCell) => {
2177 current_cell.clear();
2178 }
2179 Event::End(TagEnd::TableCell) => {
2180 current_row.push(current_cell.clone());
2181 current_cell.clear();
2182 }
2183 _ => {}
2184 }
2185 }
2186
2187 if !current_spans.is_empty() {
2189 lines.push(Line::from(current_spans));
2190 }
2191
2192 if lines.is_empty() {
2194 let wrapped = wrap_text(md, content_width);
2195 for wl in wrapped {
2196 lines.push(Line::from(Span::styled(wl, base_style)));
2197 }
2198 }
2199
2200 lines
2201}
2202
2203fn highlight_code_line<'a>(line: &'a str, lang: &str) -> Vec<Span<'static>> {
2206 let lang_lower = lang.to_lowercase();
2207 let keywords: &[&str] = match lang_lower.as_str() {
2208 "rust" | "rs" => &[
2209 "fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for",
2210 "while", "loop", "if", "else", "match", "return", "self", "Self", "where", "async",
2211 "await", "move", "ref", "type", "const", "static", "crate", "super", "as", "in",
2212 "true", "false", "Some", "None", "Ok", "Err",
2213 ],
2214 "python" | "py" => &[
2215 "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as",
2216 "with", "try", "except", "finally", "raise", "pass", "break", "continue", "yield",
2217 "lambda", "and", "or", "not", "in", "is", "True", "False", "None", "global",
2218 "nonlocal", "assert", "del", "async", "await", "self", "print",
2219 ],
2220 "javascript" | "js" | "typescript" | "ts" | "jsx" | "tsx" => &[
2221 "function",
2222 "const",
2223 "let",
2224 "var",
2225 "return",
2226 "if",
2227 "else",
2228 "for",
2229 "while",
2230 "class",
2231 "new",
2232 "this",
2233 "import",
2234 "export",
2235 "from",
2236 "default",
2237 "async",
2238 "await",
2239 "try",
2240 "catch",
2241 "finally",
2242 "throw",
2243 "typeof",
2244 "instanceof",
2245 "true",
2246 "false",
2247 "null",
2248 "undefined",
2249 "of",
2250 "in",
2251 "switch",
2252 "case",
2253 ],
2254 "go" | "golang" => &[
2255 "func",
2256 "package",
2257 "import",
2258 "return",
2259 "if",
2260 "else",
2261 "for",
2262 "range",
2263 "struct",
2264 "interface",
2265 "type",
2266 "var",
2267 "const",
2268 "defer",
2269 "go",
2270 "chan",
2271 "select",
2272 "case",
2273 "switch",
2274 "default",
2275 "break",
2276 "continue",
2277 "map",
2278 "true",
2279 "false",
2280 "nil",
2281 "make",
2282 "append",
2283 "len",
2284 "cap",
2285 ],
2286 "java" | "kotlin" | "kt" => &[
2287 "public",
2288 "private",
2289 "protected",
2290 "class",
2291 "interface",
2292 "extends",
2293 "implements",
2294 "return",
2295 "if",
2296 "else",
2297 "for",
2298 "while",
2299 "new",
2300 "this",
2301 "import",
2302 "package",
2303 "static",
2304 "final",
2305 "void",
2306 "int",
2307 "String",
2308 "boolean",
2309 "true",
2310 "false",
2311 "null",
2312 "try",
2313 "catch",
2314 "throw",
2315 "throws",
2316 "fun",
2317 "val",
2318 "var",
2319 "when",
2320 "object",
2321 "companion",
2322 ],
2323 "sh" | "bash" | "zsh" | "shell" => &[
2324 "if",
2325 "then",
2326 "else",
2327 "elif",
2328 "fi",
2329 "for",
2330 "while",
2331 "do",
2332 "done",
2333 "case",
2334 "esac",
2335 "function",
2336 "return",
2337 "exit",
2338 "echo",
2339 "export",
2340 "local",
2341 "readonly",
2342 "set",
2343 "unset",
2344 "shift",
2345 "source",
2346 "in",
2347 "true",
2348 "false",
2349 "read",
2350 "declare",
2351 "typeset",
2352 "trap",
2353 "eval",
2354 "exec",
2355 "test",
2356 "select",
2357 "until",
2358 "break",
2359 "continue",
2360 "printf",
2361 "go",
2363 "build",
2364 "run",
2365 "test",
2366 "fmt",
2367 "vet",
2368 "mod",
2369 "get",
2370 "install",
2371 "clean",
2372 "doc",
2373 "list",
2374 "version",
2375 "env",
2376 "generate",
2377 "tool",
2378 "proxy",
2379 "GOPATH",
2380 "GOROOT",
2381 "GOBIN",
2382 "GOMODCACHE",
2383 "GOPROXY",
2384 "GOSUMDB",
2385 "cargo",
2387 "new",
2388 "init",
2389 "add",
2390 "remove",
2391 "update",
2392 "check",
2393 "clippy",
2394 "rustfmt",
2395 "rustc",
2396 "rustup",
2397 "publish",
2398 "install",
2399 "uninstall",
2400 "search",
2401 "tree",
2402 "locate_project",
2403 "metadata",
2404 "audit",
2405 "watch",
2406 "expand",
2407 ],
2408 "c" | "cpp" | "c++" | "h" | "hpp" => &[
2409 "int",
2410 "char",
2411 "float",
2412 "double",
2413 "void",
2414 "long",
2415 "short",
2416 "unsigned",
2417 "signed",
2418 "const",
2419 "static",
2420 "extern",
2421 "struct",
2422 "union",
2423 "enum",
2424 "typedef",
2425 "sizeof",
2426 "return",
2427 "if",
2428 "else",
2429 "for",
2430 "while",
2431 "do",
2432 "switch",
2433 "case",
2434 "break",
2435 "continue",
2436 "default",
2437 "goto",
2438 "auto",
2439 "register",
2440 "volatile",
2441 "class",
2442 "public",
2443 "private",
2444 "protected",
2445 "virtual",
2446 "override",
2447 "template",
2448 "namespace",
2449 "using",
2450 "new",
2451 "delete",
2452 "try",
2453 "catch",
2454 "throw",
2455 "nullptr",
2456 "true",
2457 "false",
2458 "this",
2459 "include",
2460 "define",
2461 "ifdef",
2462 "ifndef",
2463 "endif",
2464 ],
2465 "sql" => &[
2466 "SELECT",
2467 "FROM",
2468 "WHERE",
2469 "INSERT",
2470 "UPDATE",
2471 "DELETE",
2472 "CREATE",
2473 "DROP",
2474 "ALTER",
2475 "TABLE",
2476 "INDEX",
2477 "INTO",
2478 "VALUES",
2479 "SET",
2480 "AND",
2481 "OR",
2482 "NOT",
2483 "NULL",
2484 "JOIN",
2485 "LEFT",
2486 "RIGHT",
2487 "INNER",
2488 "OUTER",
2489 "ON",
2490 "GROUP",
2491 "BY",
2492 "ORDER",
2493 "ASC",
2494 "DESC",
2495 "HAVING",
2496 "LIMIT",
2497 "OFFSET",
2498 "UNION",
2499 "AS",
2500 "DISTINCT",
2501 "COUNT",
2502 "SUM",
2503 "AVG",
2504 "MIN",
2505 "MAX",
2506 "LIKE",
2507 "IN",
2508 "BETWEEN",
2509 "EXISTS",
2510 "CASE",
2511 "WHEN",
2512 "THEN",
2513 "ELSE",
2514 "END",
2515 "BEGIN",
2516 "COMMIT",
2517 "ROLLBACK",
2518 "PRIMARY",
2519 "KEY",
2520 "FOREIGN",
2521 "REFERENCES",
2522 "select",
2523 "from",
2524 "where",
2525 "insert",
2526 "update",
2527 "delete",
2528 "create",
2529 "drop",
2530 "alter",
2531 "table",
2532 "index",
2533 "into",
2534 "values",
2535 "set",
2536 "and",
2537 "or",
2538 "not",
2539 "null",
2540 "join",
2541 "left",
2542 "right",
2543 "inner",
2544 "outer",
2545 "on",
2546 "group",
2547 "by",
2548 "order",
2549 "asc",
2550 "desc",
2551 "having",
2552 "limit",
2553 "offset",
2554 "union",
2555 "as",
2556 "distinct",
2557 "count",
2558 "sum",
2559 "avg",
2560 "min",
2561 "max",
2562 "like",
2563 "in",
2564 "between",
2565 "exists",
2566 "case",
2567 "when",
2568 "then",
2569 "else",
2570 "end",
2571 "begin",
2572 "commit",
2573 "rollback",
2574 "primary",
2575 "key",
2576 "foreign",
2577 "references",
2578 ],
2579 "yaml" | "yml" => &["true", "false", "null", "yes", "no", "on", "off"],
2580 "toml" => &[
2581 "true",
2582 "false",
2583 "true",
2584 "false",
2585 "name",
2587 "version",
2588 "edition",
2589 "authors",
2590 "dependencies",
2591 "dev-dependencies",
2592 "build-dependencies",
2593 "features",
2594 "workspace",
2595 "members",
2596 "exclude",
2597 "include",
2598 "path",
2599 "git",
2600 "branch",
2601 "tag",
2602 "rev",
2603 "package",
2604 "lib",
2605 "bin",
2606 "example",
2607 "test",
2608 "bench",
2609 "doc",
2610 "profile",
2611 "release",
2612 "debug",
2613 "opt-level",
2614 "lto",
2615 "codegen-units",
2616 "panic",
2617 "strip",
2618 "default",
2619 "features",
2620 "optional",
2621 "repository",
2623 "homepage",
2624 "documentation",
2625 "license",
2626 "license-file",
2627 "keywords",
2628 "categories",
2629 "readme",
2630 "description",
2631 "resolver",
2632 ],
2633 "css" | "scss" | "less" => &[
2634 "color",
2635 "background",
2636 "border",
2637 "margin",
2638 "padding",
2639 "display",
2640 "position",
2641 "width",
2642 "height",
2643 "font",
2644 "text",
2645 "flex",
2646 "grid",
2647 "align",
2648 "justify",
2649 "important",
2650 "none",
2651 "auto",
2652 "inherit",
2653 "initial",
2654 "unset",
2655 ],
2656 "dockerfile" | "docker" => &[
2657 "FROM",
2658 "RUN",
2659 "CMD",
2660 "LABEL",
2661 "EXPOSE",
2662 "ENV",
2663 "ADD",
2664 "COPY",
2665 "ENTRYPOINT",
2666 "VOLUME",
2667 "USER",
2668 "WORKDIR",
2669 "ARG",
2670 "ONBUILD",
2671 "STOPSIGNAL",
2672 "HEALTHCHECK",
2673 "SHELL",
2674 "AS",
2675 ],
2676 "ruby" | "rb" => &[
2677 "def", "end", "class", "module", "if", "elsif", "else", "unless", "while", "until",
2678 "for", "do", "begin", "rescue", "ensure", "raise", "return", "yield", "require",
2679 "include", "attr", "self", "true", "false", "nil", "puts", "print",
2680 ],
2681 _ => &[
2682 "fn", "function", "def", "class", "return", "if", "else", "for", "while", "import",
2683 "export", "const", "let", "var", "true", "false", "null", "nil", "None", "self",
2684 "this",
2685 ],
2686 };
2687
2688 let comment_prefix = match lang_lower.as_str() {
2689 "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml"
2690 | "toml" | "dockerfile" | "docker" => "#",
2691 "sql" => "--",
2692 "css" | "scss" | "less" => "/*",
2693 _ => "//",
2694 };
2695
2696 let code_style = Style::default().fg(Color::Rgb(200, 200, 210));
2698 let kw_style = Style::default().fg(Color::Rgb(198, 120, 221));
2700 let str_style = Style::default().fg(Color::Rgb(152, 195, 121));
2702 let comment_style = Style::default()
2704 .fg(Color::Rgb(92, 99, 112))
2705 .add_modifier(Modifier::ITALIC);
2706 let num_style = Style::default().fg(Color::Rgb(209, 154, 102));
2708 let type_style = Style::default().fg(Color::Rgb(229, 192, 123));
2710
2711 let trimmed = line.trim_start();
2712
2713 if trimmed.starts_with(comment_prefix) {
2715 return vec![Span::styled(line.to_string(), comment_style)];
2716 }
2717
2718 let mut spans = Vec::new();
2720 let mut chars = line.chars().peekable();
2721 let mut buf = String::new();
2722
2723 while let Some(&ch) = chars.peek() {
2724 if ch == '"' || ch == '\'' || ch == '`' {
2726 if !buf.is_empty() {
2728 spans.extend(colorize_tokens(
2729 &buf, keywords, code_style, kw_style, num_style, type_style,
2730 ));
2731 buf.clear();
2732 }
2733 let quote = ch;
2734 let mut s = String::new();
2735 s.push(ch);
2736 chars.next();
2737 while let Some(&c) = chars.peek() {
2738 s.push(c);
2739 chars.next();
2740 if c == quote && !s.ends_with("\\\\") {
2741 break;
2742 }
2743 }
2744 spans.push(Span::styled(s, str_style));
2745 continue;
2746 }
2747 if ch == '$'
2749 && matches!(
2750 lang_lower.as_str(),
2751 "sh" | "bash" | "zsh" | "shell" | "dockerfile" | "docker"
2752 )
2753 {
2754 if !buf.is_empty() {
2755 spans.extend(colorize_tokens(
2756 &buf, keywords, code_style, kw_style, num_style, type_style,
2757 ));
2758 buf.clear();
2759 }
2760 let var_style = Style::default().fg(Color::Rgb(86, 182, 194));
2761 let mut var = String::new();
2762 var.push(ch);
2763 chars.next();
2764 if let Some(&next_ch) = chars.peek() {
2765 if next_ch == '{' {
2766 var.push(next_ch);
2768 chars.next();
2769 while let Some(&c) = chars.peek() {
2770 var.push(c);
2771 chars.next();
2772 if c == '}' {
2773 break;
2774 }
2775 }
2776 } else if next_ch == '(' {
2777 var.push(next_ch);
2779 chars.next();
2780 let mut depth = 1;
2781 while let Some(&c) = chars.peek() {
2782 var.push(c);
2783 chars.next();
2784 if c == '(' {
2785 depth += 1;
2786 }
2787 if c == ')' {
2788 depth -= 1;
2789 if depth == 0 {
2790 break;
2791 }
2792 }
2793 }
2794 } else if next_ch.is_alphanumeric()
2795 || next_ch == '_'
2796 || next_ch == '@'
2797 || next_ch == '#'
2798 || next_ch == '?'
2799 || next_ch == '!'
2800 {
2801 while let Some(&c) = chars.peek() {
2803 if c.is_alphanumeric() || c == '_' {
2804 var.push(c);
2805 chars.next();
2806 } else {
2807 break;
2808 }
2809 }
2810 }
2811 }
2812 spans.push(Span::styled(var, var_style));
2813 continue;
2814 }
2815 if ch == '/' || ch == '#' {
2817 let rest: String = chars.clone().collect();
2818 if rest.starts_with(comment_prefix) {
2819 if !buf.is_empty() {
2820 spans.extend(colorize_tokens(
2821 &buf, keywords, code_style, kw_style, num_style, type_style,
2822 ));
2823 buf.clear();
2824 }
2825 spans.push(Span::styled(rest, comment_style));
2826 break;
2827 }
2828 }
2829 buf.push(ch);
2830 chars.next();
2831 }
2832
2833 if !buf.is_empty() {
2834 spans.extend(colorize_tokens(
2835 &buf, keywords, code_style, kw_style, num_style, type_style,
2836 ));
2837 }
2838
2839 if spans.is_empty() {
2840 spans.push(Span::styled(line.to_string(), code_style));
2841 }
2842
2843 spans
2844}
2845
2846fn colorize_tokens<'a>(
2848 text: &str,
2849 keywords: &[&str],
2850 default_style: Style,
2851 kw_style: Style,
2852 num_style: Style,
2853 type_style: Style,
2854) -> Vec<Span<'static>> {
2855 let mut spans = Vec::new();
2856 let mut current_word = String::new();
2857 let mut current_non_word = String::new();
2858
2859 for ch in text.chars() {
2860 if ch.is_alphanumeric() || ch == '_' {
2861 if !current_non_word.is_empty() {
2862 spans.push(Span::styled(current_non_word.clone(), default_style));
2863 current_non_word.clear();
2864 }
2865 current_word.push(ch);
2866 } else {
2867 if !current_word.is_empty() {
2868 let style = if keywords.contains(¤t_word.as_str()) {
2869 kw_style
2870 } else if current_word
2871 .chars()
2872 .next()
2873 .map(|c| c.is_ascii_digit())
2874 .unwrap_or(false)
2875 {
2876 num_style
2877 } else if current_word
2878 .chars()
2879 .next()
2880 .map(|c| c.is_uppercase())
2881 .unwrap_or(false)
2882 {
2883 type_style
2884 } else {
2885 default_style
2886 };
2887 spans.push(Span::styled(current_word.clone(), style));
2888 current_word.clear();
2889 }
2890 current_non_word.push(ch);
2891 }
2892 }
2893
2894 if !current_non_word.is_empty() {
2896 spans.push(Span::styled(current_non_word, default_style));
2897 }
2898 if !current_word.is_empty() {
2899 let style = if keywords.contains(¤t_word.as_str()) {
2900 kw_style
2901 } else if current_word
2902 .chars()
2903 .next()
2904 .map(|c| c.is_ascii_digit())
2905 .unwrap_or(false)
2906 {
2907 num_style
2908 } else if current_word
2909 .chars()
2910 .next()
2911 .map(|c| c.is_uppercase())
2912 .unwrap_or(false)
2913 {
2914 type_style
2915 } else {
2916 default_style
2917 };
2918 spans.push(Span::styled(current_word, style));
2919 }
2920
2921 spans
2922}
2923
2924fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
2926 if max_width == 0 {
2927 return vec![text.to_string()];
2928 }
2929 let mut result = Vec::new();
2930 let mut current_line = String::new();
2931 let mut current_width = 0;
2932
2933 for ch in text.chars() {
2934 let ch_width = char_width(ch);
2935 if current_width + ch_width > max_width && !current_line.is_empty() {
2936 result.push(current_line.clone());
2937 current_line.clear();
2938 current_width = 0;
2939 }
2940 current_line.push(ch);
2941 current_width += ch_width;
2942 }
2943 if !current_line.is_empty() {
2944 result.push(current_line);
2945 }
2946 if result.is_empty() {
2947 result.push(String::new());
2948 }
2949 result
2950}
2951
2952fn display_width(s: &str) -> usize {
2955 s.chars().map(|c| char_width(c)).sum()
2956}
2957
2958fn char_width(c: char) -> usize {
2960 if c.is_ascii() {
2961 return 1;
2962 }
2963 let cp = c as u32;
2965 if (0x4E00..=0x9FFF).contains(&cp) || (0x3400..=0x4DBF).contains(&cp) || (0x20000..=0x2A6DF).contains(&cp) || (0x2A700..=0x2B73F).contains(&cp) || (0x2B740..=0x2B81F).contains(&cp) || (0xF900..=0xFAFF).contains(&cp) || (0x2F800..=0x2FA1F).contains(&cp) || (0x3000..=0x303F).contains(&cp) || (0xFF01..=0xFF60).contains(&cp) || (0xFFE0..=0xFFE6).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xAC00..=0xD7AF).contains(&cp) || (0x1F300..=0x1F9FF).contains(&cp)
2982 || (0x2600..=0x26FF).contains(&cp)
2983 || (0x2700..=0x27BF).contains(&cp)
2984 {
2985 2
2986 } else {
2987 1
2988 }
2989}
2990
2991fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2993 let usable_width = area.width.saturating_sub(2 + 4) as usize;
2995
2996 let chars: Vec<char> = app.input.chars().collect();
2997
2998 let before_all: String = chars[..app.cursor_pos].iter().collect();
3000 let before_width = display_width(&before_all);
3001
3002 let scroll_offset_chars = if before_width >= usable_width {
3004 let target_width = before_width.saturating_sub(usable_width / 2);
3006 let mut w = 0;
3007 let mut skip = 0;
3008 for (i, &ch) in chars.iter().enumerate() {
3009 if w >= target_width {
3010 skip = i;
3011 break;
3012 }
3013 w += char_width(ch);
3014 }
3015 skip
3016 } else {
3017 0
3018 };
3019
3020 let visible_chars = &chars[scroll_offset_chars..];
3022 let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
3023
3024 let before: String = visible_chars[..cursor_in_visible].iter().collect();
3025 let cursor_ch = if cursor_in_visible < visible_chars.len() {
3026 visible_chars[cursor_in_visible].to_string()
3027 } else {
3028 " ".to_string()
3029 };
3030 let after: String = if cursor_in_visible < visible_chars.len() {
3031 visible_chars[cursor_in_visible + 1..].iter().collect()
3032 } else {
3033 String::new()
3034 };
3035
3036 let prompt_style = if app.is_loading {
3037 Style::default().fg(Color::Rgb(255, 200, 80))
3038 } else {
3039 Style::default().fg(Color::Rgb(100, 200, 130))
3040 };
3041 let prompt_text = if app.is_loading { " .. " } else { " > " };
3042
3043 let full_visible = format!("{}{}{}", before, cursor_ch, after);
3045 let inner_height = area.height.saturating_sub(2) as usize; let wrapped_lines = wrap_text(&full_visible, usable_width);
3047
3048 let before_len = before.chars().count();
3050 let cursor_len = cursor_ch.chars().count();
3051 let cursor_global_pos = before_len; let mut cursor_line_idx: usize = 0;
3053 {
3054 let mut cumulative = 0usize;
3055 for (li, wl) in wrapped_lines.iter().enumerate() {
3056 let line_char_count = wl.chars().count();
3057 if cumulative + line_char_count > cursor_global_pos {
3058 cursor_line_idx = li;
3059 break;
3060 }
3061 cumulative += line_char_count;
3062 cursor_line_idx = li; }
3064 }
3065
3066 let line_scroll = if wrapped_lines.len() <= inner_height {
3068 0
3069 } else if cursor_line_idx < inner_height {
3070 0
3071 } else {
3072 cursor_line_idx.saturating_sub(inner_height - 1)
3074 };
3075
3076 let mut display_lines: Vec<Line> = Vec::new();
3078 let mut char_offset: usize = 0;
3079 for wl in wrapped_lines.iter().take(line_scroll) {
3081 char_offset += wl.chars().count();
3082 }
3083
3084 for (_line_idx, wl) in wrapped_lines
3085 .iter()
3086 .skip(line_scroll)
3087 .enumerate()
3088 .take(inner_height.max(1))
3089 {
3090 let mut spans: Vec<Span> = Vec::new();
3091 if _line_idx == 0 && line_scroll == 0 {
3092 spans.push(Span::styled(prompt_text, prompt_style));
3093 } else {
3094 spans.push(Span::styled(" ", Style::default())); }
3096
3097 let line_chars: Vec<char> = wl.chars().collect();
3099 let mut seg_start = 0;
3100 for (ci, &ch) in line_chars.iter().enumerate() {
3101 let global_idx = char_offset + ci;
3102 let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
3103
3104 if is_cursor {
3105 if ci > seg_start {
3107 let seg: String = line_chars[seg_start..ci].iter().collect();
3108 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
3109 }
3110 spans.push(Span::styled(
3111 ch.to_string(),
3112 Style::default()
3113 .fg(Color::Rgb(22, 22, 30))
3114 .bg(Color::Rgb(200, 210, 240)),
3115 ));
3116 seg_start = ci + 1;
3117 }
3118 }
3119 if seg_start < line_chars.len() {
3121 let seg: String = line_chars[seg_start..].iter().collect();
3122 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
3123 }
3124
3125 char_offset += line_chars.len();
3126 display_lines.push(Line::from(spans));
3127 }
3128
3129 if display_lines.is_empty() {
3130 display_lines.push(Line::from(vec![
3131 Span::styled(prompt_text, prompt_style),
3132 Span::styled(
3133 " ",
3134 Style::default()
3135 .fg(Color::Rgb(22, 22, 30))
3136 .bg(Color::Rgb(200, 210, 240)),
3137 ),
3138 ]));
3139 }
3140
3141 let input_widget = Paragraph::new(display_lines).block(
3142 Block::default()
3143 .borders(Borders::ALL)
3144 .border_type(ratatui::widgets::BorderType::Rounded)
3145 .border_style(if app.is_loading {
3146 Style::default().fg(Color::Rgb(120, 100, 50))
3147 } else {
3148 Style::default().fg(Color::Rgb(60, 100, 80))
3149 })
3150 .title(Span::styled(
3151 " 输入消息 ",
3152 Style::default().fg(Color::Rgb(140, 140, 170)),
3153 ))
3154 .style(Style::default().bg(Color::Rgb(26, 26, 38))),
3155 );
3156
3157 f.render_widget(input_widget, area);
3158
3159 if !app.is_loading {
3162 let prompt_w: u16 = 4; let border_left: u16 = 1; let cursor_col_in_line = {
3167 let mut col = 0usize;
3168 let mut char_count = 0usize;
3169 let mut skip_chars = 0usize;
3171 for wl in wrapped_lines.iter().take(line_scroll) {
3172 skip_chars += wl.chars().count();
3173 }
3174 for wl in wrapped_lines.iter().skip(line_scroll) {
3176 let line_len = wl.chars().count();
3177 if skip_chars + char_count + line_len > cursor_global_pos {
3178 let pos_in_line = cursor_global_pos - (skip_chars + char_count);
3180 col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
3181 break;
3182 }
3183 char_count += line_len;
3184 }
3185 col as u16
3186 };
3187
3188 let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
3190
3191 let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
3192 let cursor_y = area.y + 1 + cursor_row_in_display; if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
3196 f.set_cursor_position((cursor_x, cursor_y));
3197 }
3198 }
3199}
3200
3201fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
3203 let hints = match app.mode {
3204 ChatMode::Chat => {
3205 vec![
3206 ("Enter", "发送"),
3207 ("↑↓", "滚动"),
3208 ("Ctrl+T", "切换模型"),
3209 ("Ctrl+L", "清空"),
3210 ("Ctrl+Y", "复制"),
3211 ("Ctrl+B", "浏览"),
3212 ("Ctrl+S", "流式切换"),
3213 ("Ctrl+E", "配置"),
3214 ("?/F1", "帮助"),
3215 ("Esc", "退出"),
3216 ]
3217 }
3218 ChatMode::SelectModel => {
3219 vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
3220 }
3221 ChatMode::Browse => {
3222 vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
3223 }
3224 ChatMode::Help => {
3225 vec![("任意键", "返回")]
3226 }
3227 ChatMode::Config => {
3228 vec![
3229 ("↑↓", "切换字段"),
3230 ("Enter", "编辑"),
3231 ("Tab", "切换 Provider"),
3232 ("a", "新增"),
3233 ("d", "删除"),
3234 ("Esc", "保存返回"),
3235 ]
3236 }
3237 };
3238
3239 let mut spans: Vec<Span> = Vec::new();
3240 spans.push(Span::styled(" ", Style::default()));
3241 for (i, (key, desc)) in hints.iter().enumerate() {
3242 if i > 0 {
3243 spans.push(Span::styled(
3244 " │ ",
3245 Style::default().fg(Color::Rgb(50, 50, 65)),
3246 ));
3247 }
3248 spans.push(Span::styled(
3249 format!(" {} ", key),
3250 Style::default()
3251 .fg(Color::Rgb(22, 22, 30))
3252 .bg(Color::Rgb(100, 110, 140)),
3253 ));
3254 spans.push(Span::styled(
3255 format!(" {}", desc),
3256 Style::default().fg(Color::Rgb(120, 120, 150)),
3257 ));
3258 }
3259
3260 let hint_bar =
3261 Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(22, 22, 30)));
3262 f.render_widget(hint_bar, area);
3263}
3264
3265fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
3267 if let Some((ref msg, is_error, _)) = app.toast {
3268 let text_width = display_width(msg);
3269 let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
3271 let toast_height: u16 = 3;
3272
3273 let x = area.width.saturating_sub(toast_width + 1);
3275 let y: u16 = 1;
3276
3277 if x + toast_width <= area.width && y + toast_height <= area.height {
3278 let toast_area = Rect::new(x, y, toast_width, toast_height);
3279
3280 let clear = Block::default().style(Style::default().bg(if is_error {
3282 Color::Rgb(60, 20, 20)
3283 } else {
3284 Color::Rgb(20, 50, 30)
3285 }));
3286 f.render_widget(clear, toast_area);
3287
3288 let (icon, border_color, text_color) = if is_error {
3289 ("❌", Color::Rgb(200, 70, 70), Color::Rgb(255, 130, 130))
3290 } else {
3291 ("✅", Color::Rgb(60, 160, 80), Color::Rgb(140, 230, 160))
3292 };
3293
3294 let toast_widget = Paragraph::new(Line::from(vec![
3295 Span::styled(format!(" {} ", icon), Style::default()),
3296 Span::styled(msg.as_str(), Style::default().fg(text_color)),
3297 ]))
3298 .block(
3299 Block::default()
3300 .borders(Borders::ALL)
3301 .border_type(ratatui::widgets::BorderType::Rounded)
3302 .border_style(Style::default().fg(border_color))
3303 .style(Style::default().bg(if is_error {
3304 Color::Rgb(50, 18, 18)
3305 } else {
3306 Color::Rgb(18, 40, 25)
3307 })),
3308 );
3309 f.render_widget(toast_widget, toast_area);
3310 }
3311 }
3312}
3313
3314fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
3316 let items: Vec<ListItem> = app
3317 .agent_config
3318 .providers
3319 .iter()
3320 .enumerate()
3321 .map(|(i, p)| {
3322 let is_active = i == app.agent_config.active_index;
3323 let marker = if is_active { " ● " } else { " ○ " };
3324 let style = if is_active {
3325 Style::default()
3326 .fg(Color::Rgb(120, 220, 160))
3327 .add_modifier(Modifier::BOLD)
3328 } else {
3329 Style::default().fg(Color::Rgb(180, 180, 200))
3330 };
3331 let detail = format!("{}{} ({})", marker, p.name, p.model);
3332 ListItem::new(Line::from(Span::styled(detail, style)))
3333 })
3334 .collect();
3335
3336 let list = List::new(items)
3337 .block(
3338 Block::default()
3339 .borders(Borders::ALL)
3340 .border_type(ratatui::widgets::BorderType::Rounded)
3341 .border_style(Style::default().fg(Color::Rgb(180, 160, 80)))
3342 .title(Span::styled(
3343 " 🔄 选择模型 ",
3344 Style::default()
3345 .fg(Color::Rgb(230, 210, 120))
3346 .add_modifier(Modifier::BOLD),
3347 ))
3348 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
3349 )
3350 .highlight_style(
3351 Style::default()
3352 .bg(Color::Rgb(50, 55, 80))
3353 .fg(Color::White)
3354 .add_modifier(Modifier::BOLD),
3355 )
3356 .highlight_symbol(" ▸ ");
3357
3358 f.render_stateful_widget(list, area, &mut app.model_list_state);
3359}
3360
3361fn draw_help(f: &mut ratatui::Frame, area: Rect) {
3363 let separator = Line::from(Span::styled(
3364 " ─────────────────────────────────────────",
3365 Style::default().fg(Color::Rgb(50, 55, 70)),
3366 ));
3367
3368 let help_lines = vec![
3369 Line::from(""),
3370 Line::from(Span::styled(
3371 " 📖 快捷键帮助",
3372 Style::default()
3373 .fg(Color::Rgb(120, 180, 255))
3374 .add_modifier(Modifier::BOLD),
3375 )),
3376 Line::from(""),
3377 separator.clone(),
3378 Line::from(""),
3379 Line::from(vec![
3380 Span::styled(
3381 " Enter ",
3382 Style::default()
3383 .fg(Color::Rgb(230, 210, 120))
3384 .add_modifier(Modifier::BOLD),
3385 ),
3386 Span::styled("发送消息", Style::default().fg(Color::Rgb(200, 200, 220))),
3387 ]),
3388 Line::from(vec![
3389 Span::styled(
3390 " ↑ / ↓ ",
3391 Style::default()
3392 .fg(Color::Rgb(230, 210, 120))
3393 .add_modifier(Modifier::BOLD),
3394 ),
3395 Span::styled(
3396 "滚动对话记录",
3397 Style::default().fg(Color::Rgb(200, 200, 220)),
3398 ),
3399 ]),
3400 Line::from(vec![
3401 Span::styled(
3402 " ← / → ",
3403 Style::default()
3404 .fg(Color::Rgb(230, 210, 120))
3405 .add_modifier(Modifier::BOLD),
3406 ),
3407 Span::styled(
3408 "移动输入光标",
3409 Style::default().fg(Color::Rgb(200, 200, 220)),
3410 ),
3411 ]),
3412 Line::from(vec![
3413 Span::styled(
3414 " Ctrl+T ",
3415 Style::default()
3416 .fg(Color::Rgb(230, 210, 120))
3417 .add_modifier(Modifier::BOLD),
3418 ),
3419 Span::styled("切换模型", Style::default().fg(Color::Rgb(200, 200, 220))),
3420 ]),
3421 Line::from(vec![
3422 Span::styled(
3423 " Ctrl+L ",
3424 Style::default()
3425 .fg(Color::Rgb(230, 210, 120))
3426 .add_modifier(Modifier::BOLD),
3427 ),
3428 Span::styled(
3429 "清空对话历史",
3430 Style::default().fg(Color::Rgb(200, 200, 220)),
3431 ),
3432 ]),
3433 Line::from(vec![
3434 Span::styled(
3435 " Ctrl+Y ",
3436 Style::default()
3437 .fg(Color::Rgb(230, 210, 120))
3438 .add_modifier(Modifier::BOLD),
3439 ),
3440 Span::styled(
3441 "复制最后一条 AI 回复",
3442 Style::default().fg(Color::Rgb(200, 200, 220)),
3443 ),
3444 ]),
3445 Line::from(vec![
3446 Span::styled(
3447 " Ctrl+B ",
3448 Style::default()
3449 .fg(Color::Rgb(230, 210, 120))
3450 .add_modifier(Modifier::BOLD),
3451 ),
3452 Span::styled(
3453 "浏览消息 (↑↓选择, y/Enter复制)",
3454 Style::default().fg(Color::Rgb(200, 200, 220)),
3455 ),
3456 ]),
3457 Line::from(vec![
3458 Span::styled(
3459 " Ctrl+S ",
3460 Style::default()
3461 .fg(Color::Rgb(230, 210, 120))
3462 .add_modifier(Modifier::BOLD),
3463 ),
3464 Span::styled(
3465 "切换流式/整体输出",
3466 Style::default().fg(Color::Rgb(200, 200, 220)),
3467 ),
3468 ]),
3469 Line::from(vec![
3470 Span::styled(
3471 " Ctrl+E ",
3472 Style::default()
3473 .fg(Color::Rgb(230, 210, 120))
3474 .add_modifier(Modifier::BOLD),
3475 ),
3476 Span::styled(
3477 "打开配置界面",
3478 Style::default().fg(Color::Rgb(200, 200, 220)),
3479 ),
3480 ]),
3481 Line::from(vec![
3482 Span::styled(
3483 " Esc / Ctrl+C ",
3484 Style::default()
3485 .fg(Color::Rgb(230, 210, 120))
3486 .add_modifier(Modifier::BOLD),
3487 ),
3488 Span::styled("退出对话", Style::default().fg(Color::Rgb(200, 200, 220))),
3489 ]),
3490 Line::from(vec![
3491 Span::styled(
3492 " ? / F1 ",
3493 Style::default()
3494 .fg(Color::Rgb(230, 210, 120))
3495 .add_modifier(Modifier::BOLD),
3496 ),
3497 Span::styled(
3498 "显示 / 关闭此帮助",
3499 Style::default().fg(Color::Rgb(200, 200, 220)),
3500 ),
3501 ]),
3502 Line::from(""),
3503 separator,
3504 Line::from(""),
3505 Line::from(Span::styled(
3506 " 📁 配置文件:",
3507 Style::default()
3508 .fg(Color::Rgb(120, 180, 255))
3509 .add_modifier(Modifier::BOLD),
3510 )),
3511 Line::from(Span::styled(
3512 format!(" {}", agent_config_path().display()),
3513 Style::default().fg(Color::Rgb(100, 100, 130)),
3514 )),
3515 ];
3516
3517 let help_block = Block::default()
3518 .borders(Borders::ALL)
3519 .border_type(ratatui::widgets::BorderType::Rounded)
3520 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
3521 .title(Span::styled(
3522 " 帮助 (按任意键返回) ",
3523 Style::default().fg(Color::Rgb(140, 140, 170)),
3524 ))
3525 .style(Style::default().bg(Color::Rgb(24, 24, 34)));
3526 let help_widget = Paragraph::new(help_lines).block(help_block);
3527 f.render_widget(help_widget, area);
3528}
3529
3530fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
3532 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
3534 return true;
3535 }
3536
3537 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') {
3539 if !app.agent_config.providers.is_empty() {
3540 app.mode = ChatMode::SelectModel;
3541 app.model_list_state
3542 .select(Some(app.agent_config.active_index));
3543 }
3544 return false;
3545 }
3546
3547 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
3549 app.clear_session();
3550 return false;
3551 }
3552
3553 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('y') {
3555 if let Some(last_ai) = app
3556 .session
3557 .messages
3558 .iter()
3559 .rev()
3560 .find(|m| m.role == "assistant")
3561 {
3562 if copy_to_clipboard(&last_ai.content) {
3563 app.show_toast("已复制最后一条 AI 回复", false);
3564 } else {
3565 app.show_toast("复制到剪切板失败", true);
3566 }
3567 } else {
3568 app.show_toast("暂无 AI 回复可复制", true);
3569 }
3570 return false;
3571 }
3572
3573 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
3575 if !app.session.messages.is_empty() {
3576 app.browse_msg_index = app.session.messages.len() - 1;
3578 app.mode = ChatMode::Browse;
3579 app.msg_lines_cache = None; } else {
3581 app.show_toast("暂无消息可浏览", true);
3582 }
3583 return false;
3584 }
3585
3586 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
3588 app.config_provider_idx = app
3590 .agent_config
3591 .active_index
3592 .min(app.agent_config.providers.len().saturating_sub(1));
3593 app.config_field_idx = 0;
3594 app.config_editing = false;
3595 app.config_edit_buf.clear();
3596 app.mode = ChatMode::Config;
3597 return false;
3598 }
3599
3600 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
3602 app.agent_config.stream_mode = !app.agent_config.stream_mode;
3603 let _ = save_agent_config(&app.agent_config);
3604 let mode_str = if app.agent_config.stream_mode {
3605 "流式输出"
3606 } else {
3607 "整体输出"
3608 };
3609 app.show_toast(&format!("已切换为: {}", mode_str), false);
3610 return false;
3611 }
3612
3613 let char_count = app.input.chars().count();
3614
3615 match key.code {
3616 KeyCode::Esc => return true,
3617
3618 KeyCode::Enter => {
3619 if !app.is_loading {
3620 app.send_message();
3621 }
3622 }
3623
3624 KeyCode::Up => app.scroll_up(),
3626 KeyCode::Down => app.scroll_down(),
3627 KeyCode::PageUp => {
3628 for _ in 0..10 {
3629 app.scroll_up();
3630 }
3631 }
3632 KeyCode::PageDown => {
3633 for _ in 0..10 {
3634 app.scroll_down();
3635 }
3636 }
3637
3638 KeyCode::Left => {
3640 if app.cursor_pos > 0 {
3641 app.cursor_pos -= 1;
3642 }
3643 }
3644 KeyCode::Right => {
3645 if app.cursor_pos < char_count {
3646 app.cursor_pos += 1;
3647 }
3648 }
3649 KeyCode::Home => app.cursor_pos = 0,
3650 KeyCode::End => app.cursor_pos = char_count,
3651
3652 KeyCode::Backspace => {
3654 if app.cursor_pos > 0 {
3655 let start = app
3656 .input
3657 .char_indices()
3658 .nth(app.cursor_pos - 1)
3659 .map(|(i, _)| i)
3660 .unwrap_or(0);
3661 let end = app
3662 .input
3663 .char_indices()
3664 .nth(app.cursor_pos)
3665 .map(|(i, _)| i)
3666 .unwrap_or(app.input.len());
3667 app.input.drain(start..end);
3668 app.cursor_pos -= 1;
3669 }
3670 }
3671 KeyCode::Delete => {
3672 if app.cursor_pos < char_count {
3673 let start = app
3674 .input
3675 .char_indices()
3676 .nth(app.cursor_pos)
3677 .map(|(i, _)| i)
3678 .unwrap_or(app.input.len());
3679 let end = app
3680 .input
3681 .char_indices()
3682 .nth(app.cursor_pos + 1)
3683 .map(|(i, _)| i)
3684 .unwrap_or(app.input.len());
3685 app.input.drain(start..end);
3686 }
3687 }
3688
3689 KeyCode::F(1) => {
3691 app.mode = ChatMode::Help;
3692 }
3693 KeyCode::Char('?') if app.input.is_empty() => {
3695 app.mode = ChatMode::Help;
3696 }
3697 KeyCode::Char(c) => {
3698 let byte_idx = app
3699 .input
3700 .char_indices()
3701 .nth(app.cursor_pos)
3702 .map(|(i, _)| i)
3703 .unwrap_or(app.input.len());
3704 app.input.insert_str(byte_idx, &c.to_string());
3705 app.cursor_pos += 1;
3706 }
3707
3708 _ => {}
3709 }
3710
3711 false
3712}
3713
3714fn handle_browse_mode(app: &mut ChatApp, key: KeyEvent) {
3716 let msg_count = app.session.messages.len();
3717 if msg_count == 0 {
3718 app.mode = ChatMode::Chat;
3719 app.msg_lines_cache = None;
3720 return;
3721 }
3722
3723 match key.code {
3724 KeyCode::Esc => {
3725 app.mode = ChatMode::Chat;
3726 app.msg_lines_cache = None; }
3728 KeyCode::Up | KeyCode::Char('k') => {
3729 if app.browse_msg_index > 0 {
3730 app.browse_msg_index -= 1;
3731 app.msg_lines_cache = None; }
3733 }
3734 KeyCode::Down | KeyCode::Char('j') => {
3735 if app.browse_msg_index < msg_count - 1 {
3736 app.browse_msg_index += 1;
3737 app.msg_lines_cache = None; }
3739 }
3740 KeyCode::Enter | KeyCode::Char('y') => {
3741 if let Some(msg) = app.session.messages.get(app.browse_msg_index) {
3743 let content = msg.content.clone();
3744 let role_label = if msg.role == "assistant" {
3745 "AI"
3746 } else if msg.role == "user" {
3747 "用户"
3748 } else {
3749 "系统"
3750 };
3751 if copy_to_clipboard(&content) {
3752 app.show_toast(
3753 &format!("已复制第 {} 条{}消息", app.browse_msg_index + 1, role_label),
3754 false,
3755 );
3756 } else {
3757 app.show_toast("复制到剪切板失败", true);
3758 }
3759 }
3760 }
3761 _ => {}
3762 }
3763}
3764
3765fn config_field_label(idx: usize) -> &'static str {
3767 let total_provider = CONFIG_FIELDS.len();
3768 if idx < total_provider {
3769 match CONFIG_FIELDS[idx] {
3770 "name" => "显示名称",
3771 "api_base" => "API Base",
3772 "api_key" => "API Key",
3773 "model" => "模型名称",
3774 _ => CONFIG_FIELDS[idx],
3775 }
3776 } else {
3777 let gi = idx - total_provider;
3778 match CONFIG_GLOBAL_FIELDS[gi] {
3779 "system_prompt" => "系统提示词",
3780 "stream_mode" => "流式输出",
3781 _ => CONFIG_GLOBAL_FIELDS[gi],
3782 }
3783 }
3784}
3785
3786fn config_field_value(app: &ChatApp, field_idx: usize) -> String {
3788 let total_provider = CONFIG_FIELDS.len();
3789 if field_idx < total_provider {
3790 if app.agent_config.providers.is_empty() {
3791 return String::new();
3792 }
3793 let p = &app.agent_config.providers[app.config_provider_idx];
3794 match CONFIG_FIELDS[field_idx] {
3795 "name" => p.name.clone(),
3796 "api_base" => p.api_base.clone(),
3797 "api_key" => {
3798 if p.api_key.len() > 8 {
3800 format!(
3801 "{}****{}",
3802 &p.api_key[..4],
3803 &p.api_key[p.api_key.len() - 4..]
3804 )
3805 } else {
3806 p.api_key.clone()
3807 }
3808 }
3809 "model" => p.model.clone(),
3810 _ => String::new(),
3811 }
3812 } else {
3813 let gi = field_idx - total_provider;
3814 match CONFIG_GLOBAL_FIELDS[gi] {
3815 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
3816 "stream_mode" => {
3817 if app.agent_config.stream_mode {
3818 "开启".into()
3819 } else {
3820 "关闭".into()
3821 }
3822 }
3823 _ => String::new(),
3824 }
3825 }
3826}
3827
3828fn config_field_raw_value(app: &ChatApp, field_idx: usize) -> String {
3830 let total_provider = CONFIG_FIELDS.len();
3831 if field_idx < total_provider {
3832 if app.agent_config.providers.is_empty() {
3833 return String::new();
3834 }
3835 let p = &app.agent_config.providers[app.config_provider_idx];
3836 match CONFIG_FIELDS[field_idx] {
3837 "name" => p.name.clone(),
3838 "api_base" => p.api_base.clone(),
3839 "api_key" => p.api_key.clone(),
3840 "model" => p.model.clone(),
3841 _ => String::new(),
3842 }
3843 } else {
3844 let gi = field_idx - total_provider;
3845 match CONFIG_GLOBAL_FIELDS[gi] {
3846 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
3847 "stream_mode" => {
3848 if app.agent_config.stream_mode {
3849 "true".into()
3850 } else {
3851 "false".into()
3852 }
3853 }
3854 _ => String::new(),
3855 }
3856 }
3857}
3858
3859fn config_field_set(app: &mut ChatApp, field_idx: usize, value: &str) {
3861 let total_provider = CONFIG_FIELDS.len();
3862 if field_idx < total_provider {
3863 if app.agent_config.providers.is_empty() {
3864 return;
3865 }
3866 let p = &mut app.agent_config.providers[app.config_provider_idx];
3867 match CONFIG_FIELDS[field_idx] {
3868 "name" => p.name = value.to_string(),
3869 "api_base" => p.api_base = value.to_string(),
3870 "api_key" => p.api_key = value.to_string(),
3871 "model" => p.model = value.to_string(),
3872 _ => {}
3873 }
3874 } else {
3875 let gi = field_idx - total_provider;
3876 match CONFIG_GLOBAL_FIELDS[gi] {
3877 "system_prompt" => {
3878 if value.is_empty() {
3879 app.agent_config.system_prompt = None;
3880 } else {
3881 app.agent_config.system_prompt = Some(value.to_string());
3882 }
3883 }
3884 "stream_mode" => {
3885 app.agent_config.stream_mode = matches!(
3886 value.trim().to_lowercase().as_str(),
3887 "true" | "1" | "开启" | "on" | "yes"
3888 );
3889 }
3890 _ => {}
3891 }
3892 }
3893}
3894
3895fn handle_config_mode(app: &mut ChatApp, key: KeyEvent) {
3897 let total_fields = config_total_fields();
3898
3899 if app.config_editing {
3900 match key.code {
3902 KeyCode::Esc => {
3903 app.config_editing = false;
3905 }
3906 KeyCode::Enter => {
3907 let val = app.config_edit_buf.clone();
3909 config_field_set(app, app.config_field_idx, &val);
3910 app.config_editing = false;
3911 }
3912 KeyCode::Backspace => {
3913 if app.config_edit_cursor > 0 {
3914 let idx = app
3915 .config_edit_buf
3916 .char_indices()
3917 .nth(app.config_edit_cursor - 1)
3918 .map(|(i, _)| i)
3919 .unwrap_or(0);
3920 let end_idx = app
3921 .config_edit_buf
3922 .char_indices()
3923 .nth(app.config_edit_cursor)
3924 .map(|(i, _)| i)
3925 .unwrap_or(app.config_edit_buf.len());
3926 app.config_edit_buf = format!(
3927 "{}{}",
3928 &app.config_edit_buf[..idx],
3929 &app.config_edit_buf[end_idx..]
3930 );
3931 app.config_edit_cursor -= 1;
3932 }
3933 }
3934 KeyCode::Left => {
3935 app.config_edit_cursor = app.config_edit_cursor.saturating_sub(1);
3936 }
3937 KeyCode::Right => {
3938 let char_count = app.config_edit_buf.chars().count();
3939 if app.config_edit_cursor < char_count {
3940 app.config_edit_cursor += 1;
3941 }
3942 }
3943 KeyCode::Char(c) => {
3944 let byte_idx = app
3945 .config_edit_buf
3946 .char_indices()
3947 .nth(app.config_edit_cursor)
3948 .map(|(i, _)| i)
3949 .unwrap_or(app.config_edit_buf.len());
3950 app.config_edit_buf.insert(byte_idx, c);
3951 app.config_edit_cursor += 1;
3952 }
3953 _ => {}
3954 }
3955 return;
3956 }
3957
3958 match key.code {
3960 KeyCode::Esc => {
3961 let _ = save_agent_config(&app.agent_config);
3963 app.show_toast("配置已保存 ✅", false);
3964 app.mode = ChatMode::Chat;
3965 }
3966 KeyCode::Up | KeyCode::Char('k') => {
3967 if total_fields > 0 {
3968 if app.config_field_idx == 0 {
3969 app.config_field_idx = total_fields - 1;
3970 } else {
3971 app.config_field_idx -= 1;
3972 }
3973 }
3974 }
3975 KeyCode::Down | KeyCode::Char('j') => {
3976 if total_fields > 0 {
3977 app.config_field_idx = (app.config_field_idx + 1) % total_fields;
3978 }
3979 }
3980 KeyCode::Tab | KeyCode::Right => {
3981 let count = app.agent_config.providers.len();
3983 if count > 1 {
3984 app.config_provider_idx = (app.config_provider_idx + 1) % count;
3985 }
3987 }
3988 KeyCode::BackTab | KeyCode::Left => {
3989 let count = app.agent_config.providers.len();
3991 if count > 1 {
3992 if app.config_provider_idx == 0 {
3993 app.config_provider_idx = count - 1;
3994 } else {
3995 app.config_provider_idx -= 1;
3996 }
3997 }
3998 }
3999 KeyCode::Enter => {
4000 let total_provider = CONFIG_FIELDS.len();
4002 if app.config_field_idx < total_provider && app.agent_config.providers.is_empty() {
4003 app.show_toast("还没有 Provider,按 a 新增", true);
4004 return;
4005 }
4006 let gi = app.config_field_idx.checked_sub(total_provider);
4008 if let Some(gi) = gi {
4009 if CONFIG_GLOBAL_FIELDS[gi] == "stream_mode" {
4010 app.agent_config.stream_mode = !app.agent_config.stream_mode;
4011 return;
4012 }
4013 }
4014 app.config_edit_buf = config_field_raw_value(app, app.config_field_idx);
4015 app.config_edit_cursor = app.config_edit_buf.chars().count();
4016 app.config_editing = true;
4017 }
4018 KeyCode::Char('a') => {
4019 let new_provider = ModelProvider {
4021 name: format!("Provider-{}", app.agent_config.providers.len() + 1),
4022 api_base: "https://api.openai.com/v1".to_string(),
4023 api_key: String::new(),
4024 model: String::new(),
4025 };
4026 app.agent_config.providers.push(new_provider);
4027 app.config_provider_idx = app.agent_config.providers.len() - 1;
4028 app.config_field_idx = 0; app.show_toast("已新增 Provider,请填写配置", false);
4030 }
4031 KeyCode::Char('d') => {
4032 let count = app.agent_config.providers.len();
4034 if count == 0 {
4035 app.show_toast("没有可删除的 Provider", true);
4036 } else {
4037 let removed_name = app.agent_config.providers[app.config_provider_idx]
4038 .name
4039 .clone();
4040 app.agent_config.providers.remove(app.config_provider_idx);
4041 if app.config_provider_idx >= app.agent_config.providers.len()
4043 && app.config_provider_idx > 0
4044 {
4045 app.config_provider_idx -= 1;
4046 }
4047 if app.agent_config.active_index >= app.agent_config.providers.len()
4049 && app.agent_config.active_index > 0
4050 {
4051 app.agent_config.active_index -= 1;
4052 }
4053 app.show_toast(format!("已删除 Provider: {}", removed_name), false);
4054 }
4055 }
4056 KeyCode::Char('s') => {
4057 if !app.agent_config.providers.is_empty() {
4059 app.agent_config.active_index = app.config_provider_idx;
4060 let name = app.agent_config.providers[app.config_provider_idx]
4061 .name
4062 .clone();
4063 app.show_toast(format!("已设为活跃模型: {}", name), false);
4064 }
4065 }
4066 _ => {}
4067 }
4068}
4069
4070fn draw_config_screen(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
4072 let bg = Color::Rgb(28, 28, 40);
4073 let total_provider_fields = CONFIG_FIELDS.len();
4074
4075 let mut lines: Vec<Line> = Vec::new();
4076 lines.push(Line::from(""));
4077
4078 lines.push(Line::from(vec![Span::styled(
4080 " ⚙️ 模型配置",
4081 Style::default()
4082 .fg(Color::Rgb(120, 180, 255))
4083 .add_modifier(Modifier::BOLD),
4084 )]));
4085 lines.push(Line::from(""));
4086
4087 let provider_count = app.agent_config.providers.len();
4089 if provider_count > 0 {
4090 let mut tab_spans: Vec<Span> = vec![Span::styled(" ", Style::default())];
4091 for (i, p) in app.agent_config.providers.iter().enumerate() {
4092 let is_current = i == app.config_provider_idx;
4093 let is_active = i == app.agent_config.active_index;
4094 let marker = if is_active { "● " } else { "○ " };
4095 let label = format!(" {}{} ", marker, p.name);
4096 if is_current {
4097 tab_spans.push(Span::styled(
4098 label,
4099 Style::default()
4100 .fg(Color::Rgb(22, 22, 30))
4101 .bg(Color::Rgb(120, 180, 255))
4102 .add_modifier(Modifier::BOLD),
4103 ));
4104 } else {
4105 tab_spans.push(Span::styled(
4106 label,
4107 Style::default().fg(Color::Rgb(150, 150, 170)),
4108 ));
4109 }
4110 if i < provider_count - 1 {
4111 tab_spans.push(Span::styled(
4112 " │ ",
4113 Style::default().fg(Color::Rgb(50, 55, 70)),
4114 ));
4115 }
4116 }
4117 tab_spans.push(Span::styled(
4118 " (● = 活跃模型, Tab 切换, s 设为活跃)",
4119 Style::default().fg(Color::Rgb(80, 80, 100)),
4120 ));
4121 lines.push(Line::from(tab_spans));
4122 } else {
4123 lines.push(Line::from(Span::styled(
4124 " (无 Provider,按 a 新增)",
4125 Style::default().fg(Color::Rgb(180, 120, 80)),
4126 )));
4127 }
4128 lines.push(Line::from(""));
4129
4130 lines.push(Line::from(Span::styled(
4132 " ─────────────────────────────────────────",
4133 Style::default().fg(Color::Rgb(50, 55, 70)),
4134 )));
4135 lines.push(Line::from(""));
4136
4137 if provider_count > 0 {
4139 lines.push(Line::from(Span::styled(
4140 " 📦 Provider 配置",
4141 Style::default()
4142 .fg(Color::Rgb(160, 220, 160))
4143 .add_modifier(Modifier::BOLD),
4144 )));
4145 lines.push(Line::from(""));
4146
4147 for i in 0..total_provider_fields {
4148 let is_selected = app.config_field_idx == i;
4149 let label = config_field_label(i);
4150 let value = if app.config_editing && is_selected {
4151 app.config_edit_buf.clone()
4153 } else {
4154 config_field_value(app, i)
4155 };
4156
4157 let pointer = if is_selected { " ▸ " } else { " " };
4158 let pointer_style = if is_selected {
4159 Style::default().fg(Color::Rgb(255, 200, 80))
4160 } else {
4161 Style::default()
4162 };
4163
4164 let label_style = if is_selected {
4165 Style::default()
4166 .fg(Color::Rgb(230, 210, 120))
4167 .add_modifier(Modifier::BOLD)
4168 } else {
4169 Style::default().fg(Color::Rgb(140, 140, 160))
4170 };
4171
4172 let value_style = if app.config_editing && is_selected {
4173 Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
4174 } else if is_selected {
4175 Style::default().fg(Color::White)
4176 } else {
4177 if CONFIG_FIELDS[i] == "api_key" {
4179 Style::default().fg(Color::Rgb(100, 100, 120))
4180 } else {
4181 Style::default().fg(Color::Rgb(180, 180, 200))
4182 }
4183 };
4184
4185 let edit_indicator = if app.config_editing && is_selected {
4186 " ✏️"
4187 } else {
4188 ""
4189 };
4190
4191 lines.push(Line::from(vec![
4192 Span::styled(pointer, pointer_style),
4193 Span::styled(format!("{:<10}", label), label_style),
4194 Span::styled(" ", Style::default()),
4195 Span::styled(
4196 if value.is_empty() {
4197 "(空)".to_string()
4198 } else {
4199 value
4200 },
4201 value_style,
4202 ),
4203 Span::styled(edit_indicator, Style::default()),
4204 ]));
4205 }
4206 }
4207
4208 lines.push(Line::from(""));
4209 lines.push(Line::from(Span::styled(
4211 " ─────────────────────────────────────────",
4212 Style::default().fg(Color::Rgb(50, 55, 70)),
4213 )));
4214 lines.push(Line::from(""));
4215
4216 lines.push(Line::from(Span::styled(
4218 " 🌐 全局配置",
4219 Style::default()
4220 .fg(Color::Rgb(160, 220, 160))
4221 .add_modifier(Modifier::BOLD),
4222 )));
4223 lines.push(Line::from(""));
4224
4225 for i in 0..CONFIG_GLOBAL_FIELDS.len() {
4226 let field_idx = total_provider_fields + i;
4227 let is_selected = app.config_field_idx == field_idx;
4228 let label = config_field_label(field_idx);
4229 let value = if app.config_editing && is_selected {
4230 app.config_edit_buf.clone()
4231 } else {
4232 config_field_value(app, field_idx)
4233 };
4234
4235 let pointer = if is_selected { " ▸ " } else { " " };
4236 let pointer_style = if is_selected {
4237 Style::default().fg(Color::Rgb(255, 200, 80))
4238 } else {
4239 Style::default()
4240 };
4241
4242 let label_style = if is_selected {
4243 Style::default()
4244 .fg(Color::Rgb(230, 210, 120))
4245 .add_modifier(Modifier::BOLD)
4246 } else {
4247 Style::default().fg(Color::Rgb(140, 140, 160))
4248 };
4249
4250 let value_style = if app.config_editing && is_selected {
4251 Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
4252 } else if is_selected {
4253 Style::default().fg(Color::White)
4254 } else {
4255 Style::default().fg(Color::Rgb(180, 180, 200))
4256 };
4257
4258 let edit_indicator = if app.config_editing && is_selected {
4259 " ✏️"
4260 } else {
4261 ""
4262 };
4263
4264 if CONFIG_GLOBAL_FIELDS[i] == "stream_mode" {
4266 let toggle_on = app.agent_config.stream_mode;
4267 let toggle_style = if toggle_on {
4268 Style::default()
4269 .fg(Color::Rgb(120, 220, 160))
4270 .add_modifier(Modifier::BOLD)
4271 } else {
4272 Style::default().fg(Color::Rgb(200, 100, 100))
4273 };
4274 let toggle_text = if toggle_on {
4275 "● 开启"
4276 } else {
4277 "○ 关闭"
4278 };
4279
4280 lines.push(Line::from(vec![
4281 Span::styled(pointer, pointer_style),
4282 Span::styled(format!("{:<10}", label), label_style),
4283 Span::styled(" ", Style::default()),
4284 Span::styled(toggle_text, toggle_style),
4285 Span::styled(
4286 if is_selected { " (Enter 切换)" } else { "" },
4287 Style::default().fg(Color::Rgb(80, 80, 100)),
4288 ),
4289 ]));
4290 } else {
4291 lines.push(Line::from(vec![
4292 Span::styled(pointer, pointer_style),
4293 Span::styled(format!("{:<10}", label), label_style),
4294 Span::styled(" ", Style::default()),
4295 Span::styled(
4296 if value.is_empty() {
4297 "(空)".to_string()
4298 } else {
4299 value
4300 },
4301 value_style,
4302 ),
4303 Span::styled(edit_indicator, Style::default()),
4304 ]));
4305 }
4306 }
4307
4308 lines.push(Line::from(""));
4309 lines.push(Line::from(""));
4310
4311 lines.push(Line::from(Span::styled(
4313 " ─────────────────────────────────────────",
4314 Style::default().fg(Color::Rgb(50, 55, 70)),
4315 )));
4316 lines.push(Line::from(""));
4317 lines.push(Line::from(vec![
4318 Span::styled(" ", Style::default()),
4319 Span::styled(
4320 "↑↓/jk",
4321 Style::default()
4322 .fg(Color::Rgb(230, 210, 120))
4323 .add_modifier(Modifier::BOLD),
4324 ),
4325 Span::styled(
4326 " 切换字段 ",
4327 Style::default().fg(Color::Rgb(120, 120, 150)),
4328 ),
4329 Span::styled(
4330 "Enter",
4331 Style::default()
4332 .fg(Color::Rgb(230, 210, 120))
4333 .add_modifier(Modifier::BOLD),
4334 ),
4335 Span::styled(" 编辑 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4336 Span::styled(
4337 "Tab/←→",
4338 Style::default()
4339 .fg(Color::Rgb(230, 210, 120))
4340 .add_modifier(Modifier::BOLD),
4341 ),
4342 Span::styled(
4343 " 切换 Provider ",
4344 Style::default().fg(Color::Rgb(120, 120, 150)),
4345 ),
4346 Span::styled(
4347 "a",
4348 Style::default()
4349 .fg(Color::Rgb(230, 210, 120))
4350 .add_modifier(Modifier::BOLD),
4351 ),
4352 Span::styled(" 新增 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4353 Span::styled(
4354 "d",
4355 Style::default()
4356 .fg(Color::Rgb(230, 210, 120))
4357 .add_modifier(Modifier::BOLD),
4358 ),
4359 Span::styled(" 删除 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4360 Span::styled(
4361 "s",
4362 Style::default()
4363 .fg(Color::Rgb(230, 210, 120))
4364 .add_modifier(Modifier::BOLD),
4365 ),
4366 Span::styled(
4367 " 设为活跃 ",
4368 Style::default().fg(Color::Rgb(120, 120, 150)),
4369 ),
4370 Span::styled(
4371 "Esc",
4372 Style::default()
4373 .fg(Color::Rgb(230, 210, 120))
4374 .add_modifier(Modifier::BOLD),
4375 ),
4376 Span::styled(" 保存返回", Style::default().fg(Color::Rgb(120, 120, 150))),
4377 ]));
4378
4379 let content = Paragraph::new(lines)
4380 .block(
4381 Block::default()
4382 .borders(Borders::ALL)
4383 .border_type(ratatui::widgets::BorderType::Rounded)
4384 .border_style(Style::default().fg(Color::Rgb(80, 80, 110)))
4385 .title(Span::styled(
4386 " ⚙️ 模型配置编辑 ",
4387 Style::default()
4388 .fg(Color::Rgb(230, 210, 120))
4389 .add_modifier(Modifier::BOLD),
4390 ))
4391 .style(Style::default().bg(bg)),
4392 )
4393 .scroll((0, 0));
4394 f.render_widget(content, area);
4395}
4396
4397fn handle_select_model(app: &mut ChatApp, key: KeyEvent) {
4399 let count = app.agent_config.providers.len();
4400 match key.code {
4401 KeyCode::Esc => {
4402 app.mode = ChatMode::Chat;
4403 }
4404 KeyCode::Up | KeyCode::Char('k') => {
4405 if count > 0 {
4406 let i = app
4407 .model_list_state
4408 .selected()
4409 .map(|i| if i == 0 { count - 1 } else { i - 1 })
4410 .unwrap_or(0);
4411 app.model_list_state.select(Some(i));
4412 }
4413 }
4414 KeyCode::Down | KeyCode::Char('j') => {
4415 if count > 0 {
4416 let i = app
4417 .model_list_state
4418 .selected()
4419 .map(|i| if i >= count - 1 { 0 } else { i + 1 })
4420 .unwrap_or(0);
4421 app.model_list_state.select(Some(i));
4422 }
4423 }
4424 KeyCode::Enter => {
4425 app.switch_model();
4426 }
4427 _ => {}
4428 }
4429}
4430
4431fn copy_to_clipboard(content: &str) -> bool {
4433 use std::process::{Command, Stdio};
4434
4435 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
4436 ("pbcopy", vec![])
4437 } else if cfg!(target_os = "linux") {
4438 if Command::new("which")
4439 .arg("xclip")
4440 .output()
4441 .map(|o| o.status.success())
4442 .unwrap_or(false)
4443 {
4444 ("xclip", vec!["-selection", "clipboard"])
4445 } else {
4446 ("xsel", vec!["--clipboard", "--input"])
4447 }
4448 } else {
4449 return false;
4450 };
4451
4452 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
4453
4454 match child {
4455 Ok(mut child) => {
4456 if let Some(ref mut stdin) = child.stdin {
4457 let _ = stdin.write_all(content.as_bytes());
4458 }
4459 child.wait().map(|s| s.success()).unwrap_or(false)
4460 }
4461 Err(_) => false,
4462 }
4463}