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