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