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(
1770 current
1771 .add_modifier(Modifier::BOLD)
1772 .fg(Color::Rgb(130, 220, 255)),
1773 );
1774 }
1775 Event::End(TagEnd::Strong) => {
1776 style_stack.pop();
1777 }
1778 Event::Start(Tag::Emphasis) => {
1779 let current = *style_stack.last().unwrap_or(&base_style);
1780 style_stack.push(current.add_modifier(Modifier::ITALIC));
1781 }
1782 Event::End(TagEnd::Emphasis) => {
1783 style_stack.pop();
1784 }
1785 Event::Start(Tag::Strikethrough) => {
1786 let current = *style_stack.last().unwrap_or(&base_style);
1787 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
1788 }
1789 Event::End(TagEnd::Strikethrough) => {
1790 style_stack.pop();
1791 }
1792 Event::Start(Tag::CodeBlock(kind)) => {
1793 flush_line(&mut current_spans, &mut lines);
1794 in_code_block = true;
1795 code_block_content.clear();
1796 code_block_lang = match kind {
1797 CodeBlockKind::Fenced(lang) => lang.to_string(),
1798 CodeBlockKind::Indented => String::new(),
1799 };
1800 let label = if code_block_lang.is_empty() {
1802 " code ".to_string()
1803 } else {
1804 format!(" {} ", code_block_lang)
1805 };
1806 let label_w = display_width(&label);
1807 let border_fill = content_width.saturating_sub(2 + label_w);
1808 let top_border = format!("┌─{}{}", label, "─".repeat(border_fill));
1809 lines.push(Line::from(Span::styled(
1810 top_border,
1811 Style::default().fg(Color::Rgb(80, 90, 110)),
1812 )));
1813 }
1814 Event::End(TagEnd::CodeBlock) => {
1815 let code_inner_w = content_width.saturating_sub(4); for code_line in code_block_content.lines() {
1818 let wrapped = wrap_text(code_line, code_inner_w);
1819 for wl in wrapped {
1820 let highlighted = highlight_code_line(&wl, &code_block_lang);
1821 let text_w: usize =
1822 highlighted.iter().map(|s| display_width(&s.content)).sum();
1823 let fill = code_inner_w.saturating_sub(text_w);
1824 let mut spans_vec = Vec::new();
1825 spans_vec.push(Span::styled(
1826 "│ ",
1827 Style::default().fg(Color::Rgb(80, 90, 110)),
1828 ));
1829 for hs in highlighted {
1830 spans_vec.push(Span::styled(
1831 hs.content.to_string(),
1832 hs.style.bg(Color::Rgb(30, 30, 42)),
1833 ));
1834 }
1835 spans_vec.push(Span::styled(
1836 format!("{} │", " ".repeat(fill)),
1837 Style::default()
1838 .fg(Color::Rgb(80, 90, 110))
1839 .bg(Color::Rgb(30, 30, 42)),
1840 ));
1841 lines.push(Line::from(spans_vec));
1842 }
1843 }
1844 let bottom_border = format!("└{}", "─".repeat(content_width.saturating_sub(1)));
1845 lines.push(Line::from(Span::styled(
1846 bottom_border,
1847 Style::default().fg(Color::Rgb(80, 90, 110)),
1848 )));
1849 in_code_block = false;
1850 code_block_content.clear();
1851 code_block_lang.clear();
1852 }
1853 Event::Code(text) => {
1854 if in_table {
1855 current_cell.push('`');
1857 current_cell.push_str(&text);
1858 current_cell.push('`');
1859 } else {
1860 let code_str = format!(" {} ", text);
1862 let code_w = display_width(&code_str);
1863 let effective_prefix_w = if in_blockquote { 2 } else { 0 };
1864 let full_line_w = content_width.saturating_sub(effective_prefix_w);
1865 let existing_w: usize = current_spans
1866 .iter()
1867 .map(|s| display_width(&s.content))
1868 .sum();
1869 if existing_w + code_w > full_line_w && !current_spans.is_empty() {
1870 flush_line(&mut current_spans, &mut lines);
1871 if in_blockquote {
1872 current_spans.push(Span::styled(
1873 "| ".to_string(),
1874 Style::default().fg(Color::Rgb(80, 100, 140)),
1875 ));
1876 }
1877 }
1878 current_spans.push(Span::styled(
1879 code_str,
1880 Style::default()
1881 .fg(Color::Rgb(230, 190, 120))
1882 .bg(Color::Rgb(45, 45, 60)),
1883 ));
1884 }
1885 }
1886 Event::Start(Tag::List(start)) => {
1887 flush_line(&mut current_spans, &mut lines);
1888 list_depth += 1;
1889 ordered_index = start;
1890 }
1891 Event::End(TagEnd::List(_)) => {
1892 flush_line(&mut current_spans, &mut lines);
1893 list_depth = list_depth.saturating_sub(1);
1894 ordered_index = None;
1895 }
1896 Event::Start(Tag::Item) => {
1897 flush_line(&mut current_spans, &mut lines);
1898 let indent = " ".repeat(list_depth);
1899 let bullet = if let Some(ref mut idx) = ordered_index {
1900 let s = format!("{}{}. ", indent, idx);
1901 *idx += 1;
1902 s
1903 } else {
1904 format!("{}• ", indent)
1905 };
1906 current_spans.push(Span::styled(
1907 bullet,
1908 Style::default().fg(Color::Rgb(100, 160, 255)),
1909 ));
1910 }
1911 Event::End(TagEnd::Item) => {
1912 flush_line(&mut current_spans, &mut lines);
1913 }
1914 Event::Start(Tag::Paragraph) => {
1915 if !lines.is_empty() && !in_code_block && heading_level.is_none() {
1916 let last_empty = lines.last().map(|l| l.spans.is_empty()).unwrap_or(false);
1917 if !last_empty {
1918 lines.push(Line::from(""));
1919 }
1920 }
1921 }
1922 Event::End(TagEnd::Paragraph) => {
1923 flush_line(&mut current_spans, &mut lines);
1924 }
1925 Event::Start(Tag::BlockQuote(_)) => {
1926 flush_line(&mut current_spans, &mut lines);
1927 in_blockquote = true;
1928 style_stack.push(Style::default().fg(Color::Rgb(150, 160, 180)));
1929 }
1930 Event::End(TagEnd::BlockQuote(_)) => {
1931 flush_line(&mut current_spans, &mut lines);
1932 in_blockquote = false;
1933 style_stack.pop();
1934 }
1935 Event::Text(text) => {
1936 if in_code_block {
1937 code_block_content.push_str(&text);
1938 } else if in_table {
1939 current_cell.push_str(&text);
1941 } else {
1942 let style = *style_stack.last().unwrap_or(&base_style);
1943 let text_str = text.to_string();
1944
1945 if let Some(level) = heading_level {
1947 let (prefix, prefix_style) = match level {
1948 1 => (
1949 ">> ",
1950 Style::default()
1951 .fg(Color::Rgb(100, 180, 255))
1952 .add_modifier(Modifier::BOLD),
1953 ),
1954 2 => (
1955 ">> ",
1956 Style::default()
1957 .fg(Color::Rgb(130, 190, 255))
1958 .add_modifier(Modifier::BOLD),
1959 ),
1960 3 => (
1961 "> ",
1962 Style::default()
1963 .fg(Color::Rgb(160, 200, 255))
1964 .add_modifier(Modifier::BOLD),
1965 ),
1966 _ => (
1967 "> ",
1968 Style::default()
1969 .fg(Color::Rgb(180, 210, 255))
1970 .add_modifier(Modifier::BOLD),
1971 ),
1972 };
1973 current_spans.push(Span::styled(prefix.to_string(), prefix_style));
1974 heading_level = None; }
1976
1977 let effective_prefix_w = if in_blockquote { 2 } else { 0 }; let full_line_w = content_width.saturating_sub(effective_prefix_w);
1980
1981 let existing_w: usize = current_spans
1983 .iter()
1984 .map(|s| display_width(&s.content))
1985 .sum();
1986
1987 let wrap_w = full_line_w.saturating_sub(existing_w);
1989
1990 let min_useful_w = full_line_w / 4;
1993 let wrap_w = if wrap_w < min_useful_w.max(4) && !current_spans.is_empty() {
1994 flush_line(&mut current_spans, &mut lines);
1995 if in_blockquote {
1996 current_spans.push(Span::styled(
1997 "| ".to_string(),
1998 Style::default().fg(Color::Rgb(80, 100, 140)),
1999 ));
2000 }
2001 full_line_w
2003 } else {
2004 wrap_w
2005 };
2006
2007 for (i, line) in text_str.split('\n').enumerate() {
2008 if i > 0 {
2009 flush_line(&mut current_spans, &mut lines);
2010 if in_blockquote {
2011 current_spans.push(Span::styled(
2012 "| ".to_string(),
2013 Style::default().fg(Color::Rgb(80, 100, 140)),
2014 ));
2015 }
2016 }
2017 if !line.is_empty() {
2018 let effective_wrap = if i == 0 {
2020 wrap_w
2021 } else {
2022 content_width.saturating_sub(effective_prefix_w)
2023 };
2024 let wrapped = wrap_text(line, effective_wrap);
2025 for (j, wl) in wrapped.iter().enumerate() {
2026 if j > 0 {
2027 flush_line(&mut current_spans, &mut lines);
2028 if in_blockquote {
2029 current_spans.push(Span::styled(
2030 "| ".to_string(),
2031 Style::default().fg(Color::Rgb(80, 100, 140)),
2032 ));
2033 }
2034 }
2035 current_spans.push(Span::styled(wl.clone(), style));
2036 }
2037 }
2038 }
2039 }
2040 }
2041 Event::SoftBreak => {
2042 if in_table {
2043 current_cell.push(' ');
2044 } else {
2045 current_spans.push(Span::raw(" "));
2046 }
2047 }
2048 Event::HardBreak => {
2049 if in_table {
2050 current_cell.push(' ');
2051 } else {
2052 flush_line(&mut current_spans, &mut lines);
2053 }
2054 }
2055 Event::Rule => {
2056 flush_line(&mut current_spans, &mut lines);
2057 lines.push(Line::from(Span::styled(
2058 "─".repeat(content_width),
2059 Style::default().fg(Color::Rgb(70, 75, 90)),
2060 )));
2061 }
2062 Event::Start(Tag::Table(alignments)) => {
2064 flush_line(&mut current_spans, &mut lines);
2065 in_table = true;
2066 table_rows.clear();
2067 table_alignments = alignments;
2068 }
2069 Event::End(TagEnd::Table) => {
2070 flush_line(&mut current_spans, &mut lines);
2072 in_table = false;
2073
2074 if !table_rows.is_empty() {
2075 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
2076 if num_cols > 0 {
2077 let mut col_widths: Vec<usize> = vec![0; num_cols];
2079 for row in &table_rows {
2080 for (i, cell) in row.iter().enumerate() {
2081 let w = display_width(cell);
2082 if w > col_widths[i] {
2083 col_widths[i] = w;
2084 }
2085 }
2086 }
2087
2088 let sep_w = num_cols + 1; let pad_w = num_cols * 2; let avail = content_width.saturating_sub(sep_w + pad_w);
2092 let max_col_w = avail * 2 / 3;
2094 for cw in col_widths.iter_mut() {
2095 if *cw > max_col_w {
2096 *cw = max_col_w;
2097 }
2098 }
2099 let total_col_w: usize = col_widths.iter().sum();
2100 if total_col_w > avail && total_col_w > 0 {
2101 let mut remaining = avail;
2103 for (i, cw) in col_widths.iter_mut().enumerate() {
2104 if i == num_cols - 1 {
2105 *cw = remaining.max(1);
2107 } else {
2108 *cw = ((*cw) * avail / total_col_w).max(1);
2109 remaining = remaining.saturating_sub(*cw);
2110 }
2111 }
2112 }
2113
2114 let table_style = Style::default().fg(Color::Rgb(180, 180, 200));
2115 let header_style = Style::default()
2116 .fg(Color::Rgb(120, 180, 255))
2117 .add_modifier(Modifier::BOLD);
2118 let border_style = Style::default().fg(Color::Rgb(60, 70, 100));
2119
2120 let total_col_w_final: usize = col_widths.iter().sum();
2123 let table_row_w = sep_w + pad_w + total_col_w_final;
2124 let table_right_pad = content_width.saturating_sub(table_row_w);
2126
2127 let mut top = String::from("┌");
2129 for (i, cw) in col_widths.iter().enumerate() {
2130 top.push_str(&"─".repeat(cw + 2));
2131 if i < num_cols - 1 {
2132 top.push('┬');
2133 }
2134 }
2135 top.push('┐');
2136 let mut top_spans = vec![Span::styled(top, border_style)];
2138 if table_right_pad > 0 {
2139 top_spans.push(Span::raw(" ".repeat(table_right_pad)));
2140 }
2141 lines.push(Line::from(top_spans));
2142
2143 for (row_idx, row) in table_rows.iter().enumerate() {
2144 let mut row_spans: Vec<Span> = Vec::new();
2146 row_spans.push(Span::styled("│", border_style));
2147 for (i, cw) in col_widths.iter().enumerate() {
2148 let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
2149 let cell_w = display_width(cell_text);
2150 let text = if cell_w > *cw {
2151 let mut t = String::new();
2153 let mut w = 0;
2154 for ch in cell_text.chars() {
2155 let chw = char_width(ch);
2156 if w + chw > *cw {
2157 break;
2158 }
2159 t.push(ch);
2160 w += chw;
2161 }
2162 let fill = cw.saturating_sub(w);
2163 format!(" {}{} ", t, " ".repeat(fill))
2164 } else {
2165 let fill = cw.saturating_sub(cell_w);
2167 let align = table_alignments
2168 .get(i)
2169 .copied()
2170 .unwrap_or(pulldown_cmark::Alignment::None);
2171 match align {
2172 pulldown_cmark::Alignment::Center => {
2173 let left = fill / 2;
2174 let right = fill - left;
2175 format!(
2176 " {}{}{} ",
2177 " ".repeat(left),
2178 cell_text,
2179 " ".repeat(right)
2180 )
2181 }
2182 pulldown_cmark::Alignment::Right => {
2183 format!(" {}{} ", " ".repeat(fill), cell_text)
2184 }
2185 _ => {
2186 format!(" {}{} ", cell_text, " ".repeat(fill))
2187 }
2188 }
2189 };
2190 let style = if row_idx == 0 {
2191 header_style
2192 } else {
2193 table_style
2194 };
2195 row_spans.push(Span::styled(text, style));
2196 row_spans.push(Span::styled("│", border_style));
2197 }
2198 if table_right_pad > 0 {
2200 row_spans.push(Span::raw(" ".repeat(table_right_pad)));
2201 }
2202 lines.push(Line::from(row_spans));
2203
2204 if row_idx == 0 {
2206 let mut sep = String::from("├");
2207 for (i, cw) in col_widths.iter().enumerate() {
2208 sep.push_str(&"─".repeat(cw + 2));
2209 if i < num_cols - 1 {
2210 sep.push('┼');
2211 }
2212 }
2213 sep.push('┤');
2214 let mut sep_spans = vec![Span::styled(sep, border_style)];
2215 if table_right_pad > 0 {
2216 sep_spans.push(Span::raw(" ".repeat(table_right_pad)));
2217 }
2218 lines.push(Line::from(sep_spans));
2219 }
2220 }
2221
2222 let mut bottom = String::from("└");
2224 for (i, cw) in col_widths.iter().enumerate() {
2225 bottom.push_str(&"─".repeat(cw + 2));
2226 if i < num_cols - 1 {
2227 bottom.push('┴');
2228 }
2229 }
2230 bottom.push('┘');
2231 let mut bottom_spans = vec![Span::styled(bottom, border_style)];
2232 if table_right_pad > 0 {
2233 bottom_spans.push(Span::raw(" ".repeat(table_right_pad)));
2234 }
2235 lines.push(Line::from(bottom_spans));
2236 }
2237 }
2238 table_rows.clear();
2239 table_alignments.clear();
2240 }
2241 Event::Start(Tag::TableHead) => {
2242 current_row.clear();
2243 }
2244 Event::End(TagEnd::TableHead) => {
2245 table_rows.push(current_row.clone());
2246 current_row.clear();
2247 }
2248 Event::Start(Tag::TableRow) => {
2249 current_row.clear();
2250 }
2251 Event::End(TagEnd::TableRow) => {
2252 table_rows.push(current_row.clone());
2253 current_row.clear();
2254 }
2255 Event::Start(Tag::TableCell) => {
2256 current_cell.clear();
2257 }
2258 Event::End(TagEnd::TableCell) => {
2259 current_row.push(current_cell.clone());
2260 current_cell.clear();
2261 }
2262 _ => {}
2263 }
2264 }
2265
2266 if !current_spans.is_empty() {
2268 lines.push(Line::from(current_spans));
2269 }
2270
2271 if lines.is_empty() {
2273 let wrapped = wrap_text(md, content_width);
2274 for wl in wrapped {
2275 lines.push(Line::from(Span::styled(wl, base_style)));
2276 }
2277 }
2278
2279 lines
2280}
2281
2282fn highlight_code_line<'a>(line: &'a str, lang: &str) -> Vec<Span<'static>> {
2285 let lang_lower = lang.to_lowercase();
2286 let keywords: &[&str] = match lang_lower.as_str() {
2287 "rust" | "rs" => &[
2288 "fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for",
2289 "while", "loop", "if", "else", "match", "return", "self", "Self", "where", "async",
2290 "await", "move", "ref", "type", "const", "static", "crate", "super", "as", "in",
2291 "true", "false", "Some", "None", "Ok", "Err",
2292 ],
2293 "python" | "py" => &[
2294 "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as",
2295 "with", "try", "except", "finally", "raise", "pass", "break", "continue", "yield",
2296 "lambda", "and", "or", "not", "in", "is", "True", "False", "None", "global",
2297 "nonlocal", "assert", "del", "async", "await", "self", "print",
2298 ],
2299 "javascript" | "js" | "typescript" | "ts" | "jsx" | "tsx" => &[
2300 "function",
2301 "const",
2302 "let",
2303 "var",
2304 "return",
2305 "if",
2306 "else",
2307 "for",
2308 "while",
2309 "class",
2310 "new",
2311 "this",
2312 "import",
2313 "export",
2314 "from",
2315 "default",
2316 "async",
2317 "await",
2318 "try",
2319 "catch",
2320 "finally",
2321 "throw",
2322 "typeof",
2323 "instanceof",
2324 "true",
2325 "false",
2326 "null",
2327 "undefined",
2328 "of",
2329 "in",
2330 "switch",
2331 "case",
2332 ],
2333 "go" | "golang" => &[
2334 "func",
2335 "package",
2336 "import",
2337 "return",
2338 "if",
2339 "else",
2340 "for",
2341 "range",
2342 "struct",
2343 "interface",
2344 "type",
2345 "var",
2346 "const",
2347 "defer",
2348 "go",
2349 "chan",
2350 "select",
2351 "case",
2352 "switch",
2353 "default",
2354 "break",
2355 "continue",
2356 "map",
2357 "true",
2358 "false",
2359 "nil",
2360 "make",
2361 "append",
2362 "len",
2363 "cap",
2364 ],
2365 "java" | "kotlin" | "kt" => &[
2366 "public",
2367 "private",
2368 "protected",
2369 "class",
2370 "interface",
2371 "extends",
2372 "implements",
2373 "return",
2374 "if",
2375 "else",
2376 "for",
2377 "while",
2378 "new",
2379 "this",
2380 "import",
2381 "package",
2382 "static",
2383 "final",
2384 "void",
2385 "int",
2386 "String",
2387 "boolean",
2388 "true",
2389 "false",
2390 "null",
2391 "try",
2392 "catch",
2393 "throw",
2394 "throws",
2395 "fun",
2396 "val",
2397 "var",
2398 "when",
2399 "object",
2400 "companion",
2401 ],
2402 "sh" | "bash" | "zsh" | "shell" => &[
2403 "if",
2404 "then",
2405 "else",
2406 "elif",
2407 "fi",
2408 "for",
2409 "while",
2410 "do",
2411 "done",
2412 "case",
2413 "esac",
2414 "function",
2415 "return",
2416 "exit",
2417 "echo",
2418 "export",
2419 "local",
2420 "readonly",
2421 "set",
2422 "unset",
2423 "shift",
2424 "source",
2425 "in",
2426 "true",
2427 "false",
2428 "read",
2429 "declare",
2430 "typeset",
2431 "trap",
2432 "eval",
2433 "exec",
2434 "test",
2435 "select",
2436 "until",
2437 "break",
2438 "continue",
2439 "printf",
2440 "go",
2442 "build",
2443 "run",
2444 "test",
2445 "fmt",
2446 "vet",
2447 "mod",
2448 "get",
2449 "install",
2450 "clean",
2451 "doc",
2452 "list",
2453 "version",
2454 "env",
2455 "generate",
2456 "tool",
2457 "proxy",
2458 "GOPATH",
2459 "GOROOT",
2460 "GOBIN",
2461 "GOMODCACHE",
2462 "GOPROXY",
2463 "GOSUMDB",
2464 "cargo",
2466 "new",
2467 "init",
2468 "add",
2469 "remove",
2470 "update",
2471 "check",
2472 "clippy",
2473 "rustfmt",
2474 "rustc",
2475 "rustup",
2476 "publish",
2477 "install",
2478 "uninstall",
2479 "search",
2480 "tree",
2481 "locate_project",
2482 "metadata",
2483 "audit",
2484 "watch",
2485 "expand",
2486 ],
2487 "c" | "cpp" | "c++" | "h" | "hpp" => &[
2488 "int",
2489 "char",
2490 "float",
2491 "double",
2492 "void",
2493 "long",
2494 "short",
2495 "unsigned",
2496 "signed",
2497 "const",
2498 "static",
2499 "extern",
2500 "struct",
2501 "union",
2502 "enum",
2503 "typedef",
2504 "sizeof",
2505 "return",
2506 "if",
2507 "else",
2508 "for",
2509 "while",
2510 "do",
2511 "switch",
2512 "case",
2513 "break",
2514 "continue",
2515 "default",
2516 "goto",
2517 "auto",
2518 "register",
2519 "volatile",
2520 "class",
2521 "public",
2522 "private",
2523 "protected",
2524 "virtual",
2525 "override",
2526 "template",
2527 "namespace",
2528 "using",
2529 "new",
2530 "delete",
2531 "try",
2532 "catch",
2533 "throw",
2534 "nullptr",
2535 "true",
2536 "false",
2537 "this",
2538 "include",
2539 "define",
2540 "ifdef",
2541 "ifndef",
2542 "endif",
2543 ],
2544 "sql" => &[
2545 "SELECT",
2546 "FROM",
2547 "WHERE",
2548 "INSERT",
2549 "UPDATE",
2550 "DELETE",
2551 "CREATE",
2552 "DROP",
2553 "ALTER",
2554 "TABLE",
2555 "INDEX",
2556 "INTO",
2557 "VALUES",
2558 "SET",
2559 "AND",
2560 "OR",
2561 "NOT",
2562 "NULL",
2563 "JOIN",
2564 "LEFT",
2565 "RIGHT",
2566 "INNER",
2567 "OUTER",
2568 "ON",
2569 "GROUP",
2570 "BY",
2571 "ORDER",
2572 "ASC",
2573 "DESC",
2574 "HAVING",
2575 "LIMIT",
2576 "OFFSET",
2577 "UNION",
2578 "AS",
2579 "DISTINCT",
2580 "COUNT",
2581 "SUM",
2582 "AVG",
2583 "MIN",
2584 "MAX",
2585 "LIKE",
2586 "IN",
2587 "BETWEEN",
2588 "EXISTS",
2589 "CASE",
2590 "WHEN",
2591 "THEN",
2592 "ELSE",
2593 "END",
2594 "BEGIN",
2595 "COMMIT",
2596 "ROLLBACK",
2597 "PRIMARY",
2598 "KEY",
2599 "FOREIGN",
2600 "REFERENCES",
2601 "select",
2602 "from",
2603 "where",
2604 "insert",
2605 "update",
2606 "delete",
2607 "create",
2608 "drop",
2609 "alter",
2610 "table",
2611 "index",
2612 "into",
2613 "values",
2614 "set",
2615 "and",
2616 "or",
2617 "not",
2618 "null",
2619 "join",
2620 "left",
2621 "right",
2622 "inner",
2623 "outer",
2624 "on",
2625 "group",
2626 "by",
2627 "order",
2628 "asc",
2629 "desc",
2630 "having",
2631 "limit",
2632 "offset",
2633 "union",
2634 "as",
2635 "distinct",
2636 "count",
2637 "sum",
2638 "avg",
2639 "min",
2640 "max",
2641 "like",
2642 "in",
2643 "between",
2644 "exists",
2645 "case",
2646 "when",
2647 "then",
2648 "else",
2649 "end",
2650 "begin",
2651 "commit",
2652 "rollback",
2653 "primary",
2654 "key",
2655 "foreign",
2656 "references",
2657 ],
2658 "yaml" | "yml" => &["true", "false", "null", "yes", "no", "on", "off"],
2659 "toml" => &[
2660 "true",
2661 "false",
2662 "true",
2663 "false",
2664 "name",
2666 "version",
2667 "edition",
2668 "authors",
2669 "dependencies",
2670 "dev-dependencies",
2671 "build-dependencies",
2672 "features",
2673 "workspace",
2674 "members",
2675 "exclude",
2676 "include",
2677 "path",
2678 "git",
2679 "branch",
2680 "tag",
2681 "rev",
2682 "package",
2683 "lib",
2684 "bin",
2685 "example",
2686 "test",
2687 "bench",
2688 "doc",
2689 "profile",
2690 "release",
2691 "debug",
2692 "opt-level",
2693 "lto",
2694 "codegen-units",
2695 "panic",
2696 "strip",
2697 "default",
2698 "features",
2699 "optional",
2700 "repository",
2702 "homepage",
2703 "documentation",
2704 "license",
2705 "license-file",
2706 "keywords",
2707 "categories",
2708 "readme",
2709 "description",
2710 "resolver",
2711 ],
2712 "css" | "scss" | "less" => &[
2713 "color",
2714 "background",
2715 "border",
2716 "margin",
2717 "padding",
2718 "display",
2719 "position",
2720 "width",
2721 "height",
2722 "font",
2723 "text",
2724 "flex",
2725 "grid",
2726 "align",
2727 "justify",
2728 "important",
2729 "none",
2730 "auto",
2731 "inherit",
2732 "initial",
2733 "unset",
2734 ],
2735 "dockerfile" | "docker" => &[
2736 "FROM",
2737 "RUN",
2738 "CMD",
2739 "LABEL",
2740 "EXPOSE",
2741 "ENV",
2742 "ADD",
2743 "COPY",
2744 "ENTRYPOINT",
2745 "VOLUME",
2746 "USER",
2747 "WORKDIR",
2748 "ARG",
2749 "ONBUILD",
2750 "STOPSIGNAL",
2751 "HEALTHCHECK",
2752 "SHELL",
2753 "AS",
2754 ],
2755 "ruby" | "rb" => &[
2756 "def", "end", "class", "module", "if", "elsif", "else", "unless", "while", "until",
2757 "for", "do", "begin", "rescue", "ensure", "raise", "return", "yield", "require",
2758 "include", "attr", "self", "true", "false", "nil", "puts", "print",
2759 ],
2760 _ => &[
2761 "fn", "function", "def", "class", "return", "if", "else", "for", "while", "import",
2762 "export", "const", "let", "var", "true", "false", "null", "nil", "None", "self",
2763 "this",
2764 ],
2765 };
2766
2767 let comment_prefix = match lang_lower.as_str() {
2768 "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml"
2769 | "toml" | "dockerfile" | "docker" => "#",
2770 "sql" => "--",
2771 "css" | "scss" | "less" => "/*",
2772 _ => "//",
2773 };
2774
2775 let code_style = Style::default().fg(Color::Rgb(200, 200, 210));
2777 let kw_style = Style::default().fg(Color::Rgb(198, 120, 221));
2779 let str_style = Style::default().fg(Color::Rgb(152, 195, 121));
2781 let comment_style = Style::default()
2783 .fg(Color::Rgb(92, 99, 112))
2784 .add_modifier(Modifier::ITALIC);
2785 let num_style = Style::default().fg(Color::Rgb(209, 154, 102));
2787 let type_style = Style::default().fg(Color::Rgb(229, 192, 123));
2789
2790 let trimmed = line.trim_start();
2791
2792 if trimmed.starts_with(comment_prefix) {
2794 return vec![Span::styled(line.to_string(), comment_style)];
2795 }
2796
2797 let mut spans = Vec::new();
2799 let mut chars = line.chars().peekable();
2800 let mut buf = String::new();
2801
2802 while let Some(&ch) = chars.peek() {
2803 if ch == '"' || ch == '\'' || ch == '`' {
2805 if !buf.is_empty() {
2807 spans.extend(colorize_tokens(
2808 &buf, keywords, code_style, kw_style, num_style, type_style,
2809 ));
2810 buf.clear();
2811 }
2812 let quote = ch;
2813 let mut s = String::new();
2814 s.push(ch);
2815 chars.next();
2816 while let Some(&c) = chars.peek() {
2817 s.push(c);
2818 chars.next();
2819 if c == quote && !s.ends_with("\\\\") {
2820 break;
2821 }
2822 }
2823 spans.push(Span::styled(s, str_style));
2824 continue;
2825 }
2826 if ch == '$'
2828 && matches!(
2829 lang_lower.as_str(),
2830 "sh" | "bash" | "zsh" | "shell" | "dockerfile" | "docker"
2831 )
2832 {
2833 if !buf.is_empty() {
2834 spans.extend(colorize_tokens(
2835 &buf, keywords, code_style, kw_style, num_style, type_style,
2836 ));
2837 buf.clear();
2838 }
2839 let var_style = Style::default().fg(Color::Rgb(86, 182, 194));
2840 let mut var = String::new();
2841 var.push(ch);
2842 chars.next();
2843 if let Some(&next_ch) = chars.peek() {
2844 if next_ch == '{' {
2845 var.push(next_ch);
2847 chars.next();
2848 while let Some(&c) = chars.peek() {
2849 var.push(c);
2850 chars.next();
2851 if c == '}' {
2852 break;
2853 }
2854 }
2855 } else if next_ch == '(' {
2856 var.push(next_ch);
2858 chars.next();
2859 let mut depth = 1;
2860 while let Some(&c) = chars.peek() {
2861 var.push(c);
2862 chars.next();
2863 if c == '(' {
2864 depth += 1;
2865 }
2866 if c == ')' {
2867 depth -= 1;
2868 if depth == 0 {
2869 break;
2870 }
2871 }
2872 }
2873 } else if next_ch.is_alphanumeric()
2874 || next_ch == '_'
2875 || next_ch == '@'
2876 || next_ch == '#'
2877 || next_ch == '?'
2878 || next_ch == '!'
2879 {
2880 while let Some(&c) = chars.peek() {
2882 if c.is_alphanumeric() || c == '_' {
2883 var.push(c);
2884 chars.next();
2885 } else {
2886 break;
2887 }
2888 }
2889 }
2890 }
2891 spans.push(Span::styled(var, var_style));
2892 continue;
2893 }
2894 if ch == '/' || ch == '#' {
2896 let rest: String = chars.clone().collect();
2897 if rest.starts_with(comment_prefix) {
2898 if !buf.is_empty() {
2899 spans.extend(colorize_tokens(
2900 &buf, keywords, code_style, kw_style, num_style, type_style,
2901 ));
2902 buf.clear();
2903 }
2904 spans.push(Span::styled(rest, comment_style));
2905 break;
2906 }
2907 }
2908 buf.push(ch);
2909 chars.next();
2910 }
2911
2912 if !buf.is_empty() {
2913 spans.extend(colorize_tokens(
2914 &buf, keywords, code_style, kw_style, num_style, type_style,
2915 ));
2916 }
2917
2918 if spans.is_empty() {
2919 spans.push(Span::styled(line.to_string(), code_style));
2920 }
2921
2922 spans
2923}
2924
2925fn colorize_tokens<'a>(
2927 text: &str,
2928 keywords: &[&str],
2929 default_style: Style,
2930 kw_style: Style,
2931 num_style: Style,
2932 type_style: Style,
2933) -> Vec<Span<'static>> {
2934 let mut spans = Vec::new();
2935 let mut current_word = String::new();
2936 let mut current_non_word = String::new();
2937
2938 for ch in text.chars() {
2939 if ch.is_alphanumeric() || ch == '_' {
2940 if !current_non_word.is_empty() {
2941 spans.push(Span::styled(current_non_word.clone(), default_style));
2942 current_non_word.clear();
2943 }
2944 current_word.push(ch);
2945 } else {
2946 if !current_word.is_empty() {
2947 let style = if keywords.contains(¤t_word.as_str()) {
2948 kw_style
2949 } else if current_word
2950 .chars()
2951 .next()
2952 .map(|c| c.is_ascii_digit())
2953 .unwrap_or(false)
2954 {
2955 num_style
2956 } else if current_word
2957 .chars()
2958 .next()
2959 .map(|c| c.is_uppercase())
2960 .unwrap_or(false)
2961 {
2962 type_style
2963 } else {
2964 default_style
2965 };
2966 spans.push(Span::styled(current_word.clone(), style));
2967 current_word.clear();
2968 }
2969 current_non_word.push(ch);
2970 }
2971 }
2972
2973 if !current_non_word.is_empty() {
2975 spans.push(Span::styled(current_non_word, default_style));
2976 }
2977 if !current_word.is_empty() {
2978 let style = if keywords.contains(¤t_word.as_str()) {
2979 kw_style
2980 } else if current_word
2981 .chars()
2982 .next()
2983 .map(|c| c.is_ascii_digit())
2984 .unwrap_or(false)
2985 {
2986 num_style
2987 } else if current_word
2988 .chars()
2989 .next()
2990 .map(|c| c.is_uppercase())
2991 .unwrap_or(false)
2992 {
2993 type_style
2994 } else {
2995 default_style
2996 };
2997 spans.push(Span::styled(current_word, style));
2998 }
2999
3000 spans
3001}
3002
3003fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
3005 let max_width = max_width.max(2);
3007 let mut result = Vec::new();
3008 let mut current_line = String::new();
3009 let mut current_width = 0;
3010
3011 for ch in text.chars() {
3012 let ch_width = char_width(ch);
3013 if current_width + ch_width > max_width && !current_line.is_empty() {
3014 result.push(current_line.clone());
3015 current_line.clear();
3016 current_width = 0;
3017 }
3018 current_line.push(ch);
3019 current_width += ch_width;
3020 }
3021 if !current_line.is_empty() {
3022 result.push(current_line);
3023 }
3024 if result.is_empty() {
3025 result.push(String::new());
3026 }
3027 result
3028}
3029
3030fn display_width(s: &str) -> usize {
3032 use unicode_width::UnicodeWidthStr;
3033 UnicodeWidthStr::width(s)
3034}
3035
3036fn char_width(c: char) -> usize {
3038 use unicode_width::UnicodeWidthChar;
3039 UnicodeWidthChar::width(c).unwrap_or(0)
3040}
3041
3042fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
3044 let usable_width = area.width.saturating_sub(2 + 4) as usize;
3046
3047 let chars: Vec<char> = app.input.chars().collect();
3048
3049 let before_all: String = chars[..app.cursor_pos].iter().collect();
3051 let before_width = display_width(&before_all);
3052
3053 let scroll_offset_chars = if before_width >= usable_width {
3055 let target_width = before_width.saturating_sub(usable_width / 2);
3057 let mut w = 0;
3058 let mut skip = 0;
3059 for (i, &ch) in chars.iter().enumerate() {
3060 if w >= target_width {
3061 skip = i;
3062 break;
3063 }
3064 w += char_width(ch);
3065 }
3066 skip
3067 } else {
3068 0
3069 };
3070
3071 let visible_chars = &chars[scroll_offset_chars..];
3073 let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
3074
3075 let before: String = visible_chars[..cursor_in_visible].iter().collect();
3076 let cursor_ch = if cursor_in_visible < visible_chars.len() {
3077 visible_chars[cursor_in_visible].to_string()
3078 } else {
3079 " ".to_string()
3080 };
3081 let after: String = if cursor_in_visible < visible_chars.len() {
3082 visible_chars[cursor_in_visible + 1..].iter().collect()
3083 } else {
3084 String::new()
3085 };
3086
3087 let prompt_style = if app.is_loading {
3088 Style::default().fg(Color::Rgb(255, 200, 80))
3089 } else {
3090 Style::default().fg(Color::Rgb(100, 200, 130))
3091 };
3092 let prompt_text = if app.is_loading { " .. " } else { " > " };
3093
3094 let full_visible = format!("{}{}{}", before, cursor_ch, after);
3096 let inner_height = area.height.saturating_sub(2) as usize; let wrapped_lines = wrap_text(&full_visible, usable_width);
3098
3099 let before_len = before.chars().count();
3101 let cursor_len = cursor_ch.chars().count();
3102 let cursor_global_pos = before_len; let mut cursor_line_idx: usize = 0;
3104 {
3105 let mut cumulative = 0usize;
3106 for (li, wl) in wrapped_lines.iter().enumerate() {
3107 let line_char_count = wl.chars().count();
3108 if cumulative + line_char_count > cursor_global_pos {
3109 cursor_line_idx = li;
3110 break;
3111 }
3112 cumulative += line_char_count;
3113 cursor_line_idx = li; }
3115 }
3116
3117 let line_scroll = if wrapped_lines.len() <= inner_height {
3119 0
3120 } else if cursor_line_idx < inner_height {
3121 0
3122 } else {
3123 cursor_line_idx.saturating_sub(inner_height - 1)
3125 };
3126
3127 let mut display_lines: Vec<Line> = Vec::new();
3129 let mut char_offset: usize = 0;
3130 for wl in wrapped_lines.iter().take(line_scroll) {
3132 char_offset += wl.chars().count();
3133 }
3134
3135 for (_line_idx, wl) in wrapped_lines
3136 .iter()
3137 .skip(line_scroll)
3138 .enumerate()
3139 .take(inner_height.max(1))
3140 {
3141 let mut spans: Vec<Span> = Vec::new();
3142 if _line_idx == 0 && line_scroll == 0 {
3143 spans.push(Span::styled(prompt_text, prompt_style));
3144 } else {
3145 spans.push(Span::styled(" ", Style::default())); }
3147
3148 let line_chars: Vec<char> = wl.chars().collect();
3150 let mut seg_start = 0;
3151 for (ci, &ch) in line_chars.iter().enumerate() {
3152 let global_idx = char_offset + ci;
3153 let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
3154
3155 if is_cursor {
3156 if ci > seg_start {
3158 let seg: String = line_chars[seg_start..ci].iter().collect();
3159 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
3160 }
3161 spans.push(Span::styled(
3162 ch.to_string(),
3163 Style::default()
3164 .fg(Color::Rgb(22, 22, 30))
3165 .bg(Color::Rgb(200, 210, 240)),
3166 ));
3167 seg_start = ci + 1;
3168 }
3169 }
3170 if seg_start < line_chars.len() {
3172 let seg: String = line_chars[seg_start..].iter().collect();
3173 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
3174 }
3175
3176 char_offset += line_chars.len();
3177 display_lines.push(Line::from(spans));
3178 }
3179
3180 if display_lines.is_empty() {
3181 display_lines.push(Line::from(vec![
3182 Span::styled(prompt_text, prompt_style),
3183 Span::styled(
3184 " ",
3185 Style::default()
3186 .fg(Color::Rgb(22, 22, 30))
3187 .bg(Color::Rgb(200, 210, 240)),
3188 ),
3189 ]));
3190 }
3191
3192 let input_widget = Paragraph::new(display_lines).block(
3193 Block::default()
3194 .borders(Borders::ALL)
3195 .border_type(ratatui::widgets::BorderType::Rounded)
3196 .border_style(if app.is_loading {
3197 Style::default().fg(Color::Rgb(120, 100, 50))
3198 } else {
3199 Style::default().fg(Color::Rgb(60, 100, 80))
3200 })
3201 .title(Span::styled(
3202 " 输入消息 ",
3203 Style::default().fg(Color::Rgb(140, 140, 170)),
3204 ))
3205 .style(Style::default().bg(Color::Rgb(26, 26, 38))),
3206 );
3207
3208 f.render_widget(input_widget, area);
3209
3210 if !app.is_loading {
3213 let prompt_w: u16 = 4; let border_left: u16 = 1; let cursor_col_in_line = {
3218 let mut col = 0usize;
3219 let mut char_count = 0usize;
3220 let mut skip_chars = 0usize;
3222 for wl in wrapped_lines.iter().take(line_scroll) {
3223 skip_chars += wl.chars().count();
3224 }
3225 for wl in wrapped_lines.iter().skip(line_scroll) {
3227 let line_len = wl.chars().count();
3228 if skip_chars + char_count + line_len > cursor_global_pos {
3229 let pos_in_line = cursor_global_pos - (skip_chars + char_count);
3231 col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
3232 break;
3233 }
3234 char_count += line_len;
3235 }
3236 col as u16
3237 };
3238
3239 let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
3241
3242 let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
3243 let cursor_y = area.y + 1 + cursor_row_in_display; if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
3247 f.set_cursor_position((cursor_x, cursor_y));
3248 }
3249 }
3250}
3251
3252fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
3254 let hints = match app.mode {
3255 ChatMode::Chat => {
3256 vec![
3257 ("Enter", "发送"),
3258 ("↑↓", "滚动"),
3259 ("Ctrl+T", "切换模型"),
3260 ("Ctrl+L", "清空"),
3261 ("Ctrl+Y", "复制"),
3262 ("Ctrl+B", "浏览"),
3263 ("Ctrl+S", "流式切换"),
3264 ("Ctrl+E", "配置"),
3265 ("?/F1", "帮助"),
3266 ("Esc", "退出"),
3267 ]
3268 }
3269 ChatMode::SelectModel => {
3270 vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
3271 }
3272 ChatMode::Browse => {
3273 vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
3274 }
3275 ChatMode::Help => {
3276 vec![("任意键", "返回")]
3277 }
3278 ChatMode::Config => {
3279 vec![
3280 ("↑↓", "切换字段"),
3281 ("Enter", "编辑"),
3282 ("Tab", "切换 Provider"),
3283 ("a", "新增"),
3284 ("d", "删除"),
3285 ("Esc", "保存返回"),
3286 ]
3287 }
3288 };
3289
3290 let mut spans: Vec<Span> = Vec::new();
3291 spans.push(Span::styled(" ", Style::default()));
3292 for (i, (key, desc)) in hints.iter().enumerate() {
3293 if i > 0 {
3294 spans.push(Span::styled(
3295 " │ ",
3296 Style::default().fg(Color::Rgb(50, 50, 65)),
3297 ));
3298 }
3299 spans.push(Span::styled(
3300 format!(" {} ", key),
3301 Style::default()
3302 .fg(Color::Rgb(22, 22, 30))
3303 .bg(Color::Rgb(100, 110, 140)),
3304 ));
3305 spans.push(Span::styled(
3306 format!(" {}", desc),
3307 Style::default().fg(Color::Rgb(120, 120, 150)),
3308 ));
3309 }
3310
3311 let hint_bar =
3312 Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(22, 22, 30)));
3313 f.render_widget(hint_bar, area);
3314}
3315
3316fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
3318 if let Some((ref msg, is_error, _)) = app.toast {
3319 let text_width = display_width(msg);
3320 let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
3322 let toast_height: u16 = 3;
3323
3324 let x = area.width.saturating_sub(toast_width + 1);
3326 let y: u16 = 1;
3327
3328 if x + toast_width <= area.width && y + toast_height <= area.height {
3329 let toast_area = Rect::new(x, y, toast_width, toast_height);
3330
3331 let clear = Block::default().style(Style::default().bg(if is_error {
3333 Color::Rgb(60, 20, 20)
3334 } else {
3335 Color::Rgb(20, 50, 30)
3336 }));
3337 f.render_widget(clear, toast_area);
3338
3339 let (icon, border_color, text_color) = if is_error {
3340 ("❌", Color::Rgb(200, 70, 70), Color::Rgb(255, 130, 130))
3341 } else {
3342 ("✅", Color::Rgb(60, 160, 80), Color::Rgb(140, 230, 160))
3343 };
3344
3345 let toast_widget = Paragraph::new(Line::from(vec![
3346 Span::styled(format!(" {} ", icon), Style::default()),
3347 Span::styled(msg.as_str(), Style::default().fg(text_color)),
3348 ]))
3349 .block(
3350 Block::default()
3351 .borders(Borders::ALL)
3352 .border_type(ratatui::widgets::BorderType::Rounded)
3353 .border_style(Style::default().fg(border_color))
3354 .style(Style::default().bg(if is_error {
3355 Color::Rgb(50, 18, 18)
3356 } else {
3357 Color::Rgb(18, 40, 25)
3358 })),
3359 );
3360 f.render_widget(toast_widget, toast_area);
3361 }
3362 }
3363}
3364
3365fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
3367 let items: Vec<ListItem> = app
3368 .agent_config
3369 .providers
3370 .iter()
3371 .enumerate()
3372 .map(|(i, p)| {
3373 let is_active = i == app.agent_config.active_index;
3374 let marker = if is_active { " ● " } else { " ○ " };
3375 let style = if is_active {
3376 Style::default()
3377 .fg(Color::Rgb(120, 220, 160))
3378 .add_modifier(Modifier::BOLD)
3379 } else {
3380 Style::default().fg(Color::Rgb(180, 180, 200))
3381 };
3382 let detail = format!("{}{} ({})", marker, p.name, p.model);
3383 ListItem::new(Line::from(Span::styled(detail, style)))
3384 })
3385 .collect();
3386
3387 let list = List::new(items)
3388 .block(
3389 Block::default()
3390 .borders(Borders::ALL)
3391 .border_type(ratatui::widgets::BorderType::Rounded)
3392 .border_style(Style::default().fg(Color::Rgb(180, 160, 80)))
3393 .title(Span::styled(
3394 " 🔄 选择模型 ",
3395 Style::default()
3396 .fg(Color::Rgb(230, 210, 120))
3397 .add_modifier(Modifier::BOLD),
3398 ))
3399 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
3400 )
3401 .highlight_style(
3402 Style::default()
3403 .bg(Color::Rgb(50, 55, 80))
3404 .fg(Color::White)
3405 .add_modifier(Modifier::BOLD),
3406 )
3407 .highlight_symbol(" ▸ ");
3408
3409 f.render_stateful_widget(list, area, &mut app.model_list_state);
3410}
3411
3412fn draw_help(f: &mut ratatui::Frame, area: Rect) {
3414 let separator = Line::from(Span::styled(
3415 " ─────────────────────────────────────────",
3416 Style::default().fg(Color::Rgb(50, 55, 70)),
3417 ));
3418
3419 let help_lines = vec![
3420 Line::from(""),
3421 Line::from(Span::styled(
3422 " 📖 快捷键帮助",
3423 Style::default()
3424 .fg(Color::Rgb(120, 180, 255))
3425 .add_modifier(Modifier::BOLD),
3426 )),
3427 Line::from(""),
3428 separator.clone(),
3429 Line::from(""),
3430 Line::from(vec![
3431 Span::styled(
3432 " Enter ",
3433 Style::default()
3434 .fg(Color::Rgb(230, 210, 120))
3435 .add_modifier(Modifier::BOLD),
3436 ),
3437 Span::styled("发送消息", Style::default().fg(Color::Rgb(200, 200, 220))),
3438 ]),
3439 Line::from(vec![
3440 Span::styled(
3441 " ↑ / ↓ ",
3442 Style::default()
3443 .fg(Color::Rgb(230, 210, 120))
3444 .add_modifier(Modifier::BOLD),
3445 ),
3446 Span::styled(
3447 "滚动对话记录",
3448 Style::default().fg(Color::Rgb(200, 200, 220)),
3449 ),
3450 ]),
3451 Line::from(vec![
3452 Span::styled(
3453 " ← / → ",
3454 Style::default()
3455 .fg(Color::Rgb(230, 210, 120))
3456 .add_modifier(Modifier::BOLD),
3457 ),
3458 Span::styled(
3459 "移动输入光标",
3460 Style::default().fg(Color::Rgb(200, 200, 220)),
3461 ),
3462 ]),
3463 Line::from(vec![
3464 Span::styled(
3465 " Ctrl+T ",
3466 Style::default()
3467 .fg(Color::Rgb(230, 210, 120))
3468 .add_modifier(Modifier::BOLD),
3469 ),
3470 Span::styled("切换模型", Style::default().fg(Color::Rgb(200, 200, 220))),
3471 ]),
3472 Line::from(vec![
3473 Span::styled(
3474 " Ctrl+L ",
3475 Style::default()
3476 .fg(Color::Rgb(230, 210, 120))
3477 .add_modifier(Modifier::BOLD),
3478 ),
3479 Span::styled(
3480 "清空对话历史",
3481 Style::default().fg(Color::Rgb(200, 200, 220)),
3482 ),
3483 ]),
3484 Line::from(vec![
3485 Span::styled(
3486 " Ctrl+Y ",
3487 Style::default()
3488 .fg(Color::Rgb(230, 210, 120))
3489 .add_modifier(Modifier::BOLD),
3490 ),
3491 Span::styled(
3492 "复制最后一条 AI 回复",
3493 Style::default().fg(Color::Rgb(200, 200, 220)),
3494 ),
3495 ]),
3496 Line::from(vec![
3497 Span::styled(
3498 " Ctrl+B ",
3499 Style::default()
3500 .fg(Color::Rgb(230, 210, 120))
3501 .add_modifier(Modifier::BOLD),
3502 ),
3503 Span::styled(
3504 "浏览消息 (↑↓选择, y/Enter复制)",
3505 Style::default().fg(Color::Rgb(200, 200, 220)),
3506 ),
3507 ]),
3508 Line::from(vec![
3509 Span::styled(
3510 " Ctrl+S ",
3511 Style::default()
3512 .fg(Color::Rgb(230, 210, 120))
3513 .add_modifier(Modifier::BOLD),
3514 ),
3515 Span::styled(
3516 "切换流式/整体输出",
3517 Style::default().fg(Color::Rgb(200, 200, 220)),
3518 ),
3519 ]),
3520 Line::from(vec![
3521 Span::styled(
3522 " Ctrl+E ",
3523 Style::default()
3524 .fg(Color::Rgb(230, 210, 120))
3525 .add_modifier(Modifier::BOLD),
3526 ),
3527 Span::styled(
3528 "打开配置界面",
3529 Style::default().fg(Color::Rgb(200, 200, 220)),
3530 ),
3531 ]),
3532 Line::from(vec![
3533 Span::styled(
3534 " Esc / Ctrl+C ",
3535 Style::default()
3536 .fg(Color::Rgb(230, 210, 120))
3537 .add_modifier(Modifier::BOLD),
3538 ),
3539 Span::styled("退出对话", Style::default().fg(Color::Rgb(200, 200, 220))),
3540 ]),
3541 Line::from(vec![
3542 Span::styled(
3543 " ? / F1 ",
3544 Style::default()
3545 .fg(Color::Rgb(230, 210, 120))
3546 .add_modifier(Modifier::BOLD),
3547 ),
3548 Span::styled(
3549 "显示 / 关闭此帮助",
3550 Style::default().fg(Color::Rgb(200, 200, 220)),
3551 ),
3552 ]),
3553 Line::from(""),
3554 separator,
3555 Line::from(""),
3556 Line::from(Span::styled(
3557 " 📁 配置文件:",
3558 Style::default()
3559 .fg(Color::Rgb(120, 180, 255))
3560 .add_modifier(Modifier::BOLD),
3561 )),
3562 Line::from(Span::styled(
3563 format!(" {}", agent_config_path().display()),
3564 Style::default().fg(Color::Rgb(100, 100, 130)),
3565 )),
3566 ];
3567
3568 let help_block = Block::default()
3569 .borders(Borders::ALL)
3570 .border_type(ratatui::widgets::BorderType::Rounded)
3571 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
3572 .title(Span::styled(
3573 " 帮助 (按任意键返回) ",
3574 Style::default().fg(Color::Rgb(140, 140, 170)),
3575 ))
3576 .style(Style::default().bg(Color::Rgb(24, 24, 34)));
3577 let help_widget = Paragraph::new(help_lines).block(help_block);
3578 f.render_widget(help_widget, area);
3579}
3580
3581fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
3583 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
3585 return true;
3586 }
3587
3588 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') {
3590 if !app.agent_config.providers.is_empty() {
3591 app.mode = ChatMode::SelectModel;
3592 app.model_list_state
3593 .select(Some(app.agent_config.active_index));
3594 }
3595 return false;
3596 }
3597
3598 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
3600 app.clear_session();
3601 return false;
3602 }
3603
3604 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('y') {
3606 if let Some(last_ai) = app
3607 .session
3608 .messages
3609 .iter()
3610 .rev()
3611 .find(|m| m.role == "assistant")
3612 {
3613 if copy_to_clipboard(&last_ai.content) {
3614 app.show_toast("已复制最后一条 AI 回复", false);
3615 } else {
3616 app.show_toast("复制到剪切板失败", true);
3617 }
3618 } else {
3619 app.show_toast("暂无 AI 回复可复制", true);
3620 }
3621 return false;
3622 }
3623
3624 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
3626 if !app.session.messages.is_empty() {
3627 app.browse_msg_index = app.session.messages.len() - 1;
3629 app.mode = ChatMode::Browse;
3630 app.msg_lines_cache = None; } else {
3632 app.show_toast("暂无消息可浏览", true);
3633 }
3634 return false;
3635 }
3636
3637 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
3639 app.config_provider_idx = app
3641 .agent_config
3642 .active_index
3643 .min(app.agent_config.providers.len().saturating_sub(1));
3644 app.config_field_idx = 0;
3645 app.config_editing = false;
3646 app.config_edit_buf.clear();
3647 app.mode = ChatMode::Config;
3648 return false;
3649 }
3650
3651 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
3653 app.agent_config.stream_mode = !app.agent_config.stream_mode;
3654 let _ = save_agent_config(&app.agent_config);
3655 let mode_str = if app.agent_config.stream_mode {
3656 "流式输出"
3657 } else {
3658 "整体输出"
3659 };
3660 app.show_toast(&format!("已切换为: {}", mode_str), false);
3661 return false;
3662 }
3663
3664 let char_count = app.input.chars().count();
3665
3666 match key.code {
3667 KeyCode::Esc => return true,
3668
3669 KeyCode::Enter => {
3670 if !app.is_loading {
3671 app.send_message();
3672 }
3673 }
3674
3675 KeyCode::Up => app.scroll_up(),
3677 KeyCode::Down => app.scroll_down(),
3678 KeyCode::PageUp => {
3679 for _ in 0..10 {
3680 app.scroll_up();
3681 }
3682 }
3683 KeyCode::PageDown => {
3684 for _ in 0..10 {
3685 app.scroll_down();
3686 }
3687 }
3688
3689 KeyCode::Left => {
3691 if app.cursor_pos > 0 {
3692 app.cursor_pos -= 1;
3693 }
3694 }
3695 KeyCode::Right => {
3696 if app.cursor_pos < char_count {
3697 app.cursor_pos += 1;
3698 }
3699 }
3700 KeyCode::Home => app.cursor_pos = 0,
3701 KeyCode::End => app.cursor_pos = char_count,
3702
3703 KeyCode::Backspace => {
3705 if app.cursor_pos > 0 {
3706 let start = app
3707 .input
3708 .char_indices()
3709 .nth(app.cursor_pos - 1)
3710 .map(|(i, _)| i)
3711 .unwrap_or(0);
3712 let end = app
3713 .input
3714 .char_indices()
3715 .nth(app.cursor_pos)
3716 .map(|(i, _)| i)
3717 .unwrap_or(app.input.len());
3718 app.input.drain(start..end);
3719 app.cursor_pos -= 1;
3720 }
3721 }
3722 KeyCode::Delete => {
3723 if app.cursor_pos < char_count {
3724 let start = app
3725 .input
3726 .char_indices()
3727 .nth(app.cursor_pos)
3728 .map(|(i, _)| i)
3729 .unwrap_or(app.input.len());
3730 let end = app
3731 .input
3732 .char_indices()
3733 .nth(app.cursor_pos + 1)
3734 .map(|(i, _)| i)
3735 .unwrap_or(app.input.len());
3736 app.input.drain(start..end);
3737 }
3738 }
3739
3740 KeyCode::F(1) => {
3742 app.mode = ChatMode::Help;
3743 }
3744 KeyCode::Char('?') if app.input.is_empty() => {
3746 app.mode = ChatMode::Help;
3747 }
3748 KeyCode::Char(c) => {
3749 let byte_idx = app
3750 .input
3751 .char_indices()
3752 .nth(app.cursor_pos)
3753 .map(|(i, _)| i)
3754 .unwrap_or(app.input.len());
3755 app.input.insert_str(byte_idx, &c.to_string());
3756 app.cursor_pos += 1;
3757 }
3758
3759 _ => {}
3760 }
3761
3762 false
3763}
3764
3765fn handle_browse_mode(app: &mut ChatApp, key: KeyEvent) {
3767 let msg_count = app.session.messages.len();
3768 if msg_count == 0 {
3769 app.mode = ChatMode::Chat;
3770 app.msg_lines_cache = None;
3771 return;
3772 }
3773
3774 match key.code {
3775 KeyCode::Esc => {
3776 app.mode = ChatMode::Chat;
3777 app.msg_lines_cache = None; }
3779 KeyCode::Up | KeyCode::Char('k') => {
3780 if app.browse_msg_index > 0 {
3781 app.browse_msg_index -= 1;
3782 app.msg_lines_cache = None; }
3784 }
3785 KeyCode::Down | KeyCode::Char('j') => {
3786 if app.browse_msg_index < msg_count - 1 {
3787 app.browse_msg_index += 1;
3788 app.msg_lines_cache = None; }
3790 }
3791 KeyCode::Enter | KeyCode::Char('y') => {
3792 if let Some(msg) = app.session.messages.get(app.browse_msg_index) {
3794 let content = msg.content.clone();
3795 let role_label = if msg.role == "assistant" {
3796 "AI"
3797 } else if msg.role == "user" {
3798 "用户"
3799 } else {
3800 "系统"
3801 };
3802 if copy_to_clipboard(&content) {
3803 app.show_toast(
3804 &format!("已复制第 {} 条{}消息", app.browse_msg_index + 1, role_label),
3805 false,
3806 );
3807 } else {
3808 app.show_toast("复制到剪切板失败", true);
3809 }
3810 }
3811 }
3812 _ => {}
3813 }
3814}
3815
3816fn config_field_label(idx: usize) -> &'static str {
3818 let total_provider = CONFIG_FIELDS.len();
3819 if idx < total_provider {
3820 match CONFIG_FIELDS[idx] {
3821 "name" => "显示名称",
3822 "api_base" => "API Base",
3823 "api_key" => "API Key",
3824 "model" => "模型名称",
3825 _ => CONFIG_FIELDS[idx],
3826 }
3827 } else {
3828 let gi = idx - total_provider;
3829 match CONFIG_GLOBAL_FIELDS[gi] {
3830 "system_prompt" => "系统提示词",
3831 "stream_mode" => "流式输出",
3832 _ => CONFIG_GLOBAL_FIELDS[gi],
3833 }
3834 }
3835}
3836
3837fn config_field_value(app: &ChatApp, field_idx: usize) -> String {
3839 let total_provider = CONFIG_FIELDS.len();
3840 if field_idx < total_provider {
3841 if app.agent_config.providers.is_empty() {
3842 return String::new();
3843 }
3844 let p = &app.agent_config.providers[app.config_provider_idx];
3845 match CONFIG_FIELDS[field_idx] {
3846 "name" => p.name.clone(),
3847 "api_base" => p.api_base.clone(),
3848 "api_key" => {
3849 if p.api_key.len() > 8 {
3851 format!(
3852 "{}****{}",
3853 &p.api_key[..4],
3854 &p.api_key[p.api_key.len() - 4..]
3855 )
3856 } else {
3857 p.api_key.clone()
3858 }
3859 }
3860 "model" => p.model.clone(),
3861 _ => String::new(),
3862 }
3863 } else {
3864 let gi = field_idx - total_provider;
3865 match CONFIG_GLOBAL_FIELDS[gi] {
3866 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
3867 "stream_mode" => {
3868 if app.agent_config.stream_mode {
3869 "开启".into()
3870 } else {
3871 "关闭".into()
3872 }
3873 }
3874 _ => String::new(),
3875 }
3876 }
3877}
3878
3879fn config_field_raw_value(app: &ChatApp, field_idx: usize) -> String {
3881 let total_provider = CONFIG_FIELDS.len();
3882 if field_idx < total_provider {
3883 if app.agent_config.providers.is_empty() {
3884 return String::new();
3885 }
3886 let p = &app.agent_config.providers[app.config_provider_idx];
3887 match CONFIG_FIELDS[field_idx] {
3888 "name" => p.name.clone(),
3889 "api_base" => p.api_base.clone(),
3890 "api_key" => p.api_key.clone(),
3891 "model" => p.model.clone(),
3892 _ => String::new(),
3893 }
3894 } else {
3895 let gi = field_idx - total_provider;
3896 match CONFIG_GLOBAL_FIELDS[gi] {
3897 "system_prompt" => app.agent_config.system_prompt.clone().unwrap_or_default(),
3898 "stream_mode" => {
3899 if app.agent_config.stream_mode {
3900 "true".into()
3901 } else {
3902 "false".into()
3903 }
3904 }
3905 _ => String::new(),
3906 }
3907 }
3908}
3909
3910fn config_field_set(app: &mut ChatApp, field_idx: usize, value: &str) {
3912 let total_provider = CONFIG_FIELDS.len();
3913 if field_idx < total_provider {
3914 if app.agent_config.providers.is_empty() {
3915 return;
3916 }
3917 let p = &mut app.agent_config.providers[app.config_provider_idx];
3918 match CONFIG_FIELDS[field_idx] {
3919 "name" => p.name = value.to_string(),
3920 "api_base" => p.api_base = value.to_string(),
3921 "api_key" => p.api_key = value.to_string(),
3922 "model" => p.model = value.to_string(),
3923 _ => {}
3924 }
3925 } else {
3926 let gi = field_idx - total_provider;
3927 match CONFIG_GLOBAL_FIELDS[gi] {
3928 "system_prompt" => {
3929 if value.is_empty() {
3930 app.agent_config.system_prompt = None;
3931 } else {
3932 app.agent_config.system_prompt = Some(value.to_string());
3933 }
3934 }
3935 "stream_mode" => {
3936 app.agent_config.stream_mode = matches!(
3937 value.trim().to_lowercase().as_str(),
3938 "true" | "1" | "开启" | "on" | "yes"
3939 );
3940 }
3941 _ => {}
3942 }
3943 }
3944}
3945
3946fn handle_config_mode(app: &mut ChatApp, key: KeyEvent) {
3948 let total_fields = config_total_fields();
3949
3950 if app.config_editing {
3951 match key.code {
3953 KeyCode::Esc => {
3954 app.config_editing = false;
3956 }
3957 KeyCode::Enter => {
3958 let val = app.config_edit_buf.clone();
3960 config_field_set(app, app.config_field_idx, &val);
3961 app.config_editing = false;
3962 }
3963 KeyCode::Backspace => {
3964 if app.config_edit_cursor > 0 {
3965 let idx = app
3966 .config_edit_buf
3967 .char_indices()
3968 .nth(app.config_edit_cursor - 1)
3969 .map(|(i, _)| i)
3970 .unwrap_or(0);
3971 let end_idx = app
3972 .config_edit_buf
3973 .char_indices()
3974 .nth(app.config_edit_cursor)
3975 .map(|(i, _)| i)
3976 .unwrap_or(app.config_edit_buf.len());
3977 app.config_edit_buf = format!(
3978 "{}{}",
3979 &app.config_edit_buf[..idx],
3980 &app.config_edit_buf[end_idx..]
3981 );
3982 app.config_edit_cursor -= 1;
3983 }
3984 }
3985 KeyCode::Left => {
3986 app.config_edit_cursor = app.config_edit_cursor.saturating_sub(1);
3987 }
3988 KeyCode::Right => {
3989 let char_count = app.config_edit_buf.chars().count();
3990 if app.config_edit_cursor < char_count {
3991 app.config_edit_cursor += 1;
3992 }
3993 }
3994 KeyCode::Char(c) => {
3995 let byte_idx = app
3996 .config_edit_buf
3997 .char_indices()
3998 .nth(app.config_edit_cursor)
3999 .map(|(i, _)| i)
4000 .unwrap_or(app.config_edit_buf.len());
4001 app.config_edit_buf.insert(byte_idx, c);
4002 app.config_edit_cursor += 1;
4003 }
4004 _ => {}
4005 }
4006 return;
4007 }
4008
4009 match key.code {
4011 KeyCode::Esc => {
4012 let _ = save_agent_config(&app.agent_config);
4014 app.show_toast("配置已保存 ✅", false);
4015 app.mode = ChatMode::Chat;
4016 }
4017 KeyCode::Up | KeyCode::Char('k') => {
4018 if total_fields > 0 {
4019 if app.config_field_idx == 0 {
4020 app.config_field_idx = total_fields - 1;
4021 } else {
4022 app.config_field_idx -= 1;
4023 }
4024 }
4025 }
4026 KeyCode::Down | KeyCode::Char('j') => {
4027 if total_fields > 0 {
4028 app.config_field_idx = (app.config_field_idx + 1) % total_fields;
4029 }
4030 }
4031 KeyCode::Tab | KeyCode::Right => {
4032 let count = app.agent_config.providers.len();
4034 if count > 1 {
4035 app.config_provider_idx = (app.config_provider_idx + 1) % count;
4036 }
4038 }
4039 KeyCode::BackTab | KeyCode::Left => {
4040 let count = app.agent_config.providers.len();
4042 if count > 1 {
4043 if app.config_provider_idx == 0 {
4044 app.config_provider_idx = count - 1;
4045 } else {
4046 app.config_provider_idx -= 1;
4047 }
4048 }
4049 }
4050 KeyCode::Enter => {
4051 let total_provider = CONFIG_FIELDS.len();
4053 if app.config_field_idx < total_provider && app.agent_config.providers.is_empty() {
4054 app.show_toast("还没有 Provider,按 a 新增", true);
4055 return;
4056 }
4057 let gi = app.config_field_idx.checked_sub(total_provider);
4059 if let Some(gi) = gi {
4060 if CONFIG_GLOBAL_FIELDS[gi] == "stream_mode" {
4061 app.agent_config.stream_mode = !app.agent_config.stream_mode;
4062 return;
4063 }
4064 }
4065 app.config_edit_buf = config_field_raw_value(app, app.config_field_idx);
4066 app.config_edit_cursor = app.config_edit_buf.chars().count();
4067 app.config_editing = true;
4068 }
4069 KeyCode::Char('a') => {
4070 let new_provider = ModelProvider {
4072 name: format!("Provider-{}", app.agent_config.providers.len() + 1),
4073 api_base: "https://api.openai.com/v1".to_string(),
4074 api_key: String::new(),
4075 model: String::new(),
4076 };
4077 app.agent_config.providers.push(new_provider);
4078 app.config_provider_idx = app.agent_config.providers.len() - 1;
4079 app.config_field_idx = 0; app.show_toast("已新增 Provider,请填写配置", false);
4081 }
4082 KeyCode::Char('d') => {
4083 let count = app.agent_config.providers.len();
4085 if count == 0 {
4086 app.show_toast("没有可删除的 Provider", true);
4087 } else {
4088 let removed_name = app.agent_config.providers[app.config_provider_idx]
4089 .name
4090 .clone();
4091 app.agent_config.providers.remove(app.config_provider_idx);
4092 if app.config_provider_idx >= app.agent_config.providers.len()
4094 && app.config_provider_idx > 0
4095 {
4096 app.config_provider_idx -= 1;
4097 }
4098 if app.agent_config.active_index >= app.agent_config.providers.len()
4100 && app.agent_config.active_index > 0
4101 {
4102 app.agent_config.active_index -= 1;
4103 }
4104 app.show_toast(format!("已删除 Provider: {}", removed_name), false);
4105 }
4106 }
4107 KeyCode::Char('s') => {
4108 if !app.agent_config.providers.is_empty() {
4110 app.agent_config.active_index = app.config_provider_idx;
4111 let name = app.agent_config.providers[app.config_provider_idx]
4112 .name
4113 .clone();
4114 app.show_toast(format!("已设为活跃模型: {}", name), false);
4115 }
4116 }
4117 _ => {}
4118 }
4119}
4120
4121fn draw_config_screen(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
4123 let bg = Color::Rgb(28, 28, 40);
4124 let total_provider_fields = CONFIG_FIELDS.len();
4125
4126 let mut lines: Vec<Line> = Vec::new();
4127 lines.push(Line::from(""));
4128
4129 lines.push(Line::from(vec![Span::styled(
4131 " ⚙️ 模型配置",
4132 Style::default()
4133 .fg(Color::Rgb(120, 180, 255))
4134 .add_modifier(Modifier::BOLD),
4135 )]));
4136 lines.push(Line::from(""));
4137
4138 let provider_count = app.agent_config.providers.len();
4140 if provider_count > 0 {
4141 let mut tab_spans: Vec<Span> = vec![Span::styled(" ", Style::default())];
4142 for (i, p) in app.agent_config.providers.iter().enumerate() {
4143 let is_current = i == app.config_provider_idx;
4144 let is_active = i == app.agent_config.active_index;
4145 let marker = if is_active { "● " } else { "○ " };
4146 let label = format!(" {}{} ", marker, p.name);
4147 if is_current {
4148 tab_spans.push(Span::styled(
4149 label,
4150 Style::default()
4151 .fg(Color::Rgb(22, 22, 30))
4152 .bg(Color::Rgb(120, 180, 255))
4153 .add_modifier(Modifier::BOLD),
4154 ));
4155 } else {
4156 tab_spans.push(Span::styled(
4157 label,
4158 Style::default().fg(Color::Rgb(150, 150, 170)),
4159 ));
4160 }
4161 if i < provider_count - 1 {
4162 tab_spans.push(Span::styled(
4163 " │ ",
4164 Style::default().fg(Color::Rgb(50, 55, 70)),
4165 ));
4166 }
4167 }
4168 tab_spans.push(Span::styled(
4169 " (● = 活跃模型, Tab 切换, s 设为活跃)",
4170 Style::default().fg(Color::Rgb(80, 80, 100)),
4171 ));
4172 lines.push(Line::from(tab_spans));
4173 } else {
4174 lines.push(Line::from(Span::styled(
4175 " (无 Provider,按 a 新增)",
4176 Style::default().fg(Color::Rgb(180, 120, 80)),
4177 )));
4178 }
4179 lines.push(Line::from(""));
4180
4181 lines.push(Line::from(Span::styled(
4183 " ─────────────────────────────────────────",
4184 Style::default().fg(Color::Rgb(50, 55, 70)),
4185 )));
4186 lines.push(Line::from(""));
4187
4188 if provider_count > 0 {
4190 lines.push(Line::from(Span::styled(
4191 " 📦 Provider 配置",
4192 Style::default()
4193 .fg(Color::Rgb(160, 220, 160))
4194 .add_modifier(Modifier::BOLD),
4195 )));
4196 lines.push(Line::from(""));
4197
4198 for i in 0..total_provider_fields {
4199 let is_selected = app.config_field_idx == i;
4200 let label = config_field_label(i);
4201 let value = if app.config_editing && is_selected {
4202 app.config_edit_buf.clone()
4204 } else {
4205 config_field_value(app, i)
4206 };
4207
4208 let pointer = if is_selected { " ▸ " } else { " " };
4209 let pointer_style = if is_selected {
4210 Style::default().fg(Color::Rgb(255, 200, 80))
4211 } else {
4212 Style::default()
4213 };
4214
4215 let label_style = if is_selected {
4216 Style::default()
4217 .fg(Color::Rgb(230, 210, 120))
4218 .add_modifier(Modifier::BOLD)
4219 } else {
4220 Style::default().fg(Color::Rgb(140, 140, 160))
4221 };
4222
4223 let value_style = if app.config_editing && is_selected {
4224 Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
4225 } else if is_selected {
4226 Style::default().fg(Color::White)
4227 } else {
4228 if CONFIG_FIELDS[i] == "api_key" {
4230 Style::default().fg(Color::Rgb(100, 100, 120))
4231 } else {
4232 Style::default().fg(Color::Rgb(180, 180, 200))
4233 }
4234 };
4235
4236 let edit_indicator = if app.config_editing && is_selected {
4237 " ✏️"
4238 } else {
4239 ""
4240 };
4241
4242 lines.push(Line::from(vec![
4243 Span::styled(pointer, pointer_style),
4244 Span::styled(format!("{:<10}", label), label_style),
4245 Span::styled(" ", Style::default()),
4246 Span::styled(
4247 if value.is_empty() {
4248 "(空)".to_string()
4249 } else {
4250 value
4251 },
4252 value_style,
4253 ),
4254 Span::styled(edit_indicator, Style::default()),
4255 ]));
4256 }
4257 }
4258
4259 lines.push(Line::from(""));
4260 lines.push(Line::from(Span::styled(
4262 " ─────────────────────────────────────────",
4263 Style::default().fg(Color::Rgb(50, 55, 70)),
4264 )));
4265 lines.push(Line::from(""));
4266
4267 lines.push(Line::from(Span::styled(
4269 " 🌐 全局配置",
4270 Style::default()
4271 .fg(Color::Rgb(160, 220, 160))
4272 .add_modifier(Modifier::BOLD),
4273 )));
4274 lines.push(Line::from(""));
4275
4276 for i in 0..CONFIG_GLOBAL_FIELDS.len() {
4277 let field_idx = total_provider_fields + i;
4278 let is_selected = app.config_field_idx == field_idx;
4279 let label = config_field_label(field_idx);
4280 let value = if app.config_editing && is_selected {
4281 app.config_edit_buf.clone()
4282 } else {
4283 config_field_value(app, field_idx)
4284 };
4285
4286 let pointer = if is_selected { " ▸ " } else { " " };
4287 let pointer_style = if is_selected {
4288 Style::default().fg(Color::Rgb(255, 200, 80))
4289 } else {
4290 Style::default()
4291 };
4292
4293 let label_style = if is_selected {
4294 Style::default()
4295 .fg(Color::Rgb(230, 210, 120))
4296 .add_modifier(Modifier::BOLD)
4297 } else {
4298 Style::default().fg(Color::Rgb(140, 140, 160))
4299 };
4300
4301 let value_style = if app.config_editing && is_selected {
4302 Style::default().fg(Color::White).bg(Color::Rgb(50, 55, 80))
4303 } else if is_selected {
4304 Style::default().fg(Color::White)
4305 } else {
4306 Style::default().fg(Color::Rgb(180, 180, 200))
4307 };
4308
4309 let edit_indicator = if app.config_editing && is_selected {
4310 " ✏️"
4311 } else {
4312 ""
4313 };
4314
4315 if CONFIG_GLOBAL_FIELDS[i] == "stream_mode" {
4317 let toggle_on = app.agent_config.stream_mode;
4318 let toggle_style = if toggle_on {
4319 Style::default()
4320 .fg(Color::Rgb(120, 220, 160))
4321 .add_modifier(Modifier::BOLD)
4322 } else {
4323 Style::default().fg(Color::Rgb(200, 100, 100))
4324 };
4325 let toggle_text = if toggle_on {
4326 "● 开启"
4327 } else {
4328 "○ 关闭"
4329 };
4330
4331 lines.push(Line::from(vec![
4332 Span::styled(pointer, pointer_style),
4333 Span::styled(format!("{:<10}", label), label_style),
4334 Span::styled(" ", Style::default()),
4335 Span::styled(toggle_text, toggle_style),
4336 Span::styled(
4337 if is_selected { " (Enter 切换)" } else { "" },
4338 Style::default().fg(Color::Rgb(80, 80, 100)),
4339 ),
4340 ]));
4341 } else {
4342 lines.push(Line::from(vec![
4343 Span::styled(pointer, pointer_style),
4344 Span::styled(format!("{:<10}", label), label_style),
4345 Span::styled(" ", Style::default()),
4346 Span::styled(
4347 if value.is_empty() {
4348 "(空)".to_string()
4349 } else {
4350 value
4351 },
4352 value_style,
4353 ),
4354 Span::styled(edit_indicator, Style::default()),
4355 ]));
4356 }
4357 }
4358
4359 lines.push(Line::from(""));
4360 lines.push(Line::from(""));
4361
4362 lines.push(Line::from(Span::styled(
4364 " ─────────────────────────────────────────",
4365 Style::default().fg(Color::Rgb(50, 55, 70)),
4366 )));
4367 lines.push(Line::from(""));
4368 lines.push(Line::from(vec![
4369 Span::styled(" ", Style::default()),
4370 Span::styled(
4371 "↑↓/jk",
4372 Style::default()
4373 .fg(Color::Rgb(230, 210, 120))
4374 .add_modifier(Modifier::BOLD),
4375 ),
4376 Span::styled(
4377 " 切换字段 ",
4378 Style::default().fg(Color::Rgb(120, 120, 150)),
4379 ),
4380 Span::styled(
4381 "Enter",
4382 Style::default()
4383 .fg(Color::Rgb(230, 210, 120))
4384 .add_modifier(Modifier::BOLD),
4385 ),
4386 Span::styled(" 编辑 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4387 Span::styled(
4388 "Tab/←→",
4389 Style::default()
4390 .fg(Color::Rgb(230, 210, 120))
4391 .add_modifier(Modifier::BOLD),
4392 ),
4393 Span::styled(
4394 " 切换 Provider ",
4395 Style::default().fg(Color::Rgb(120, 120, 150)),
4396 ),
4397 Span::styled(
4398 "a",
4399 Style::default()
4400 .fg(Color::Rgb(230, 210, 120))
4401 .add_modifier(Modifier::BOLD),
4402 ),
4403 Span::styled(" 新增 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4404 Span::styled(
4405 "d",
4406 Style::default()
4407 .fg(Color::Rgb(230, 210, 120))
4408 .add_modifier(Modifier::BOLD),
4409 ),
4410 Span::styled(" 删除 ", Style::default().fg(Color::Rgb(120, 120, 150))),
4411 Span::styled(
4412 "s",
4413 Style::default()
4414 .fg(Color::Rgb(230, 210, 120))
4415 .add_modifier(Modifier::BOLD),
4416 ),
4417 Span::styled(
4418 " 设为活跃 ",
4419 Style::default().fg(Color::Rgb(120, 120, 150)),
4420 ),
4421 Span::styled(
4422 "Esc",
4423 Style::default()
4424 .fg(Color::Rgb(230, 210, 120))
4425 .add_modifier(Modifier::BOLD),
4426 ),
4427 Span::styled(" 保存返回", Style::default().fg(Color::Rgb(120, 120, 150))),
4428 ]));
4429
4430 let content = Paragraph::new(lines)
4431 .block(
4432 Block::default()
4433 .borders(Borders::ALL)
4434 .border_type(ratatui::widgets::BorderType::Rounded)
4435 .border_style(Style::default().fg(Color::Rgb(80, 80, 110)))
4436 .title(Span::styled(
4437 " ⚙️ 模型配置编辑 ",
4438 Style::default()
4439 .fg(Color::Rgb(230, 210, 120))
4440 .add_modifier(Modifier::BOLD),
4441 ))
4442 .style(Style::default().bg(bg)),
4443 )
4444 .scroll((0, 0));
4445 f.render_widget(content, area);
4446}
4447
4448fn handle_select_model(app: &mut ChatApp, key: KeyEvent) {
4450 let count = app.agent_config.providers.len();
4451 match key.code {
4452 KeyCode::Esc => {
4453 app.mode = ChatMode::Chat;
4454 }
4455 KeyCode::Up | KeyCode::Char('k') => {
4456 if count > 0 {
4457 let i = app
4458 .model_list_state
4459 .selected()
4460 .map(|i| if i == 0 { count - 1 } else { i - 1 })
4461 .unwrap_or(0);
4462 app.model_list_state.select(Some(i));
4463 }
4464 }
4465 KeyCode::Down | KeyCode::Char('j') => {
4466 if count > 0 {
4467 let i = app
4468 .model_list_state
4469 .selected()
4470 .map(|i| if i >= count - 1 { 0 } else { i + 1 })
4471 .unwrap_or(0);
4472 app.model_list_state.select(Some(i));
4473 }
4474 }
4475 KeyCode::Enter => {
4476 app.switch_model();
4477 }
4478 _ => {}
4479 }
4480}
4481
4482fn copy_to_clipboard(content: &str) -> bool {
4484 use std::process::{Command, Stdio};
4485
4486 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
4487 ("pbcopy", vec![])
4488 } else if cfg!(target_os = "linux") {
4489 if Command::new("which")
4490 .arg("xclip")
4491 .output()
4492 .map(|o| o.status.success())
4493 .unwrap_or(false)
4494 {
4495 ("xclip", vec!["-selection", "clipboard"])
4496 } else {
4497 ("xsel", vec!["--clipboard", "--input"])
4498 }
4499 } else {
4500 return false;
4501 };
4502
4503 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
4504
4505 match child {
4506 Ok(mut child) => {
4507 if let Some(ref mut stdin) = child.stdin {
4508 let _ = stdin.write_all(content.as_bytes());
4509 }
4510 child.wait().map(|s| s.success()).unwrap_or(false)
4511 }
4512 Err(_) => false,
4513 }
4514}