1use crate::config::YamlConfig;
2use crate::{error, info};
3use async_openai::{
4 Client,
5 config::OpenAIConfig,
6 types::chat::{
7 ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
8 ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs,
9 CreateChatCompletionRequestArgs,
10 },
11};
12use crossterm::{
13 event::{
14 self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
15 MouseEventKind,
16 },
17 execute,
18 terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
19};
20use futures::StreamExt;
21use ratatui::{
22 Terminal,
23 backend::CrosstermBackend,
24 layout::{Constraint, Direction, Layout, Rect},
25 style::{Color, Modifier, Style},
26 text::{Line, Span},
27 widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
28};
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::io::{self, Write};
32use std::path::PathBuf;
33use std::sync::{Arc, Mutex, mpsc};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ModelProvider {
40 pub name: String,
42 pub api_base: String,
44 pub api_key: String,
46 pub model: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct AgentConfig {
53 #[serde(default)]
55 pub providers: Vec<ModelProvider>,
56 #[serde(default)]
58 pub active_index: usize,
59 #[serde(default)]
61 pub system_prompt: Option<String>,
62 #[serde(default = "default_stream_mode")]
64 pub stream_mode: bool,
65}
66
67fn default_stream_mode() -> bool {
69 true
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct ChatMessage {
75 pub role: String, pub content: String,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct ChatSession {
82 pub messages: Vec<ChatMessage>,
83}
84
85fn agent_data_dir() -> PathBuf {
89 let dir = YamlConfig::data_dir().join("agent").join("data");
90 let _ = fs::create_dir_all(&dir);
91 dir
92}
93
94fn agent_config_path() -> PathBuf {
96 agent_data_dir().join("agent_config.json")
97}
98
99fn chat_history_path() -> PathBuf {
101 agent_data_dir().join("chat_history.json")
102}
103
104fn load_agent_config() -> AgentConfig {
108 let path = agent_config_path();
109 if !path.exists() {
110 return AgentConfig::default();
111 }
112 match fs::read_to_string(&path) {
113 Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
114 error!("❌ 解析 agent_config.json 失败: {}", e);
115 AgentConfig::default()
116 }),
117 Err(e) => {
118 error!("❌ 读取 agent_config.json 失败: {}", e);
119 AgentConfig::default()
120 }
121 }
122}
123
124fn save_agent_config(config: &AgentConfig) -> bool {
126 let path = agent_config_path();
127 if let Some(parent) = path.parent() {
128 let _ = fs::create_dir_all(parent);
129 }
130 match serde_json::to_string_pretty(config) {
131 Ok(json) => match fs::write(&path, json) {
132 Ok(_) => true,
133 Err(e) => {
134 error!("❌ 保存 agent_config.json 失败: {}", e);
135 false
136 }
137 },
138 Err(e) => {
139 error!("❌ 序列化 agent 配置失败: {}", e);
140 false
141 }
142 }
143}
144
145fn load_chat_session() -> ChatSession {
147 let path = chat_history_path();
148 if !path.exists() {
149 return ChatSession::default();
150 }
151 match fs::read_to_string(&path) {
152 Ok(content) => serde_json::from_str(&content).unwrap_or_else(|_| ChatSession::default()),
153 Err(_) => ChatSession::default(),
154 }
155}
156
157fn save_chat_session(session: &ChatSession) -> bool {
159 let path = chat_history_path();
160 if let Some(parent) = path.parent() {
161 let _ = fs::create_dir_all(parent);
162 }
163 match serde_json::to_string_pretty(session) {
164 Ok(json) => fs::write(&path, json).is_ok(),
165 Err(_) => false,
166 }
167}
168
169fn create_openai_client(provider: &ModelProvider) -> Client<OpenAIConfig> {
173 let config = OpenAIConfig::new()
174 .with_api_key(&provider.api_key)
175 .with_api_base(&provider.api_base);
176 Client::with_config(config)
177}
178
179fn to_openai_messages(messages: &[ChatMessage]) -> Vec<ChatCompletionRequestMessage> {
181 messages
182 .iter()
183 .filter_map(|msg| match msg.role.as_str() {
184 "system" => ChatCompletionRequestSystemMessageArgs::default()
185 .content(msg.content.as_str())
186 .build()
187 .ok()
188 .map(ChatCompletionRequestMessage::System),
189 "user" => ChatCompletionRequestUserMessageArgs::default()
190 .content(msg.content.as_str())
191 .build()
192 .ok()
193 .map(ChatCompletionRequestMessage::User),
194 "assistant" => ChatCompletionRequestAssistantMessageArgs::default()
195 .content(msg.content.as_str())
196 .build()
197 .ok()
198 .map(ChatCompletionRequestMessage::Assistant),
199 _ => None,
200 })
201 .collect()
202}
203
204async fn call_openai_stream_async(
207 provider: &ModelProvider,
208 messages: &[ChatMessage],
209 on_chunk: &mut dyn FnMut(&str),
210) -> Result<String, String> {
211 let client = create_openai_client(provider);
212 let openai_messages = to_openai_messages(messages);
213
214 let request = CreateChatCompletionRequestArgs::default()
215 .model(&provider.model)
216 .messages(openai_messages)
217 .build()
218 .map_err(|e| format!("构建请求失败: {}", e))?;
219
220 let mut stream = client
221 .chat()
222 .create_stream(request)
223 .await
224 .map_err(|e| format!("API 请求失败: {}", e))?;
225
226 let mut full_content = String::new();
227
228 while let Some(result) = stream.next().await {
229 match result {
230 Ok(response) => {
231 for choice in &response.choices {
232 if let Some(ref content) = choice.delta.content {
233 full_content.push_str(content);
234 on_chunk(content);
235 }
236 }
237 }
238 Err(e) => {
239 return Err(format!("流式响应错误: {}", e));
240 }
241 }
242 }
243
244 Ok(full_content)
245}
246
247fn call_openai_stream(
249 provider: &ModelProvider,
250 messages: &[ChatMessage],
251 on_chunk: &mut dyn FnMut(&str),
252) -> Result<String, String> {
253 let rt = tokio::runtime::Runtime::new().map_err(|e| format!("创建异步运行时失败: {}", e))?;
254 rt.block_on(call_openai_stream_async(provider, messages, on_chunk))
255}
256
257pub fn handle_chat(content: &[String], _config: &YamlConfig) {
261 let agent_config = load_agent_config();
262
263 if agent_config.providers.is_empty() {
264 info!("⚠️ 尚未配置 LLM 模型提供方。");
265 info!("📁 请编辑配置文件: {}", agent_config_path().display());
266 info!("📝 配置示例:");
267 let example = AgentConfig {
268 providers: vec![ModelProvider {
269 name: "GPT-4o".to_string(),
270 api_base: "https://api.openai.com/v1".to_string(),
271 api_key: "sk-your-api-key".to_string(),
272 model: "gpt-4o".to_string(),
273 }],
274 active_index: 0,
275 system_prompt: Some("你是一个有用的助手。".to_string()),
276 stream_mode: true,
277 };
278 if let Ok(json) = serde_json::to_string_pretty(&example) {
279 println!("{}", json);
280 }
281 if !agent_config_path().exists() {
283 let _ = save_agent_config(&example);
284 info!(
285 "✅ 已自动创建示例配置文件: {}",
286 agent_config_path().display()
287 );
288 info!("📌 请修改其中的 api_key 和其他配置后重新运行 chat 命令");
289 }
290 return;
291 }
292
293 if content.is_empty() {
294 run_chat_tui();
296 return;
297 }
298
299 let message = content.join(" ");
301 let message = message.trim().to_string();
302 if message.is_empty() {
303 error!("⚠️ 消息内容为空");
304 return;
305 }
306
307 let idx = agent_config
308 .active_index
309 .min(agent_config.providers.len() - 1);
310 let provider = &agent_config.providers[idx];
311
312 info!("🤖 [{}] 思考中...", provider.name);
313
314 let mut messages = Vec::new();
315 if let Some(sys) = &agent_config.system_prompt {
316 messages.push(ChatMessage {
317 role: "system".to_string(),
318 content: sys.clone(),
319 });
320 }
321 messages.push(ChatMessage {
322 role: "user".to_string(),
323 content: message,
324 });
325
326 match call_openai_stream(provider, &messages, &mut |chunk| {
327 print!("{}", chunk);
328 let _ = io::stdout().flush();
329 }) {
330 Ok(_) => {
331 println!(); }
333 Err(e) => {
334 error!("\n❌ {}", e);
335 }
336 }
337}
338
339enum StreamMsg {
343 Chunk,
345 Done,
347 Error(String),
349}
350
351struct ChatApp {
353 agent_config: AgentConfig,
355 session: ChatSession,
357 input: String,
359 cursor_pos: usize,
361 mode: ChatMode,
363 scroll_offset: u16,
365 is_loading: bool,
367 model_list_state: ListState,
369 toast: Option<(String, bool, std::time::Instant)>,
371 stream_rx: Option<mpsc::Receiver<StreamMsg>>,
373 streaming_content: Arc<Mutex<String>>,
375 msg_lines_cache: Option<MsgLinesCache>,
378 browse_msg_index: usize,
380}
381
382struct MsgLinesCache {
384 msg_count: usize,
386 last_msg_len: usize,
388 streaming_len: usize,
390 is_loading: bool,
392 bubble_max_width: usize,
394 browse_index: Option<usize>,
396 lines: Vec<Line<'static>>,
398 msg_start_lines: Vec<(usize, usize)>, }
401
402const TOAST_DURATION_SECS: u64 = 4;
404
405#[derive(PartialEq)]
406enum ChatMode {
407 Chat,
409 SelectModel,
411 Browse,
413 Help,
415}
416
417impl ChatApp {
418 fn new() -> Self {
419 let agent_config = load_agent_config();
420 let session = load_chat_session();
421 let mut model_list_state = ListState::default();
422 if !agent_config.providers.is_empty() {
423 model_list_state.select(Some(agent_config.active_index));
424 }
425 Self {
426 agent_config,
427 session,
428 input: String::new(),
429 cursor_pos: 0,
430 mode: ChatMode::Chat,
431 scroll_offset: u16::MAX, is_loading: false,
433 model_list_state,
434 toast: None,
435 stream_rx: None,
436 streaming_content: Arc::new(Mutex::new(String::new())),
437 msg_lines_cache: None,
438 browse_msg_index: 0,
439 }
440 }
441
442 fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
444 self.toast = Some((msg.into(), is_error, std::time::Instant::now()));
445 }
446
447 fn tick_toast(&mut self) {
449 if let Some((_, _, created)) = &self.toast {
450 if created.elapsed().as_secs() >= TOAST_DURATION_SECS {
451 self.toast = None;
452 }
453 }
454 }
455
456 fn active_provider(&self) -> Option<&ModelProvider> {
458 if self.agent_config.providers.is_empty() {
459 return None;
460 }
461 let idx = self
462 .agent_config
463 .active_index
464 .min(self.agent_config.providers.len() - 1);
465 Some(&self.agent_config.providers[idx])
466 }
467
468 fn active_model_name(&self) -> String {
470 self.active_provider()
471 .map(|p| p.name.clone())
472 .unwrap_or_else(|| "未配置".to_string())
473 }
474
475 fn build_api_messages(&self) -> Vec<ChatMessage> {
477 let mut messages = Vec::new();
478 if let Some(sys) = &self.agent_config.system_prompt {
479 messages.push(ChatMessage {
480 role: "system".to_string(),
481 content: sys.clone(),
482 });
483 }
484 for msg in &self.session.messages {
485 messages.push(msg.clone());
486 }
487 messages
488 }
489
490 fn send_message(&mut self) {
492 let text = self.input.trim().to_string();
493 if text.is_empty() {
494 return;
495 }
496
497 self.session.messages.push(ChatMessage {
499 role: "user".to_string(),
500 content: text,
501 });
502 self.input.clear();
503 self.cursor_pos = 0;
504 self.scroll_offset = u16::MAX;
506
507 let provider = match self.active_provider() {
509 Some(p) => p.clone(),
510 None => {
511 self.show_toast("未配置模型提供方,请先编辑配置文件", true);
512 return;
513 }
514 };
515
516 self.is_loading = true;
517
518 let api_messages = self.build_api_messages();
519
520 {
522 let mut sc = self.streaming_content.lock().unwrap();
523 sc.clear();
524 }
525
526 let (tx, rx) = mpsc::channel::<StreamMsg>();
528 self.stream_rx = Some(rx);
529
530 let streaming_content = Arc::clone(&self.streaming_content);
531
532 let use_stream = self.agent_config.stream_mode;
533
534 std::thread::spawn(move || {
536 let rt = match tokio::runtime::Runtime::new() {
537 Ok(rt) => rt,
538 Err(e) => {
539 let _ = tx.send(StreamMsg::Error(format!("创建异步运行时失败: {}", e)));
540 return;
541 }
542 };
543
544 rt.block_on(async {
545 let client = create_openai_client(&provider);
546 let openai_messages = to_openai_messages(&api_messages);
547
548 let request = match CreateChatCompletionRequestArgs::default()
549 .model(&provider.model)
550 .messages(openai_messages)
551 .build()
552 {
553 Ok(req) => req,
554 Err(e) => {
555 let _ = tx.send(StreamMsg::Error(format!("构建请求失败: {}", e)));
556 return;
557 }
558 };
559
560 if use_stream {
561 let mut stream = match client.chat().create_stream(request).await {
563 Ok(s) => s,
564 Err(e) => {
565 let _ = tx.send(StreamMsg::Error(format!("API 请求失败: {}", e)));
566 return;
567 }
568 };
569
570 while let Some(result) = stream.next().await {
571 match result {
572 Ok(response) => {
573 for choice in &response.choices {
574 if let Some(ref content) = choice.delta.content {
575 {
577 let mut sc = streaming_content.lock().unwrap();
578 sc.push_str(content);
579 }
580 let _ = tx.send(StreamMsg::Chunk);
581 }
582 }
583 }
584 Err(e) => {
585 let _ = tx.send(StreamMsg::Error(format!("流式响应错误: {}", e)));
586 return;
587 }
588 }
589 }
590 } else {
591 match client.chat().create(request).await {
593 Ok(response) => {
594 if let Some(choice) = response.choices.first() {
595 if let Some(ref content) = choice.message.content {
596 {
597 let mut sc = streaming_content.lock().unwrap();
598 sc.push_str(content);
599 }
600 let _ = tx.send(StreamMsg::Chunk);
601 }
602 }
603 }
604 Err(e) => {
605 let _ = tx.send(StreamMsg::Error(format!("API 请求失败: {}", e)));
606 return;
607 }
608 }
609 }
610
611 let _ = tx.send(StreamMsg::Done);
612
613 let _ = tx.send(StreamMsg::Done);
614 });
615 });
616 }
617
618 fn poll_stream(&mut self) {
620 if self.stream_rx.is_none() {
621 return;
622 }
623
624 let mut finished = false;
625 let mut had_error = false;
626
627 if let Some(ref rx) = self.stream_rx {
629 loop {
630 match rx.try_recv() {
631 Ok(StreamMsg::Chunk) => {
632 self.scroll_offset = u16::MAX;
634 }
635 Ok(StreamMsg::Done) => {
636 finished = true;
637 break;
638 }
639 Ok(StreamMsg::Error(e)) => {
640 self.show_toast(format!("请求失败: {}", e), true);
641 had_error = true;
642 finished = true;
643 break;
644 }
645 Err(mpsc::TryRecvError::Empty) => break,
646 Err(mpsc::TryRecvError::Disconnected) => {
647 finished = true;
648 break;
649 }
650 }
651 }
652 }
653
654 if finished {
655 self.stream_rx = None;
656 self.is_loading = false;
657
658 if !had_error {
659 let content = {
661 let sc = self.streaming_content.lock().unwrap();
662 sc.clone()
663 };
664 if !content.is_empty() {
665 self.session.messages.push(ChatMessage {
666 role: "assistant".to_string(),
667 content,
668 });
669 self.streaming_content.lock().unwrap().clear();
671 self.show_toast("回复完成 ✓", false);
672 }
673 self.scroll_offset = u16::MAX;
674 } else {
675 self.streaming_content.lock().unwrap().clear();
677 }
678
679 let _ = save_chat_session(&self.session);
681 }
682 }
683
684 fn clear_session(&mut self) {
686 self.session.messages.clear();
687 self.scroll_offset = 0;
688 let _ = save_chat_session(&self.session);
689 self.show_toast("对话已清空", false);
690 }
691
692 fn switch_model(&mut self) {
694 if let Some(sel) = self.model_list_state.selected() {
695 self.agent_config.active_index = sel;
696 let _ = save_agent_config(&self.agent_config);
697 let name = self.active_model_name();
698 self.show_toast(format!("已切换到: {}", name), false);
699 }
700 self.mode = ChatMode::Chat;
701 }
702
703 fn scroll_up(&mut self) {
705 self.scroll_offset = self.scroll_offset.saturating_sub(3);
706 }
707
708 fn scroll_down(&mut self) {
710 self.scroll_offset = self.scroll_offset.saturating_add(3);
711 }
712}
713
714fn run_chat_tui() {
716 match run_chat_tui_internal() {
717 Ok(_) => {}
718 Err(e) => {
719 error!("❌ Chat TUI 启动失败: {}", e);
720 }
721 }
722}
723
724fn run_chat_tui_internal() -> io::Result<()> {
725 terminal::enable_raw_mode()?;
726 let mut stdout = io::stdout();
727 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
728
729 let backend = CrosstermBackend::new(stdout);
730 let mut terminal = Terminal::new(backend)?;
731
732 let mut app = ChatApp::new();
733
734 if app.agent_config.providers.is_empty() {
735 terminal::disable_raw_mode()?;
736 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
737 info!("⚠️ 尚未配置 LLM 模型提供方,请先运行 j chat 查看配置说明。");
738 return Ok(());
739 }
740
741 let mut needs_redraw = true; loop {
744 let had_toast = app.toast.is_some();
746 app.tick_toast();
747 if had_toast && app.toast.is_none() {
748 needs_redraw = true;
749 }
750
751 let was_loading = app.is_loading;
753 app.poll_stream();
754 if app.is_loading || (was_loading && !app.is_loading) {
756 needs_redraw = true;
757 }
758
759 if needs_redraw {
761 terminal.draw(|f| draw_chat_ui(f, &mut app))?;
762 needs_redraw = false;
763 }
764
765 let poll_timeout = if app.is_loading {
767 std::time::Duration::from_millis(150)
768 } else {
769 std::time::Duration::from_millis(1000)
770 };
771
772 if event::poll(poll_timeout)? {
773 let mut should_break = false;
775 loop {
776 let evt = event::read()?;
777 match evt {
778 Event::Key(key) => {
779 needs_redraw = true;
780 match app.mode {
781 ChatMode::Chat => {
782 if handle_chat_mode(&mut app, key) {
783 should_break = true;
784 break;
785 }
786 }
787 ChatMode::SelectModel => handle_select_model(&mut app, key),
788 ChatMode::Browse => handle_browse_mode(&mut app, key),
789 ChatMode::Help => {
790 app.mode = ChatMode::Chat;
791 }
792 }
793 }
794 Event::Mouse(mouse) => match mouse.kind {
795 MouseEventKind::ScrollUp => {
796 app.scroll_up();
797 needs_redraw = true;
798 }
799 MouseEventKind::ScrollDown => {
800 app.scroll_down();
801 needs_redraw = true;
802 }
803 _ => {}
804 },
805 Event::Resize(_, _) => {
806 needs_redraw = true;
807 }
808 _ => {}
809 }
810 if !event::poll(std::time::Duration::ZERO)? {
812 break;
813 }
814 }
815 if should_break {
816 break;
817 }
818 }
819 }
820
821 let _ = save_chat_session(&app.session);
823
824 terminal::disable_raw_mode()?;
825 execute!(
826 terminal.backend_mut(),
827 LeaveAlternateScreen,
828 DisableMouseCapture
829 )?;
830 Ok(())
831}
832
833fn draw_chat_ui(f: &mut ratatui::Frame, app: &mut ChatApp) {
835 let size = f.area();
836
837 let bg = Block::default().style(Style::default().bg(Color::Rgb(22, 22, 30)));
839 f.render_widget(bg, size);
840
841 let chunks = Layout::default()
842 .direction(Direction::Vertical)
843 .constraints([
844 Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), Constraint::Length(1), ])
849 .split(size);
850
851 draw_title_bar(f, chunks[0], app);
853
854 if app.mode == ChatMode::Help {
856 draw_help(f, chunks[1]);
857 } else if app.mode == ChatMode::SelectModel {
858 draw_model_selector(f, chunks[1], app);
859 } else {
860 draw_messages(f, chunks[1], app);
861 }
862
863 draw_input(f, chunks[2], app);
865
866 draw_hint_bar(f, chunks[3], app);
868
869 draw_toast(f, size, app);
871}
872
873fn draw_title_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
875 let model_name = app.active_model_name();
876 let msg_count = app.session.messages.len();
877 let loading = if app.is_loading {
878 " ⏳ 思考中..."
879 } else {
880 ""
881 };
882
883 let title_spans = vec![
884 Span::styled(" 💬 ", Style::default().fg(Color::Rgb(120, 180, 255))),
885 Span::styled(
886 "AI Chat",
887 Style::default()
888 .fg(Color::White)
889 .add_modifier(Modifier::BOLD),
890 ),
891 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 60, 80))),
892 Span::styled("🤖 ", Style::default()),
893 Span::styled(
894 model_name,
895 Style::default()
896 .fg(Color::Rgb(160, 220, 160))
897 .add_modifier(Modifier::BOLD),
898 ),
899 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 60, 80))),
900 Span::styled(
901 format!("📨 {} 条消息", msg_count),
902 Style::default().fg(Color::Rgb(180, 180, 200)),
903 ),
904 Span::styled(
905 loading,
906 Style::default()
907 .fg(Color::Rgb(255, 200, 80))
908 .add_modifier(Modifier::BOLD),
909 ),
910 ];
911
912 let title_block = Paragraph::new(Line::from(title_spans)).block(
913 Block::default()
914 .borders(Borders::ALL)
915 .border_type(ratatui::widgets::BorderType::Rounded)
916 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
917 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
918 );
919 f.render_widget(title_block, area);
920}
921
922fn draw_messages(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
924 let block = Block::default()
925 .borders(Borders::ALL)
926 .border_type(ratatui::widgets::BorderType::Rounded)
927 .border_style(Style::default().fg(Color::Rgb(50, 55, 70)))
928 .title(Span::styled(
929 " 对话记录 ",
930 Style::default()
931 .fg(Color::Rgb(140, 140, 170))
932 .add_modifier(Modifier::BOLD),
933 ))
934 .title_alignment(ratatui::layout::Alignment::Left)
935 .style(Style::default().bg(Color::Rgb(22, 22, 30)));
936
937 if app.session.messages.is_empty() && !app.is_loading {
939 let welcome_lines = vec![
940 Line::from(""),
941 Line::from(""),
942 Line::from(Span::styled(
943 " ╭──────────────────────────────────────╮",
944 Style::default().fg(Color::Rgb(60, 70, 90)),
945 )),
946 Line::from(Span::styled(
947 " │ │",
948 Style::default().fg(Color::Rgb(60, 70, 90)),
949 )),
950 Line::from(vec![
951 Span::styled(" │ ", Style::default().fg(Color::Rgb(60, 70, 90))),
952 Span::styled(
953 "Hi! What can I help you? ",
954 Style::default().fg(Color::Rgb(120, 140, 180)),
955 ),
956 Span::styled(" │", Style::default().fg(Color::Rgb(60, 70, 90))),
957 ]),
958 Line::from(Span::styled(
959 " │ │",
960 Style::default().fg(Color::Rgb(60, 70, 90)),
961 )),
962 Line::from(Span::styled(
963 " │ Type a message, press Enter │",
964 Style::default().fg(Color::Rgb(80, 90, 110)),
965 )),
966 Line::from(Span::styled(
967 " │ │",
968 Style::default().fg(Color::Rgb(60, 70, 90)),
969 )),
970 Line::from(Span::styled(
971 " ╰──────────────────────────────────────╯",
972 Style::default().fg(Color::Rgb(60, 70, 90)),
973 )),
974 ];
975 let empty = Paragraph::new(welcome_lines).block(block);
976 f.render_widget(empty, area);
977 return;
978 }
979
980 let inner_width = area.width.saturating_sub(4) as usize;
982 let bubble_max_width = (inner_width * 75 / 100).max(20);
984
985 let msg_count = app.session.messages.len();
987 let last_msg_len = app
988 .session
989 .messages
990 .last()
991 .map(|m| m.content.len())
992 .unwrap_or(0);
993 let streaming_len = app.streaming_content.lock().unwrap().len();
994 let current_browse_index = if app.mode == ChatMode::Browse {
995 Some(app.browse_msg_index)
996 } else {
997 None
998 };
999 let cache_hit = if let Some(ref cache) = app.msg_lines_cache {
1000 cache.msg_count == msg_count
1001 && cache.last_msg_len == last_msg_len
1002 && cache.streaming_len == streaming_len
1003 && cache.is_loading == app.is_loading
1004 && cache.bubble_max_width == bubble_max_width
1005 && cache.browse_index == current_browse_index
1006 } else {
1007 false
1008 };
1009
1010 if !cache_hit {
1011 let (new_lines, new_msg_start_lines) =
1013 build_message_lines(app, inner_width, bubble_max_width);
1014 app.msg_lines_cache = Some(MsgLinesCache {
1015 msg_count,
1016 last_msg_len,
1017 streaming_len,
1018 is_loading: app.is_loading,
1019 bubble_max_width,
1020 browse_index: current_browse_index,
1021 lines: new_lines,
1022 msg_start_lines: new_msg_start_lines,
1023 });
1024 }
1025
1026 let cached = app.msg_lines_cache.as_ref().unwrap();
1028 let all_lines = &cached.lines;
1029 let total_lines = all_lines.len() as u16;
1030
1031 f.render_widget(block, area);
1033
1034 let inner = area.inner(ratatui::layout::Margin {
1036 vertical: 1,
1037 horizontal: 1,
1038 });
1039 let visible_height = inner.height;
1040 let max_scroll = total_lines.saturating_sub(visible_height);
1041
1042 if app.mode != ChatMode::Browse {
1044 if app.scroll_offset == u16::MAX || app.scroll_offset > max_scroll {
1045 app.scroll_offset = max_scroll;
1046 }
1047 } else {
1048 if let Some(target_line) = cached
1050 .msg_start_lines
1051 .iter()
1052 .find(|(idx, _)| *idx == app.browse_msg_index)
1053 .map(|(_, line)| *line as u16)
1054 {
1055 if target_line < app.scroll_offset {
1057 app.scroll_offset = target_line;
1058 } else if target_line >= app.scroll_offset + visible_height {
1059 app.scroll_offset = target_line.saturating_sub(visible_height / 3);
1060 }
1061 if app.scroll_offset > max_scroll {
1063 app.scroll_offset = max_scroll;
1064 }
1065 }
1066 }
1067
1068 let bg_fill = Block::default().style(Style::default().bg(Color::Rgb(22, 22, 30)));
1070 f.render_widget(bg_fill, inner);
1071
1072 let start = app.scroll_offset as usize;
1074 let end = (start + visible_height as usize).min(all_lines.len());
1075 for (i, line_idx) in (start..end).enumerate() {
1076 let line = &all_lines[line_idx];
1077 let y = inner.y + i as u16;
1078 let line_area = Rect::new(inner.x, y, inner.width, 1);
1079 let p = Paragraph::new(line.clone());
1081 f.render_widget(p, line_area);
1082 }
1083}
1084
1085fn build_message_lines(
1088 app: &ChatApp,
1089 inner_width: usize,
1090 bubble_max_width: usize,
1091) -> (Vec<Line<'static>>, Vec<(usize, usize)>) {
1092 struct RenderMsg {
1093 role: String,
1094 content: String,
1095 msg_index: Option<usize>, }
1097 let mut render_msgs: Vec<RenderMsg> = app
1098 .session
1099 .messages
1100 .iter()
1101 .enumerate()
1102 .map(|(i, m)| RenderMsg {
1103 role: m.role.clone(),
1104 content: m.content.clone(),
1105 msg_index: Some(i),
1106 })
1107 .collect();
1108
1109 if app.is_loading {
1111 let streaming = app.streaming_content.lock().unwrap().clone();
1112 if !streaming.is_empty() {
1113 render_msgs.push(RenderMsg {
1114 role: "assistant".to_string(),
1115 content: streaming,
1116 msg_index: None,
1117 });
1118 } else {
1119 render_msgs.push(RenderMsg {
1121 role: "assistant".to_string(),
1122 content: "◍".to_string(),
1123 msg_index: None,
1124 });
1125 }
1126 }
1127
1128 let is_browse_mode = app.mode == ChatMode::Browse;
1130 let mut lines: Vec<Line> = Vec::new();
1131 let mut msg_start_lines: Vec<(usize, usize)> = Vec::new(); for msg in &render_msgs {
1133 let is_selected = is_browse_mode
1135 && msg.msg_index.is_some()
1136 && msg.msg_index.unwrap() == app.browse_msg_index;
1137
1138 if let Some(idx) = msg.msg_index {
1140 msg_start_lines.push((idx, lines.len()));
1141 }
1142
1143 match msg.role.as_str() {
1144 "user" => {
1145 lines.push(Line::from(""));
1147 let label = if is_selected { "▶ You " } else { "You " };
1149 let pad = inner_width.saturating_sub(display_width(label) + 2);
1150 lines.push(Line::from(vec![
1151 Span::raw(" ".repeat(pad)),
1152 Span::styled(
1153 label,
1154 Style::default()
1155 .fg(if is_selected {
1156 Color::Rgb(255, 200, 80)
1157 } else {
1158 Color::Rgb(100, 160, 255)
1159 })
1160 .add_modifier(Modifier::BOLD),
1161 ),
1162 ]));
1163 let user_bg = if is_selected {
1165 Color::Rgb(55, 85, 140)
1166 } else {
1167 Color::Rgb(40, 70, 120)
1168 };
1169 let user_pad_lr = 3usize; let user_content_w = bubble_max_width.saturating_sub(user_pad_lr * 2);
1171
1172 let mut all_wrapped_lines: Vec<String> = Vec::new();
1174 for content_line in msg.content.lines() {
1175 let wrapped = wrap_text(content_line, user_content_w);
1176 all_wrapped_lines.extend(wrapped);
1177 }
1178 if all_wrapped_lines.is_empty() {
1180 all_wrapped_lines.push(String::new());
1181 }
1182
1183 let actual_content_w = all_wrapped_lines
1185 .iter()
1186 .map(|l| display_width(l))
1187 .max()
1188 .unwrap_or(0);
1189 let actual_bubble_w = (actual_content_w + user_pad_lr * 2)
1190 .min(bubble_max_width)
1191 .max(user_pad_lr * 2 + 1);
1192 let actual_inner_content_w = actual_bubble_w.saturating_sub(user_pad_lr * 2);
1193
1194 {
1196 let bubble_text = " ".repeat(actual_bubble_w);
1197 let pad = inner_width.saturating_sub(actual_bubble_w);
1198 lines.push(Line::from(vec![
1199 Span::raw(" ".repeat(pad)),
1200 Span::styled(bubble_text, Style::default().bg(user_bg)),
1201 ]));
1202 }
1203
1204 for wl in &all_wrapped_lines {
1205 let wl_width = display_width(wl);
1206 let fill = actual_inner_content_w.saturating_sub(wl_width);
1207 let text = format!(
1208 "{}{}{}{}",
1209 " ".repeat(user_pad_lr),
1210 wl,
1211 " ".repeat(fill),
1212 " ".repeat(user_pad_lr),
1213 );
1214 let text_width = display_width(&text);
1215 let pad = inner_width.saturating_sub(text_width);
1216 lines.push(Line::from(vec![
1217 Span::raw(" ".repeat(pad)),
1218 Span::styled(text, Style::default().fg(Color::White).bg(user_bg)),
1219 ]));
1220 }
1221
1222 {
1224 let bubble_text = " ".repeat(actual_bubble_w);
1225 let pad = inner_width.saturating_sub(actual_bubble_w);
1226 lines.push(Line::from(vec![
1227 Span::raw(" ".repeat(pad)),
1228 Span::styled(bubble_text, Style::default().bg(user_bg)),
1229 ]));
1230 }
1231 }
1232 "assistant" => {
1233 lines.push(Line::from(""));
1235 let ai_label = if is_selected { " ▶ AI" } else { " AI" };
1236 lines.push(Line::from(Span::styled(
1237 ai_label,
1238 Style::default()
1239 .fg(if is_selected {
1240 Color::Rgb(255, 200, 80)
1241 } else {
1242 Color::Rgb(120, 220, 160)
1243 })
1244 .add_modifier(Modifier::BOLD),
1245 )));
1246
1247 let bubble_bg = if is_selected {
1249 Color::Rgb(48, 48, 68)
1250 } else {
1251 Color::Rgb(38, 38, 52)
1252 };
1253 let pad_left = " "; let pad_right = " "; let pad_left_w = 3usize;
1256 let pad_right_w = 3usize;
1257 let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
1259 let md_lines = markdown_to_lines(&msg.content, md_content_w + 2); let bubble_total_w = bubble_max_width;
1263
1264 {
1266 let mut top_spans: Vec<Span> = Vec::new();
1267 top_spans.push(Span::styled(
1268 " ".repeat(bubble_total_w),
1269 Style::default().bg(bubble_bg),
1270 ));
1271 lines.push(Line::from(top_spans));
1272 }
1273
1274 for md_line in md_lines {
1275 let mut styled_spans: Vec<Span> = Vec::new();
1277 styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
1278 let mut content_w: usize = 0;
1280 for span in md_line.spans {
1281 let sw = display_width(&span.content);
1282 content_w += sw;
1283 let merged_style = span.style.bg(bubble_bg);
1285 styled_spans.push(Span::styled(span.content.to_string(), merged_style));
1286 }
1287 let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
1289 let fill = target_content_w.saturating_sub(content_w);
1290 if fill > 0 {
1291 styled_spans.push(Span::styled(
1292 " ".repeat(fill),
1293 Style::default().bg(bubble_bg),
1294 ));
1295 }
1296 styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
1297 lines.push(Line::from(styled_spans));
1298 }
1299
1300 {
1302 let mut bottom_spans: Vec<Span> = Vec::new();
1303 bottom_spans.push(Span::styled(
1304 " ".repeat(bubble_total_w),
1305 Style::default().bg(bubble_bg),
1306 ));
1307 lines.push(Line::from(bottom_spans));
1308 }
1309 }
1310 "system" => {
1311 lines.push(Line::from(""));
1313 let wrapped = wrap_text(&msg.content, inner_width.saturating_sub(8));
1314 for wl in wrapped {
1315 lines.push(Line::from(Span::styled(
1316 format!(" {} {}", "sys", wl),
1317 Style::default().fg(Color::Rgb(100, 100, 120)),
1318 )));
1319 }
1320 }
1321 _ => {}
1322 }
1323 }
1324 lines.push(Line::from(""));
1326
1327 (lines, msg_start_lines)
1328}
1329
1330fn markdown_to_lines(md: &str, max_width: usize) -> Vec<Line<'static>> {
1334 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1335
1336 let content_width = max_width.saturating_sub(2);
1338
1339 let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
1340 let parser = Parser::new_ext(md, options);
1341
1342 let mut lines: Vec<Line<'static>> = Vec::new();
1343 let mut current_spans: Vec<Span<'static>> = Vec::new();
1344 let mut style_stack: Vec<Style> = vec![Style::default().fg(Color::Rgb(220, 220, 230))];
1345 let mut in_code_block = false;
1346 let mut code_block_content = String::new();
1347 let mut code_block_lang = String::new();
1348 let mut list_depth: usize = 0;
1349 let mut ordered_index: Option<u64> = None;
1350 let mut heading_level: Option<u8> = None;
1351 let mut in_blockquote = false;
1353 let mut in_table = false;
1355 let mut table_rows: Vec<Vec<String>> = Vec::new(); let mut current_row: Vec<String> = Vec::new();
1357 let mut current_cell = String::new();
1358 let mut table_alignments: Vec<pulldown_cmark::Alignment> = Vec::new();
1359
1360 let base_style = Style::default().fg(Color::Rgb(220, 220, 230));
1361
1362 let flush_line = |current_spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
1363 if !current_spans.is_empty() {
1364 lines.push(Line::from(current_spans.drain(..).collect::<Vec<_>>()));
1365 }
1366 };
1367
1368 for event in parser {
1369 match event {
1370 Event::Start(Tag::Heading { level, .. }) => {
1371 flush_line(&mut current_spans, &mut lines);
1372 heading_level = Some(level as u8);
1373 if !lines.is_empty() {
1374 lines.push(Line::from(""));
1375 }
1376 let heading_style = match level as u8 {
1378 1 => Style::default()
1379 .fg(Color::Rgb(100, 180, 255))
1380 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1381 2 => Style::default()
1382 .fg(Color::Rgb(130, 190, 255))
1383 .add_modifier(Modifier::BOLD),
1384 3 => Style::default()
1385 .fg(Color::Rgb(160, 200, 255))
1386 .add_modifier(Modifier::BOLD),
1387 _ => Style::default()
1388 .fg(Color::Rgb(180, 210, 255))
1389 .add_modifier(Modifier::BOLD),
1390 };
1391 style_stack.push(heading_style);
1392 }
1393 Event::End(TagEnd::Heading(level)) => {
1394 flush_line(&mut current_spans, &mut lines);
1395 if (level as u8) <= 2 {
1397 let sep_char = if (level as u8) == 1 { "━" } else { "─" };
1398 lines.push(Line::from(Span::styled(
1399 sep_char.repeat(content_width),
1400 Style::default().fg(Color::Rgb(60, 70, 100)),
1401 )));
1402 }
1403 style_stack.pop();
1404 heading_level = None;
1405 }
1406 Event::Start(Tag::Strong) => {
1407 let current = *style_stack.last().unwrap_or(&base_style);
1408 style_stack.push(current.add_modifier(Modifier::BOLD));
1409 }
1410 Event::End(TagEnd::Strong) => {
1411 style_stack.pop();
1412 }
1413 Event::Start(Tag::Emphasis) => {
1414 let current = *style_stack.last().unwrap_or(&base_style);
1415 style_stack.push(current.add_modifier(Modifier::ITALIC));
1416 }
1417 Event::End(TagEnd::Emphasis) => {
1418 style_stack.pop();
1419 }
1420 Event::Start(Tag::Strikethrough) => {
1421 let current = *style_stack.last().unwrap_or(&base_style);
1422 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
1423 }
1424 Event::End(TagEnd::Strikethrough) => {
1425 style_stack.pop();
1426 }
1427 Event::Start(Tag::CodeBlock(kind)) => {
1428 flush_line(&mut current_spans, &mut lines);
1429 in_code_block = true;
1430 code_block_content.clear();
1431 code_block_lang = match kind {
1432 CodeBlockKind::Fenced(lang) => lang.to_string(),
1433 CodeBlockKind::Indented => String::new(),
1434 };
1435 let label = if code_block_lang.is_empty() {
1437 " code ".to_string()
1438 } else {
1439 format!(" {} ", code_block_lang)
1440 };
1441 let label_w = display_width(&label);
1442 let border_fill = content_width.saturating_sub(2 + label_w);
1443 let top_border = format!("┌─{}{}", label, "─".repeat(border_fill));
1444 lines.push(Line::from(Span::styled(
1445 top_border,
1446 Style::default().fg(Color::Rgb(80, 90, 110)),
1447 )));
1448 }
1449 Event::End(TagEnd::CodeBlock) => {
1450 let code_inner_w = content_width.saturating_sub(4); for code_line in code_block_content.lines() {
1453 let wrapped = wrap_text(code_line, code_inner_w);
1454 for wl in wrapped {
1455 let highlighted = highlight_code_line(&wl, &code_block_lang);
1456 let text_w: usize =
1457 highlighted.iter().map(|s| display_width(&s.content)).sum();
1458 let fill = code_inner_w.saturating_sub(text_w);
1459 let mut spans_vec = Vec::new();
1460 spans_vec.push(Span::styled(
1461 "│ ",
1462 Style::default().fg(Color::Rgb(80, 90, 110)),
1463 ));
1464 for hs in highlighted {
1465 spans_vec.push(Span::styled(
1466 hs.content.to_string(),
1467 hs.style.bg(Color::Rgb(30, 30, 42)),
1468 ));
1469 }
1470 spans_vec.push(Span::styled(
1471 format!("{} │", " ".repeat(fill)),
1472 Style::default()
1473 .fg(Color::Rgb(80, 90, 110))
1474 .bg(Color::Rgb(30, 30, 42)),
1475 ));
1476 lines.push(Line::from(spans_vec));
1477 }
1478 }
1479 let bottom_border = format!("└{}", "─".repeat(content_width.saturating_sub(1)));
1480 lines.push(Line::from(Span::styled(
1481 bottom_border,
1482 Style::default().fg(Color::Rgb(80, 90, 110)),
1483 )));
1484 in_code_block = false;
1485 code_block_content.clear();
1486 code_block_lang.clear();
1487 }
1488 Event::Code(text) => {
1489 if in_table {
1490 current_cell.push('`');
1492 current_cell.push_str(&text);
1493 current_cell.push('`');
1494 } else {
1495 current_spans.push(Span::styled(
1497 format!(" {} ", text),
1498 Style::default()
1499 .fg(Color::Rgb(230, 190, 120))
1500 .bg(Color::Rgb(45, 45, 60)),
1501 ));
1502 }
1503 }
1504 Event::Start(Tag::List(start)) => {
1505 flush_line(&mut current_spans, &mut lines);
1506 list_depth += 1;
1507 ordered_index = start;
1508 }
1509 Event::End(TagEnd::List(_)) => {
1510 flush_line(&mut current_spans, &mut lines);
1511 list_depth = list_depth.saturating_sub(1);
1512 ordered_index = None;
1513 }
1514 Event::Start(Tag::Item) => {
1515 flush_line(&mut current_spans, &mut lines);
1516 let indent = " ".repeat(list_depth);
1517 let bullet = if let Some(ref mut idx) = ordered_index {
1518 let s = format!("{}{}. ", indent, idx);
1519 *idx += 1;
1520 s
1521 } else {
1522 format!("{}- ", indent)
1523 };
1524 current_spans.push(Span::styled(
1525 bullet,
1526 Style::default().fg(Color::Rgb(160, 180, 220)),
1527 ));
1528 }
1529 Event::End(TagEnd::Item) => {
1530 flush_line(&mut current_spans, &mut lines);
1531 }
1532 Event::Start(Tag::Paragraph) => {
1533 if !lines.is_empty() && !in_code_block && heading_level.is_none() {
1534 let last_empty = lines.last().map(|l| l.spans.is_empty()).unwrap_or(false);
1535 if !last_empty {
1536 lines.push(Line::from(""));
1537 }
1538 }
1539 }
1540 Event::End(TagEnd::Paragraph) => {
1541 flush_line(&mut current_spans, &mut lines);
1542 }
1543 Event::Start(Tag::BlockQuote(_)) => {
1544 flush_line(&mut current_spans, &mut lines);
1545 in_blockquote = true;
1546 style_stack.push(Style::default().fg(Color::Rgb(150, 160, 180)));
1547 }
1548 Event::End(TagEnd::BlockQuote(_)) => {
1549 flush_line(&mut current_spans, &mut lines);
1550 in_blockquote = false;
1551 style_stack.pop();
1552 }
1553 Event::Text(text) => {
1554 if in_code_block {
1555 code_block_content.push_str(&text);
1556 } else if in_table {
1557 current_cell.push_str(&text);
1559 } else {
1560 let style = *style_stack.last().unwrap_or(&base_style);
1561 let text_str = text.to_string();
1562
1563 if let Some(level) = heading_level {
1565 let (prefix, prefix_style) = match level {
1566 1 => (
1567 ">> ",
1568 Style::default()
1569 .fg(Color::Rgb(100, 180, 255))
1570 .add_modifier(Modifier::BOLD),
1571 ),
1572 2 => (
1573 ">> ",
1574 Style::default()
1575 .fg(Color::Rgb(130, 190, 255))
1576 .add_modifier(Modifier::BOLD),
1577 ),
1578 3 => (
1579 "> ",
1580 Style::default()
1581 .fg(Color::Rgb(160, 200, 255))
1582 .add_modifier(Modifier::BOLD),
1583 ),
1584 _ => (
1585 "> ",
1586 Style::default()
1587 .fg(Color::Rgb(180, 210, 255))
1588 .add_modifier(Modifier::BOLD),
1589 ),
1590 };
1591 current_spans.push(Span::styled(prefix.to_string(), prefix_style));
1592 heading_level = None; }
1594
1595 let existing_w: usize = current_spans
1597 .iter()
1598 .map(|s| display_width(&s.content))
1599 .sum();
1600
1601 let effective_prefix_w = if in_blockquote { 2 } else { 0 }; let wrap_w = content_width.saturating_sub(effective_prefix_w + existing_w);
1604
1605 for (i, line) in text_str.split('\n').enumerate() {
1606 if i > 0 {
1607 flush_line(&mut current_spans, &mut lines);
1608 if in_blockquote {
1609 current_spans.push(Span::styled(
1610 "| ".to_string(),
1611 Style::default().fg(Color::Rgb(80, 100, 140)),
1612 ));
1613 }
1614 }
1615 if !line.is_empty() {
1616 let effective_wrap = if i == 0 {
1618 wrap_w
1619 } else {
1620 content_width.saturating_sub(effective_prefix_w)
1621 };
1622 let wrapped = wrap_text(line, effective_wrap);
1623 for (j, wl) in wrapped.iter().enumerate() {
1624 if j > 0 {
1625 flush_line(&mut current_spans, &mut lines);
1626 if in_blockquote {
1627 current_spans.push(Span::styled(
1628 "| ".to_string(),
1629 Style::default().fg(Color::Rgb(80, 100, 140)),
1630 ));
1631 }
1632 }
1633 current_spans.push(Span::styled(wl.clone(), style));
1634 }
1635 }
1636 }
1637 }
1638 }
1639 Event::SoftBreak => {
1640 if in_table {
1641 current_cell.push(' ');
1642 } else {
1643 current_spans.push(Span::raw(" "));
1644 }
1645 }
1646 Event::HardBreak => {
1647 if in_table {
1648 current_cell.push(' ');
1649 } else {
1650 flush_line(&mut current_spans, &mut lines);
1651 }
1652 }
1653 Event::Rule => {
1654 flush_line(&mut current_spans, &mut lines);
1655 lines.push(Line::from(Span::styled(
1656 "─".repeat(content_width),
1657 Style::default().fg(Color::Rgb(70, 75, 90)),
1658 )));
1659 }
1660 Event::Start(Tag::Table(alignments)) => {
1662 flush_line(&mut current_spans, &mut lines);
1663 in_table = true;
1664 table_rows.clear();
1665 table_alignments = alignments;
1666 }
1667 Event::End(TagEnd::Table) => {
1668 flush_line(&mut current_spans, &mut lines);
1670 in_table = false;
1671
1672 if !table_rows.is_empty() {
1673 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
1674 if num_cols > 0 {
1675 let mut col_widths: Vec<usize> = vec![0; num_cols];
1677 for row in &table_rows {
1678 for (i, cell) in row.iter().enumerate() {
1679 let w = display_width(cell);
1680 if w > col_widths[i] {
1681 col_widths[i] = w;
1682 }
1683 }
1684 }
1685
1686 let sep_w = num_cols + 1; let pad_w = num_cols * 2; let avail = content_width.saturating_sub(sep_w + pad_w);
1690 let max_col_w = avail * 2 / 3;
1692 for cw in col_widths.iter_mut() {
1693 if *cw > max_col_w {
1694 *cw = max_col_w;
1695 }
1696 }
1697 let total_col_w: usize = col_widths.iter().sum();
1698 if total_col_w > avail && total_col_w > 0 {
1699 let mut remaining = avail;
1701 for (i, cw) in col_widths.iter_mut().enumerate() {
1702 if i == num_cols - 1 {
1703 *cw = remaining.max(1);
1705 } else {
1706 *cw = ((*cw) * avail / total_col_w).max(1);
1707 remaining = remaining.saturating_sub(*cw);
1708 }
1709 }
1710 }
1711
1712 let table_style = Style::default().fg(Color::Rgb(180, 180, 200));
1713 let header_style = Style::default()
1714 .fg(Color::Rgb(120, 180, 255))
1715 .add_modifier(Modifier::BOLD);
1716 let border_style = Style::default().fg(Color::Rgb(60, 70, 100));
1717
1718 let total_col_w_final: usize = col_widths.iter().sum();
1721 let table_row_w = sep_w + pad_w + total_col_w_final;
1722 let table_right_pad = content_width.saturating_sub(table_row_w);
1724
1725 let mut top = String::from("┌");
1727 for (i, cw) in col_widths.iter().enumerate() {
1728 top.push_str(&"─".repeat(cw + 2));
1729 if i < num_cols - 1 {
1730 top.push('┬');
1731 }
1732 }
1733 top.push('┐');
1734 let mut top_spans = vec![Span::styled(top, border_style)];
1736 if table_right_pad > 0 {
1737 top_spans.push(Span::raw(" ".repeat(table_right_pad)));
1738 }
1739 lines.push(Line::from(top_spans));
1740
1741 for (row_idx, row) in table_rows.iter().enumerate() {
1742 let mut row_spans: Vec<Span> = Vec::new();
1744 row_spans.push(Span::styled("│", border_style));
1745 for (i, cw) in col_widths.iter().enumerate() {
1746 let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
1747 let cell_w = display_width(cell_text);
1748 let text = if cell_w > *cw {
1749 let mut t = String::new();
1751 let mut w = 0;
1752 for ch in cell_text.chars() {
1753 let chw = char_width(ch);
1754 if w + chw > *cw {
1755 break;
1756 }
1757 t.push(ch);
1758 w += chw;
1759 }
1760 let fill = cw.saturating_sub(w);
1761 format!(" {}{} ", t, " ".repeat(fill))
1762 } else {
1763 let fill = cw.saturating_sub(cell_w);
1765 let align = table_alignments
1766 .get(i)
1767 .copied()
1768 .unwrap_or(pulldown_cmark::Alignment::None);
1769 match align {
1770 pulldown_cmark::Alignment::Center => {
1771 let left = fill / 2;
1772 let right = fill - left;
1773 format!(
1774 " {}{}{} ",
1775 " ".repeat(left),
1776 cell_text,
1777 " ".repeat(right)
1778 )
1779 }
1780 pulldown_cmark::Alignment::Right => {
1781 format!(" {}{} ", " ".repeat(fill), cell_text)
1782 }
1783 _ => {
1784 format!(" {}{} ", cell_text, " ".repeat(fill))
1785 }
1786 }
1787 };
1788 let style = if row_idx == 0 {
1789 header_style
1790 } else {
1791 table_style
1792 };
1793 row_spans.push(Span::styled(text, style));
1794 row_spans.push(Span::styled("│", border_style));
1795 }
1796 if table_right_pad > 0 {
1798 row_spans.push(Span::raw(" ".repeat(table_right_pad)));
1799 }
1800 lines.push(Line::from(row_spans));
1801
1802 if row_idx == 0 {
1804 let mut sep = String::from("├");
1805 for (i, cw) in col_widths.iter().enumerate() {
1806 sep.push_str(&"─".repeat(cw + 2));
1807 if i < num_cols - 1 {
1808 sep.push('┼');
1809 }
1810 }
1811 sep.push('┤');
1812 let mut sep_spans = vec![Span::styled(sep, border_style)];
1813 if table_right_pad > 0 {
1814 sep_spans.push(Span::raw(" ".repeat(table_right_pad)));
1815 }
1816 lines.push(Line::from(sep_spans));
1817 }
1818 }
1819
1820 let mut bottom = String::from("└");
1822 for (i, cw) in col_widths.iter().enumerate() {
1823 bottom.push_str(&"─".repeat(cw + 2));
1824 if i < num_cols - 1 {
1825 bottom.push('┴');
1826 }
1827 }
1828 bottom.push('┘');
1829 let mut bottom_spans = vec![Span::styled(bottom, border_style)];
1830 if table_right_pad > 0 {
1831 bottom_spans.push(Span::raw(" ".repeat(table_right_pad)));
1832 }
1833 lines.push(Line::from(bottom_spans));
1834 }
1835 }
1836 table_rows.clear();
1837 table_alignments.clear();
1838 }
1839 Event::Start(Tag::TableHead) => {
1840 current_row.clear();
1841 }
1842 Event::End(TagEnd::TableHead) => {
1843 table_rows.push(current_row.clone());
1844 current_row.clear();
1845 }
1846 Event::Start(Tag::TableRow) => {
1847 current_row.clear();
1848 }
1849 Event::End(TagEnd::TableRow) => {
1850 table_rows.push(current_row.clone());
1851 current_row.clear();
1852 }
1853 Event::Start(Tag::TableCell) => {
1854 current_cell.clear();
1855 }
1856 Event::End(TagEnd::TableCell) => {
1857 current_row.push(current_cell.clone());
1858 current_cell.clear();
1859 }
1860 _ => {}
1861 }
1862 }
1863
1864 if !current_spans.is_empty() {
1866 lines.push(Line::from(current_spans));
1867 }
1868
1869 if lines.is_empty() {
1871 let wrapped = wrap_text(md, content_width);
1872 for wl in wrapped {
1873 lines.push(Line::from(Span::styled(wl, base_style)));
1874 }
1875 }
1876
1877 lines
1878}
1879
1880fn highlight_code_line<'a>(line: &'a str, lang: &str) -> Vec<Span<'static>> {
1883 let lang_lower = lang.to_lowercase();
1884 let keywords: &[&str] = match lang_lower.as_str() {
1885 "rust" | "rs" => &[
1886 "fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for",
1887 "while", "loop", "if", "else", "match", "return", "self", "Self", "where", "async",
1888 "await", "move", "ref", "type", "const", "static", "crate", "super", "as", "in",
1889 "true", "false", "Some", "None", "Ok", "Err",
1890 ],
1891 "python" | "py" => &[
1892 "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as",
1893 "with", "try", "except", "finally", "raise", "pass", "break", "continue", "yield",
1894 "lambda", "and", "or", "not", "in", "is", "True", "False", "None", "global",
1895 "nonlocal", "assert", "del", "async", "await", "self", "print",
1896 ],
1897 "javascript" | "js" | "typescript" | "ts" | "jsx" | "tsx" => &[
1898 "function",
1899 "const",
1900 "let",
1901 "var",
1902 "return",
1903 "if",
1904 "else",
1905 "for",
1906 "while",
1907 "class",
1908 "new",
1909 "this",
1910 "import",
1911 "export",
1912 "from",
1913 "default",
1914 "async",
1915 "await",
1916 "try",
1917 "catch",
1918 "finally",
1919 "throw",
1920 "typeof",
1921 "instanceof",
1922 "true",
1923 "false",
1924 "null",
1925 "undefined",
1926 "of",
1927 "in",
1928 "switch",
1929 "case",
1930 ],
1931 "go" | "golang" => &[
1932 "func",
1933 "package",
1934 "import",
1935 "return",
1936 "if",
1937 "else",
1938 "for",
1939 "range",
1940 "struct",
1941 "interface",
1942 "type",
1943 "var",
1944 "const",
1945 "defer",
1946 "go",
1947 "chan",
1948 "select",
1949 "case",
1950 "switch",
1951 "default",
1952 "break",
1953 "continue",
1954 "map",
1955 "true",
1956 "false",
1957 "nil",
1958 "make",
1959 "append",
1960 "len",
1961 "cap",
1962 ],
1963 "java" | "kotlin" | "kt" => &[
1964 "public",
1965 "private",
1966 "protected",
1967 "class",
1968 "interface",
1969 "extends",
1970 "implements",
1971 "return",
1972 "if",
1973 "else",
1974 "for",
1975 "while",
1976 "new",
1977 "this",
1978 "import",
1979 "package",
1980 "static",
1981 "final",
1982 "void",
1983 "int",
1984 "String",
1985 "boolean",
1986 "true",
1987 "false",
1988 "null",
1989 "try",
1990 "catch",
1991 "throw",
1992 "throws",
1993 "fun",
1994 "val",
1995 "var",
1996 "when",
1997 "object",
1998 "companion",
1999 ],
2000 "sh" | "bash" | "zsh" | "shell" => &[
2001 "if",
2002 "then",
2003 "else",
2004 "elif",
2005 "fi",
2006 "for",
2007 "while",
2008 "do",
2009 "done",
2010 "case",
2011 "esac",
2012 "function",
2013 "return",
2014 "exit",
2015 "echo",
2016 "export",
2017 "local",
2018 "readonly",
2019 "set",
2020 "unset",
2021 "shift",
2022 "source",
2023 "in",
2024 "true",
2025 "false",
2026 "read",
2027 "declare",
2028 "typeset",
2029 "trap",
2030 "eval",
2031 "exec",
2032 "test",
2033 "select",
2034 "until",
2035 "break",
2036 "continue",
2037 "printf",
2038 "go",
2040 "build",
2041 "run",
2042 "test",
2043 "fmt",
2044 "vet",
2045 "mod",
2046 "get",
2047 "install",
2048 "clean",
2049 "doc",
2050 "list",
2051 "version",
2052 "env",
2053 "generate",
2054 "tool",
2055 "proxy",
2056 "GOPATH",
2057 "GOROOT",
2058 "GOBIN",
2059 "GOMODCACHE",
2060 "GOPROXY",
2061 "GOSUMDB",
2062 "cargo",
2064 "new",
2065 "init",
2066 "add",
2067 "remove",
2068 "update",
2069 "check",
2070 "clippy",
2071 "rustfmt",
2072 "rustc",
2073 "rustup",
2074 "publish",
2075 "install",
2076 "uninstall",
2077 "search",
2078 "tree",
2079 "locate_project",
2080 "metadata",
2081 "audit",
2082 "watch",
2083 "expand",
2084 ],
2085 "c" | "cpp" | "c++" | "h" | "hpp" => &[
2086 "int",
2087 "char",
2088 "float",
2089 "double",
2090 "void",
2091 "long",
2092 "short",
2093 "unsigned",
2094 "signed",
2095 "const",
2096 "static",
2097 "extern",
2098 "struct",
2099 "union",
2100 "enum",
2101 "typedef",
2102 "sizeof",
2103 "return",
2104 "if",
2105 "else",
2106 "for",
2107 "while",
2108 "do",
2109 "switch",
2110 "case",
2111 "break",
2112 "continue",
2113 "default",
2114 "goto",
2115 "auto",
2116 "register",
2117 "volatile",
2118 "class",
2119 "public",
2120 "private",
2121 "protected",
2122 "virtual",
2123 "override",
2124 "template",
2125 "namespace",
2126 "using",
2127 "new",
2128 "delete",
2129 "try",
2130 "catch",
2131 "throw",
2132 "nullptr",
2133 "true",
2134 "false",
2135 "this",
2136 "include",
2137 "define",
2138 "ifdef",
2139 "ifndef",
2140 "endif",
2141 ],
2142 "sql" => &[
2143 "SELECT",
2144 "FROM",
2145 "WHERE",
2146 "INSERT",
2147 "UPDATE",
2148 "DELETE",
2149 "CREATE",
2150 "DROP",
2151 "ALTER",
2152 "TABLE",
2153 "INDEX",
2154 "INTO",
2155 "VALUES",
2156 "SET",
2157 "AND",
2158 "OR",
2159 "NOT",
2160 "NULL",
2161 "JOIN",
2162 "LEFT",
2163 "RIGHT",
2164 "INNER",
2165 "OUTER",
2166 "ON",
2167 "GROUP",
2168 "BY",
2169 "ORDER",
2170 "ASC",
2171 "DESC",
2172 "HAVING",
2173 "LIMIT",
2174 "OFFSET",
2175 "UNION",
2176 "AS",
2177 "DISTINCT",
2178 "COUNT",
2179 "SUM",
2180 "AVG",
2181 "MIN",
2182 "MAX",
2183 "LIKE",
2184 "IN",
2185 "BETWEEN",
2186 "EXISTS",
2187 "CASE",
2188 "WHEN",
2189 "THEN",
2190 "ELSE",
2191 "END",
2192 "BEGIN",
2193 "COMMIT",
2194 "ROLLBACK",
2195 "PRIMARY",
2196 "KEY",
2197 "FOREIGN",
2198 "REFERENCES",
2199 "select",
2200 "from",
2201 "where",
2202 "insert",
2203 "update",
2204 "delete",
2205 "create",
2206 "drop",
2207 "alter",
2208 "table",
2209 "index",
2210 "into",
2211 "values",
2212 "set",
2213 "and",
2214 "or",
2215 "not",
2216 "null",
2217 "join",
2218 "left",
2219 "right",
2220 "inner",
2221 "outer",
2222 "on",
2223 "group",
2224 "by",
2225 "order",
2226 "asc",
2227 "desc",
2228 "having",
2229 "limit",
2230 "offset",
2231 "union",
2232 "as",
2233 "distinct",
2234 "count",
2235 "sum",
2236 "avg",
2237 "min",
2238 "max",
2239 "like",
2240 "in",
2241 "between",
2242 "exists",
2243 "case",
2244 "when",
2245 "then",
2246 "else",
2247 "end",
2248 "begin",
2249 "commit",
2250 "rollback",
2251 "primary",
2252 "key",
2253 "foreign",
2254 "references",
2255 ],
2256 "yaml" | "yml" => &["true", "false", "null", "yes", "no", "on", "off"],
2257 "toml" => &[
2258 "true",
2259 "false",
2260 "true",
2261 "false",
2262 "name",
2264 "version",
2265 "edition",
2266 "authors",
2267 "dependencies",
2268 "dev-dependencies",
2269 "build-dependencies",
2270 "features",
2271 "workspace",
2272 "members",
2273 "exclude",
2274 "include",
2275 "path",
2276 "git",
2277 "branch",
2278 "tag",
2279 "rev",
2280 "package",
2281 "lib",
2282 "bin",
2283 "example",
2284 "test",
2285 "bench",
2286 "doc",
2287 "profile",
2288 "release",
2289 "debug",
2290 "opt-level",
2291 "lto",
2292 "codegen-units",
2293 "panic",
2294 "strip",
2295 "default",
2296 "features",
2297 "optional",
2298 "repository",
2300 "homepage",
2301 "documentation",
2302 "license",
2303 "license-file",
2304 "keywords",
2305 "categories",
2306 "readme",
2307 "description",
2308 "resolver",
2309 ],
2310 "css" | "scss" | "less" => &[
2311 "color",
2312 "background",
2313 "border",
2314 "margin",
2315 "padding",
2316 "display",
2317 "position",
2318 "width",
2319 "height",
2320 "font",
2321 "text",
2322 "flex",
2323 "grid",
2324 "align",
2325 "justify",
2326 "important",
2327 "none",
2328 "auto",
2329 "inherit",
2330 "initial",
2331 "unset",
2332 ],
2333 "dockerfile" | "docker" => &[
2334 "FROM",
2335 "RUN",
2336 "CMD",
2337 "LABEL",
2338 "EXPOSE",
2339 "ENV",
2340 "ADD",
2341 "COPY",
2342 "ENTRYPOINT",
2343 "VOLUME",
2344 "USER",
2345 "WORKDIR",
2346 "ARG",
2347 "ONBUILD",
2348 "STOPSIGNAL",
2349 "HEALTHCHECK",
2350 "SHELL",
2351 "AS",
2352 ],
2353 "ruby" | "rb" => &[
2354 "def", "end", "class", "module", "if", "elsif", "else", "unless", "while", "until",
2355 "for", "do", "begin", "rescue", "ensure", "raise", "return", "yield", "require",
2356 "include", "attr", "self", "true", "false", "nil", "puts", "print",
2357 ],
2358 _ => &[
2359 "fn", "function", "def", "class", "return", "if", "else", "for", "while", "import",
2360 "export", "const", "let", "var", "true", "false", "null", "nil", "None", "self",
2361 "this",
2362 ],
2363 };
2364
2365 let comment_prefix = match lang_lower.as_str() {
2366 "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml"
2367 | "toml" | "dockerfile" | "docker" => "#",
2368 "sql" => "--",
2369 "css" | "scss" | "less" => "/*",
2370 _ => "//",
2371 };
2372
2373 let code_style = Style::default().fg(Color::Rgb(200, 200, 210));
2375 let kw_style = Style::default().fg(Color::Rgb(198, 120, 221));
2377 let str_style = Style::default().fg(Color::Rgb(152, 195, 121));
2379 let comment_style = Style::default()
2381 .fg(Color::Rgb(92, 99, 112))
2382 .add_modifier(Modifier::ITALIC);
2383 let num_style = Style::default().fg(Color::Rgb(209, 154, 102));
2385 let type_style = Style::default().fg(Color::Rgb(229, 192, 123));
2387
2388 let trimmed = line.trim_start();
2389
2390 if trimmed.starts_with(comment_prefix) {
2392 return vec![Span::styled(line.to_string(), comment_style)];
2393 }
2394
2395 let mut spans = Vec::new();
2397 let mut chars = line.chars().peekable();
2398 let mut buf = String::new();
2399
2400 while let Some(&ch) = chars.peek() {
2401 if ch == '"' || ch == '\'' || ch == '`' {
2403 if !buf.is_empty() {
2405 spans.extend(colorize_tokens(
2406 &buf, keywords, code_style, kw_style, num_style, type_style,
2407 ));
2408 buf.clear();
2409 }
2410 let quote = ch;
2411 let mut s = String::new();
2412 s.push(ch);
2413 chars.next();
2414 while let Some(&c) = chars.peek() {
2415 s.push(c);
2416 chars.next();
2417 if c == quote && !s.ends_with("\\\\") {
2418 break;
2419 }
2420 }
2421 spans.push(Span::styled(s, str_style));
2422 continue;
2423 }
2424 if ch == '$'
2426 && matches!(
2427 lang_lower.as_str(),
2428 "sh" | "bash" | "zsh" | "shell" | "dockerfile" | "docker"
2429 )
2430 {
2431 if !buf.is_empty() {
2432 spans.extend(colorize_tokens(
2433 &buf, keywords, code_style, kw_style, num_style, type_style,
2434 ));
2435 buf.clear();
2436 }
2437 let var_style = Style::default().fg(Color::Rgb(86, 182, 194));
2438 let mut var = String::new();
2439 var.push(ch);
2440 chars.next();
2441 if let Some(&next_ch) = chars.peek() {
2442 if next_ch == '{' {
2443 var.push(next_ch);
2445 chars.next();
2446 while let Some(&c) = chars.peek() {
2447 var.push(c);
2448 chars.next();
2449 if c == '}' {
2450 break;
2451 }
2452 }
2453 } else if next_ch == '(' {
2454 var.push(next_ch);
2456 chars.next();
2457 let mut depth = 1;
2458 while let Some(&c) = chars.peek() {
2459 var.push(c);
2460 chars.next();
2461 if c == '(' {
2462 depth += 1;
2463 }
2464 if c == ')' {
2465 depth -= 1;
2466 if depth == 0 {
2467 break;
2468 }
2469 }
2470 }
2471 } else if next_ch.is_alphanumeric()
2472 || next_ch == '_'
2473 || next_ch == '@'
2474 || next_ch == '#'
2475 || next_ch == '?'
2476 || next_ch == '!'
2477 {
2478 while let Some(&c) = chars.peek() {
2480 if c.is_alphanumeric() || c == '_' {
2481 var.push(c);
2482 chars.next();
2483 } else {
2484 break;
2485 }
2486 }
2487 }
2488 }
2489 spans.push(Span::styled(var, var_style));
2490 continue;
2491 }
2492 if ch == '/' || ch == '#' {
2494 let rest: String = chars.clone().collect();
2495 if rest.starts_with(comment_prefix) {
2496 if !buf.is_empty() {
2497 spans.extend(colorize_tokens(
2498 &buf, keywords, code_style, kw_style, num_style, type_style,
2499 ));
2500 buf.clear();
2501 }
2502 spans.push(Span::styled(rest, comment_style));
2503 break;
2504 }
2505 }
2506 buf.push(ch);
2507 chars.next();
2508 }
2509
2510 if !buf.is_empty() {
2511 spans.extend(colorize_tokens(
2512 &buf, keywords, code_style, kw_style, num_style, type_style,
2513 ));
2514 }
2515
2516 if spans.is_empty() {
2517 spans.push(Span::styled(line.to_string(), code_style));
2518 }
2519
2520 spans
2521}
2522
2523fn colorize_tokens<'a>(
2525 text: &str,
2526 keywords: &[&str],
2527 default_style: Style,
2528 kw_style: Style,
2529 num_style: Style,
2530 type_style: Style,
2531) -> Vec<Span<'static>> {
2532 let mut spans = Vec::new();
2533 let mut current_word = String::new();
2534 let mut current_non_word = String::new();
2535
2536 for ch in text.chars() {
2537 if ch.is_alphanumeric() || ch == '_' {
2538 if !current_non_word.is_empty() {
2539 spans.push(Span::styled(current_non_word.clone(), default_style));
2540 current_non_word.clear();
2541 }
2542 current_word.push(ch);
2543 } else {
2544 if !current_word.is_empty() {
2545 let style = if keywords.contains(¤t_word.as_str()) {
2546 kw_style
2547 } else if current_word
2548 .chars()
2549 .next()
2550 .map(|c| c.is_ascii_digit())
2551 .unwrap_or(false)
2552 {
2553 num_style
2554 } else if current_word
2555 .chars()
2556 .next()
2557 .map(|c| c.is_uppercase())
2558 .unwrap_or(false)
2559 {
2560 type_style
2561 } else {
2562 default_style
2563 };
2564 spans.push(Span::styled(current_word.clone(), style));
2565 current_word.clear();
2566 }
2567 current_non_word.push(ch);
2568 }
2569 }
2570
2571 if !current_non_word.is_empty() {
2573 spans.push(Span::styled(current_non_word, default_style));
2574 }
2575 if !current_word.is_empty() {
2576 let style = if keywords.contains(¤t_word.as_str()) {
2577 kw_style
2578 } else if current_word
2579 .chars()
2580 .next()
2581 .map(|c| c.is_ascii_digit())
2582 .unwrap_or(false)
2583 {
2584 num_style
2585 } else if current_word
2586 .chars()
2587 .next()
2588 .map(|c| c.is_uppercase())
2589 .unwrap_or(false)
2590 {
2591 type_style
2592 } else {
2593 default_style
2594 };
2595 spans.push(Span::styled(current_word, style));
2596 }
2597
2598 spans
2599}
2600
2601fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
2603 if max_width == 0 {
2604 return vec![text.to_string()];
2605 }
2606 let mut result = Vec::new();
2607 let mut current_line = String::new();
2608 let mut current_width = 0;
2609
2610 for ch in text.chars() {
2611 let ch_width = char_width(ch);
2612 if current_width + ch_width > max_width && !current_line.is_empty() {
2613 result.push(current_line.clone());
2614 current_line.clear();
2615 current_width = 0;
2616 }
2617 current_line.push(ch);
2618 current_width += ch_width;
2619 }
2620 if !current_line.is_empty() {
2621 result.push(current_line);
2622 }
2623 if result.is_empty() {
2624 result.push(String::new());
2625 }
2626 result
2627}
2628
2629fn display_width(s: &str) -> usize {
2632 s.chars().map(|c| char_width(c)).sum()
2633}
2634
2635fn char_width(c: char) -> usize {
2637 if c.is_ascii() {
2638 return 1;
2639 }
2640 let cp = c as u32;
2642 if (0x4E00..=0x9FFF).contains(&cp) || (0x3400..=0x4DBF).contains(&cp) || (0x20000..=0x2A6DF).contains(&cp) || (0x2A700..=0x2B73F).contains(&cp) || (0x2B740..=0x2B81F).contains(&cp) || (0xF900..=0xFAFF).contains(&cp) || (0x2F800..=0x2FA1F).contains(&cp) || (0x3000..=0x303F).contains(&cp) || (0xFF01..=0xFF60).contains(&cp) || (0xFFE0..=0xFFE6).contains(&cp) || (0x3040..=0x309F).contains(&cp) || (0x30A0..=0x30FF).contains(&cp) || (0xAC00..=0xD7AF).contains(&cp) || (0x1F300..=0x1F9FF).contains(&cp)
2659 || (0x2600..=0x26FF).contains(&cp)
2660 || (0x2700..=0x27BF).contains(&cp)
2661 {
2662 2
2663 } else {
2664 1
2665 }
2666}
2667
2668fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2670 let usable_width = area.width.saturating_sub(2 + 4) as usize;
2672
2673 let chars: Vec<char> = app.input.chars().collect();
2674
2675 let before_all: String = chars[..app.cursor_pos].iter().collect();
2677 let before_width = display_width(&before_all);
2678
2679 let scroll_offset_chars = if before_width >= usable_width {
2681 let target_width = before_width.saturating_sub(usable_width / 2);
2683 let mut w = 0;
2684 let mut skip = 0;
2685 for (i, &ch) in chars.iter().enumerate() {
2686 if w >= target_width {
2687 skip = i;
2688 break;
2689 }
2690 w += char_width(ch);
2691 }
2692 skip
2693 } else {
2694 0
2695 };
2696
2697 let visible_chars = &chars[scroll_offset_chars..];
2699 let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
2700
2701 let before: String = visible_chars[..cursor_in_visible].iter().collect();
2702 let cursor_ch = if cursor_in_visible < visible_chars.len() {
2703 visible_chars[cursor_in_visible].to_string()
2704 } else {
2705 " ".to_string()
2706 };
2707 let after: String = if cursor_in_visible < visible_chars.len() {
2708 visible_chars[cursor_in_visible + 1..].iter().collect()
2709 } else {
2710 String::new()
2711 };
2712
2713 let prompt_style = if app.is_loading {
2714 Style::default().fg(Color::Rgb(255, 200, 80))
2715 } else {
2716 Style::default().fg(Color::Rgb(100, 200, 130))
2717 };
2718 let prompt_text = if app.is_loading { " .. " } else { " > " };
2719
2720 let full_visible = format!("{}{}{}", before, cursor_ch, after);
2722 let inner_height = area.height.saturating_sub(2) as usize; let wrapped_lines = wrap_text(&full_visible, usable_width);
2724
2725 let before_len = before.chars().count();
2727 let cursor_len = cursor_ch.chars().count();
2728 let cursor_global_pos = before_len; let mut cursor_line_idx: usize = 0;
2730 {
2731 let mut cumulative = 0usize;
2732 for (li, wl) in wrapped_lines.iter().enumerate() {
2733 let line_char_count = wl.chars().count();
2734 if cumulative + line_char_count > cursor_global_pos {
2735 cursor_line_idx = li;
2736 break;
2737 }
2738 cumulative += line_char_count;
2739 cursor_line_idx = li; }
2741 }
2742
2743 let line_scroll = if wrapped_lines.len() <= inner_height {
2745 0
2746 } else if cursor_line_idx < inner_height {
2747 0
2748 } else {
2749 cursor_line_idx.saturating_sub(inner_height - 1)
2751 };
2752
2753 let mut display_lines: Vec<Line> = Vec::new();
2755 let mut char_offset: usize = 0;
2756 for wl in wrapped_lines.iter().take(line_scroll) {
2758 char_offset += wl.chars().count();
2759 }
2760
2761 for (_line_idx, wl) in wrapped_lines
2762 .iter()
2763 .skip(line_scroll)
2764 .enumerate()
2765 .take(inner_height.max(1))
2766 {
2767 let mut spans: Vec<Span> = Vec::new();
2768 if _line_idx == 0 && line_scroll == 0 {
2769 spans.push(Span::styled(prompt_text, prompt_style));
2770 } else {
2771 spans.push(Span::styled(" ", Style::default())); }
2773
2774 let line_chars: Vec<char> = wl.chars().collect();
2776 let mut seg_start = 0;
2777 for (ci, &ch) in line_chars.iter().enumerate() {
2778 let global_idx = char_offset + ci;
2779 let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
2780
2781 if is_cursor {
2782 if ci > seg_start {
2784 let seg: String = line_chars[seg_start..ci].iter().collect();
2785 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
2786 }
2787 spans.push(Span::styled(
2788 ch.to_string(),
2789 Style::default()
2790 .fg(Color::Rgb(22, 22, 30))
2791 .bg(Color::Rgb(200, 210, 240)),
2792 ));
2793 seg_start = ci + 1;
2794 }
2795 }
2796 if seg_start < line_chars.len() {
2798 let seg: String = line_chars[seg_start..].iter().collect();
2799 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
2800 }
2801
2802 char_offset += line_chars.len();
2803 display_lines.push(Line::from(spans));
2804 }
2805
2806 if display_lines.is_empty() {
2807 display_lines.push(Line::from(vec![
2808 Span::styled(prompt_text, prompt_style),
2809 Span::styled(
2810 " ",
2811 Style::default()
2812 .fg(Color::Rgb(22, 22, 30))
2813 .bg(Color::Rgb(200, 210, 240)),
2814 ),
2815 ]));
2816 }
2817
2818 let input_widget = Paragraph::new(display_lines).block(
2819 Block::default()
2820 .borders(Borders::ALL)
2821 .border_type(ratatui::widgets::BorderType::Rounded)
2822 .border_style(if app.is_loading {
2823 Style::default().fg(Color::Rgb(120, 100, 50))
2824 } else {
2825 Style::default().fg(Color::Rgb(60, 100, 80))
2826 })
2827 .title(Span::styled(
2828 " 输入消息 ",
2829 Style::default().fg(Color::Rgb(140, 140, 170)),
2830 ))
2831 .style(Style::default().bg(Color::Rgb(26, 26, 38))),
2832 );
2833
2834 f.render_widget(input_widget, area);
2835
2836 if !app.is_loading {
2839 let prompt_w: u16 = 4; let border_left: u16 = 1; let cursor_col_in_line = {
2844 let mut col = 0usize;
2845 let mut char_count = 0usize;
2846 let mut skip_chars = 0usize;
2848 for wl in wrapped_lines.iter().take(line_scroll) {
2849 skip_chars += wl.chars().count();
2850 }
2851 for wl in wrapped_lines.iter().skip(line_scroll) {
2853 let line_len = wl.chars().count();
2854 if skip_chars + char_count + line_len > cursor_global_pos {
2855 let pos_in_line = cursor_global_pos - (skip_chars + char_count);
2857 col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
2858 break;
2859 }
2860 char_count += line_len;
2861 }
2862 col as u16
2863 };
2864
2865 let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
2867
2868 let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
2869 let cursor_y = area.y + 1 + cursor_row_in_display; if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
2873 f.set_cursor_position((cursor_x, cursor_y));
2874 }
2875 }
2876}
2877
2878fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2880 let hints = match app.mode {
2881 ChatMode::Chat => {
2882 vec![
2883 ("Enter", "发送"),
2884 ("↑↓", "滚动"),
2885 ("Ctrl+T", "切换模型"),
2886 ("Ctrl+L", "清空"),
2887 ("Ctrl+Y", "复制"),
2888 ("Ctrl+B", "浏览"),
2889 ("Ctrl+S", "流式切换"),
2890 ("?/F1", "帮助"),
2891 ("Esc", "退出"),
2892 ]
2893 }
2894 ChatMode::SelectModel => {
2895 vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
2896 }
2897 ChatMode::Browse => {
2898 vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
2899 }
2900 ChatMode::Help => {
2901 vec![("任意键", "返回")]
2902 }
2903 };
2904
2905 let mut spans: Vec<Span> = Vec::new();
2906 spans.push(Span::styled(" ", Style::default()));
2907 for (i, (key, desc)) in hints.iter().enumerate() {
2908 if i > 0 {
2909 spans.push(Span::styled(
2910 " │ ",
2911 Style::default().fg(Color::Rgb(50, 50, 65)),
2912 ));
2913 }
2914 spans.push(Span::styled(
2915 format!(" {} ", key),
2916 Style::default()
2917 .fg(Color::Rgb(22, 22, 30))
2918 .bg(Color::Rgb(100, 110, 140)),
2919 ));
2920 spans.push(Span::styled(
2921 format!(" {}", desc),
2922 Style::default().fg(Color::Rgb(120, 120, 150)),
2923 ));
2924 }
2925
2926 let hint_bar =
2927 Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(22, 22, 30)));
2928 f.render_widget(hint_bar, area);
2929}
2930
2931fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2933 if let Some((ref msg, is_error, _)) = app.toast {
2934 let text_width = display_width(msg);
2935 let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
2937 let toast_height: u16 = 3;
2938
2939 let x = area.width.saturating_sub(toast_width + 1);
2941 let y: u16 = 1;
2942
2943 if x + toast_width <= area.width && y + toast_height <= area.height {
2944 let toast_area = Rect::new(x, y, toast_width, toast_height);
2945
2946 let clear = Block::default().style(Style::default().bg(if is_error {
2948 Color::Rgb(60, 20, 20)
2949 } else {
2950 Color::Rgb(20, 50, 30)
2951 }));
2952 f.render_widget(clear, toast_area);
2953
2954 let (icon, border_color, text_color) = if is_error {
2955 ("❌", Color::Rgb(200, 70, 70), Color::Rgb(255, 130, 130))
2956 } else {
2957 ("✅", Color::Rgb(60, 160, 80), Color::Rgb(140, 230, 160))
2958 };
2959
2960 let toast_widget = Paragraph::new(Line::from(vec![
2961 Span::styled(format!(" {} ", icon), Style::default()),
2962 Span::styled(msg.as_str(), Style::default().fg(text_color)),
2963 ]))
2964 .block(
2965 Block::default()
2966 .borders(Borders::ALL)
2967 .border_type(ratatui::widgets::BorderType::Rounded)
2968 .border_style(Style::default().fg(border_color))
2969 .style(Style::default().bg(if is_error {
2970 Color::Rgb(50, 18, 18)
2971 } else {
2972 Color::Rgb(18, 40, 25)
2973 })),
2974 );
2975 f.render_widget(toast_widget, toast_area);
2976 }
2977 }
2978}
2979
2980fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
2982 let items: Vec<ListItem> = app
2983 .agent_config
2984 .providers
2985 .iter()
2986 .enumerate()
2987 .map(|(i, p)| {
2988 let is_active = i == app.agent_config.active_index;
2989 let marker = if is_active { " ● " } else { " ○ " };
2990 let style = if is_active {
2991 Style::default()
2992 .fg(Color::Rgb(120, 220, 160))
2993 .add_modifier(Modifier::BOLD)
2994 } else {
2995 Style::default().fg(Color::Rgb(180, 180, 200))
2996 };
2997 let detail = format!("{}{} ({})", marker, p.name, p.model);
2998 ListItem::new(Line::from(Span::styled(detail, style)))
2999 })
3000 .collect();
3001
3002 let list = List::new(items)
3003 .block(
3004 Block::default()
3005 .borders(Borders::ALL)
3006 .border_type(ratatui::widgets::BorderType::Rounded)
3007 .border_style(Style::default().fg(Color::Rgb(180, 160, 80)))
3008 .title(Span::styled(
3009 " 🔄 选择模型 ",
3010 Style::default()
3011 .fg(Color::Rgb(230, 210, 120))
3012 .add_modifier(Modifier::BOLD),
3013 ))
3014 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
3015 )
3016 .highlight_style(
3017 Style::default()
3018 .bg(Color::Rgb(50, 55, 80))
3019 .fg(Color::White)
3020 .add_modifier(Modifier::BOLD),
3021 )
3022 .highlight_symbol(" ▸ ");
3023
3024 f.render_stateful_widget(list, area, &mut app.model_list_state);
3025}
3026
3027fn draw_help(f: &mut ratatui::Frame, area: Rect) {
3029 let separator = Line::from(Span::styled(
3030 " ─────────────────────────────────────────",
3031 Style::default().fg(Color::Rgb(50, 55, 70)),
3032 ));
3033
3034 let help_lines = vec![
3035 Line::from(""),
3036 Line::from(Span::styled(
3037 " 📖 快捷键帮助",
3038 Style::default()
3039 .fg(Color::Rgb(120, 180, 255))
3040 .add_modifier(Modifier::BOLD),
3041 )),
3042 Line::from(""),
3043 separator.clone(),
3044 Line::from(""),
3045 Line::from(vec![
3046 Span::styled(
3047 " Enter ",
3048 Style::default()
3049 .fg(Color::Rgb(230, 210, 120))
3050 .add_modifier(Modifier::BOLD),
3051 ),
3052 Span::styled("发送消息", Style::default().fg(Color::Rgb(200, 200, 220))),
3053 ]),
3054 Line::from(vec![
3055 Span::styled(
3056 " ↑ / ↓ ",
3057 Style::default()
3058 .fg(Color::Rgb(230, 210, 120))
3059 .add_modifier(Modifier::BOLD),
3060 ),
3061 Span::styled(
3062 "滚动对话记录",
3063 Style::default().fg(Color::Rgb(200, 200, 220)),
3064 ),
3065 ]),
3066 Line::from(vec![
3067 Span::styled(
3068 " ← / → ",
3069 Style::default()
3070 .fg(Color::Rgb(230, 210, 120))
3071 .add_modifier(Modifier::BOLD),
3072 ),
3073 Span::styled(
3074 "移动输入光标",
3075 Style::default().fg(Color::Rgb(200, 200, 220)),
3076 ),
3077 ]),
3078 Line::from(vec![
3079 Span::styled(
3080 " Ctrl+T ",
3081 Style::default()
3082 .fg(Color::Rgb(230, 210, 120))
3083 .add_modifier(Modifier::BOLD),
3084 ),
3085 Span::styled("切换模型", Style::default().fg(Color::Rgb(200, 200, 220))),
3086 ]),
3087 Line::from(vec![
3088 Span::styled(
3089 " Ctrl+L ",
3090 Style::default()
3091 .fg(Color::Rgb(230, 210, 120))
3092 .add_modifier(Modifier::BOLD),
3093 ),
3094 Span::styled(
3095 "清空对话历史",
3096 Style::default().fg(Color::Rgb(200, 200, 220)),
3097 ),
3098 ]),
3099 Line::from(vec![
3100 Span::styled(
3101 " Ctrl+Y ",
3102 Style::default()
3103 .fg(Color::Rgb(230, 210, 120))
3104 .add_modifier(Modifier::BOLD),
3105 ),
3106 Span::styled(
3107 "复制最后一条 AI 回复",
3108 Style::default().fg(Color::Rgb(200, 200, 220)),
3109 ),
3110 ]),
3111 Line::from(vec![
3112 Span::styled(
3113 " Ctrl+B ",
3114 Style::default()
3115 .fg(Color::Rgb(230, 210, 120))
3116 .add_modifier(Modifier::BOLD),
3117 ),
3118 Span::styled(
3119 "浏览消息 (↑↓选择, y/Enter复制)",
3120 Style::default().fg(Color::Rgb(200, 200, 220)),
3121 ),
3122 ]),
3123 Line::from(vec![
3124 Span::styled(
3125 " Ctrl+S ",
3126 Style::default()
3127 .fg(Color::Rgb(230, 210, 120))
3128 .add_modifier(Modifier::BOLD),
3129 ),
3130 Span::styled(
3131 "切换流式/整体输出",
3132 Style::default().fg(Color::Rgb(200, 200, 220)),
3133 ),
3134 ]),
3135 Line::from(vec![
3136 Span::styled(
3137 " Esc / Ctrl+C ",
3138 Style::default()
3139 .fg(Color::Rgb(230, 210, 120))
3140 .add_modifier(Modifier::BOLD),
3141 ),
3142 Span::styled("退出对话", Style::default().fg(Color::Rgb(200, 200, 220))),
3143 ]),
3144 Line::from(vec![
3145 Span::styled(
3146 " ? / F1 ",
3147 Style::default()
3148 .fg(Color::Rgb(230, 210, 120))
3149 .add_modifier(Modifier::BOLD),
3150 ),
3151 Span::styled(
3152 "显示 / 关闭此帮助",
3153 Style::default().fg(Color::Rgb(200, 200, 220)),
3154 ),
3155 ]),
3156 Line::from(""),
3157 separator,
3158 Line::from(""),
3159 Line::from(Span::styled(
3160 " 📁 配置文件:",
3161 Style::default()
3162 .fg(Color::Rgb(120, 180, 255))
3163 .add_modifier(Modifier::BOLD),
3164 )),
3165 Line::from(Span::styled(
3166 format!(" {}", agent_config_path().display()),
3167 Style::default().fg(Color::Rgb(100, 100, 130)),
3168 )),
3169 ];
3170
3171 let help_block = Block::default()
3172 .borders(Borders::ALL)
3173 .border_type(ratatui::widgets::BorderType::Rounded)
3174 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
3175 .title(Span::styled(
3176 " 帮助 (按任意键返回) ",
3177 Style::default().fg(Color::Rgb(140, 140, 170)),
3178 ))
3179 .style(Style::default().bg(Color::Rgb(24, 24, 34)));
3180 let help_widget = Paragraph::new(help_lines).block(help_block);
3181 f.render_widget(help_widget, area);
3182}
3183
3184fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
3186 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
3188 return true;
3189 }
3190
3191 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') {
3193 if !app.agent_config.providers.is_empty() {
3194 app.mode = ChatMode::SelectModel;
3195 app.model_list_state
3196 .select(Some(app.agent_config.active_index));
3197 }
3198 return false;
3199 }
3200
3201 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
3203 app.clear_session();
3204 return false;
3205 }
3206
3207 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('y') {
3209 if let Some(last_ai) = app
3210 .session
3211 .messages
3212 .iter()
3213 .rev()
3214 .find(|m| m.role == "assistant")
3215 {
3216 if copy_to_clipboard(&last_ai.content) {
3217 app.show_toast("已复制最后一条 AI 回复", false);
3218 } else {
3219 app.show_toast("复制到剪切板失败", true);
3220 }
3221 } else {
3222 app.show_toast("暂无 AI 回复可复制", true);
3223 }
3224 return false;
3225 }
3226
3227 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
3229 if !app.session.messages.is_empty() {
3230 app.browse_msg_index = app.session.messages.len() - 1;
3232 app.mode = ChatMode::Browse;
3233 app.msg_lines_cache = None; } else {
3235 app.show_toast("暂无消息可浏览", true);
3236 }
3237 return false;
3238 }
3239
3240 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
3242 app.agent_config.stream_mode = !app.agent_config.stream_mode;
3243 let _ = save_agent_config(&app.agent_config);
3244 let mode_str = if app.agent_config.stream_mode {
3245 "流式输出"
3246 } else {
3247 "整体输出"
3248 };
3249 app.show_toast(&format!("已切换为: {}", mode_str), false);
3250 return false;
3251 }
3252
3253 let char_count = app.input.chars().count();
3254
3255 match key.code {
3256 KeyCode::Esc => return true,
3257
3258 KeyCode::Enter => {
3259 if !app.is_loading {
3260 app.send_message();
3261 }
3262 }
3263
3264 KeyCode::Up => app.scroll_up(),
3266 KeyCode::Down => app.scroll_down(),
3267 KeyCode::PageUp => {
3268 for _ in 0..10 {
3269 app.scroll_up();
3270 }
3271 }
3272 KeyCode::PageDown => {
3273 for _ in 0..10 {
3274 app.scroll_down();
3275 }
3276 }
3277
3278 KeyCode::Left => {
3280 if app.cursor_pos > 0 {
3281 app.cursor_pos -= 1;
3282 }
3283 }
3284 KeyCode::Right => {
3285 if app.cursor_pos < char_count {
3286 app.cursor_pos += 1;
3287 }
3288 }
3289 KeyCode::Home => app.cursor_pos = 0,
3290 KeyCode::End => app.cursor_pos = char_count,
3291
3292 KeyCode::Backspace => {
3294 if app.cursor_pos > 0 {
3295 let start = app
3296 .input
3297 .char_indices()
3298 .nth(app.cursor_pos - 1)
3299 .map(|(i, _)| i)
3300 .unwrap_or(0);
3301 let end = app
3302 .input
3303 .char_indices()
3304 .nth(app.cursor_pos)
3305 .map(|(i, _)| i)
3306 .unwrap_or(app.input.len());
3307 app.input.drain(start..end);
3308 app.cursor_pos -= 1;
3309 }
3310 }
3311 KeyCode::Delete => {
3312 if app.cursor_pos < char_count {
3313 let start = app
3314 .input
3315 .char_indices()
3316 .nth(app.cursor_pos)
3317 .map(|(i, _)| i)
3318 .unwrap_or(app.input.len());
3319 let end = app
3320 .input
3321 .char_indices()
3322 .nth(app.cursor_pos + 1)
3323 .map(|(i, _)| i)
3324 .unwrap_or(app.input.len());
3325 app.input.drain(start..end);
3326 }
3327 }
3328
3329 KeyCode::F(1) => {
3331 app.mode = ChatMode::Help;
3332 }
3333 KeyCode::Char('?') if app.input.is_empty() => {
3335 app.mode = ChatMode::Help;
3336 }
3337 KeyCode::Char(c) => {
3338 let byte_idx = app
3339 .input
3340 .char_indices()
3341 .nth(app.cursor_pos)
3342 .map(|(i, _)| i)
3343 .unwrap_or(app.input.len());
3344 app.input.insert_str(byte_idx, &c.to_string());
3345 app.cursor_pos += 1;
3346 }
3347
3348 _ => {}
3349 }
3350
3351 false
3352}
3353
3354fn handle_browse_mode(app: &mut ChatApp, key: KeyEvent) {
3356 let msg_count = app.session.messages.len();
3357 if msg_count == 0 {
3358 app.mode = ChatMode::Chat;
3359 app.msg_lines_cache = None;
3360 return;
3361 }
3362
3363 match key.code {
3364 KeyCode::Esc => {
3365 app.mode = ChatMode::Chat;
3366 app.msg_lines_cache = None; }
3368 KeyCode::Up | KeyCode::Char('k') => {
3369 if app.browse_msg_index > 0 {
3370 app.browse_msg_index -= 1;
3371 app.msg_lines_cache = None; }
3373 }
3374 KeyCode::Down | KeyCode::Char('j') => {
3375 if app.browse_msg_index < msg_count - 1 {
3376 app.browse_msg_index += 1;
3377 app.msg_lines_cache = None; }
3379 }
3380 KeyCode::Enter | KeyCode::Char('y') => {
3381 if let Some(msg) = app.session.messages.get(app.browse_msg_index) {
3383 let content = msg.content.clone();
3384 let role_label = if msg.role == "assistant" {
3385 "AI"
3386 } else if msg.role == "user" {
3387 "用户"
3388 } else {
3389 "系统"
3390 };
3391 if copy_to_clipboard(&content) {
3392 app.show_toast(
3393 &format!("已复制第 {} 条{}消息", app.browse_msg_index + 1, role_label),
3394 false,
3395 );
3396 } else {
3397 app.show_toast("复制到剪切板失败", true);
3398 }
3399 }
3400 }
3401 _ => {}
3402 }
3403}
3404
3405fn handle_select_model(app: &mut ChatApp, key: KeyEvent) {
3407 let count = app.agent_config.providers.len();
3408 match key.code {
3409 KeyCode::Esc => {
3410 app.mode = ChatMode::Chat;
3411 }
3412 KeyCode::Up | KeyCode::Char('k') => {
3413 if count > 0 {
3414 let i = app
3415 .model_list_state
3416 .selected()
3417 .map(|i| if i == 0 { count - 1 } else { i - 1 })
3418 .unwrap_or(0);
3419 app.model_list_state.select(Some(i));
3420 }
3421 }
3422 KeyCode::Down | KeyCode::Char('j') => {
3423 if count > 0 {
3424 let i = app
3425 .model_list_state
3426 .selected()
3427 .map(|i| if i >= count - 1 { 0 } else { i + 1 })
3428 .unwrap_or(0);
3429 app.model_list_state.select(Some(i));
3430 }
3431 }
3432 KeyCode::Enter => {
3433 app.switch_model();
3434 }
3435 _ => {}
3436 }
3437}
3438
3439fn copy_to_clipboard(content: &str) -> bool {
3441 use std::process::{Command, Stdio};
3442
3443 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
3444 ("pbcopy", vec![])
3445 } else if cfg!(target_os = "linux") {
3446 if Command::new("which")
3447 .arg("xclip")
3448 .output()
3449 .map(|o| o.status.success())
3450 .unwrap_or(false)
3451 {
3452 ("xclip", vec!["-selection", "clipboard"])
3453 } else {
3454 ("xsel", vec!["--clipboard", "--input"])
3455 }
3456 } else {
3457 return false;
3458 };
3459
3460 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
3461
3462 match child {
3463 Ok(mut child) => {
3464 if let Some(ref mut stdin) = child.stdin {
3465 let _ = stdin.write_all(content.as_bytes());
3466 }
3467 child.wait().map(|s| s.success()).unwrap_or(false)
3468 }
3469 Err(_) => false,
3470 }
3471}