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 {
1174 let bubble_text = " ".repeat(bubble_max_width);
1175 let pad = inner_width.saturating_sub(bubble_max_width);
1176 lines.push(Line::from(vec![
1177 Span::raw(" ".repeat(pad)),
1178 Span::styled(bubble_text, Style::default().bg(user_bg)),
1179 ]));
1180 }
1181
1182 for content_line in msg.content.lines() {
1183 let wrapped = wrap_text(content_line, user_content_w);
1184 for wl in wrapped {
1185 let wl_width = display_width(&wl);
1186 let fill = user_content_w.saturating_sub(wl_width);
1187 let text = format!(
1188 "{}{}{}{}",
1189 " ".repeat(user_pad_lr),
1190 wl,
1191 " ".repeat(fill),
1192 " ".repeat(user_pad_lr),
1193 );
1194 let text_width = display_width(&text);
1195 let pad = inner_width.saturating_sub(text_width);
1196 lines.push(Line::from(vec![
1197 Span::raw(" ".repeat(pad)),
1198 Span::styled(text, Style::default().fg(Color::White).bg(user_bg)),
1199 ]));
1200 }
1201 }
1202
1203 {
1205 let bubble_text = " ".repeat(bubble_max_width);
1206 let pad = inner_width.saturating_sub(bubble_max_width);
1207 lines.push(Line::from(vec![
1208 Span::raw(" ".repeat(pad)),
1209 Span::styled(bubble_text, Style::default().bg(user_bg)),
1210 ]));
1211 }
1212 }
1213 "assistant" => {
1214 lines.push(Line::from(""));
1216 let ai_label = if is_selected { " ▶ AI" } else { " AI" };
1217 lines.push(Line::from(Span::styled(
1218 ai_label,
1219 Style::default()
1220 .fg(if is_selected {
1221 Color::Rgb(255, 200, 80)
1222 } else {
1223 Color::Rgb(120, 220, 160)
1224 })
1225 .add_modifier(Modifier::BOLD),
1226 )));
1227
1228 let bubble_bg = if is_selected {
1230 Color::Rgb(48, 48, 68)
1231 } else {
1232 Color::Rgb(38, 38, 52)
1233 };
1234 let pad_left = " "; let pad_right = " "; let pad_left_w = 3usize;
1237 let pad_right_w = 3usize;
1238 let md_content_w = bubble_max_width.saturating_sub(pad_left_w + pad_right_w);
1240 let md_lines = markdown_to_lines(&msg.content, md_content_w + 2); let bubble_total_w = bubble_max_width;
1244
1245 {
1247 let mut top_spans: Vec<Span> = Vec::new();
1248 top_spans.push(Span::styled(
1249 " ".repeat(bubble_total_w),
1250 Style::default().bg(bubble_bg),
1251 ));
1252 lines.push(Line::from(top_spans));
1253 }
1254
1255 for md_line in md_lines {
1256 let mut styled_spans: Vec<Span> = Vec::new();
1258 styled_spans.push(Span::styled(pad_left, Style::default().bg(bubble_bg)));
1259 let mut content_w: usize = 0;
1261 for span in md_line.spans {
1262 let sw = display_width(&span.content);
1263 content_w += sw;
1264 let merged_style = span.style.bg(bubble_bg);
1266 styled_spans.push(Span::styled(span.content.to_string(), merged_style));
1267 }
1268 let target_content_w = bubble_total_w.saturating_sub(pad_left_w + pad_right_w);
1270 let fill = target_content_w.saturating_sub(content_w);
1271 if fill > 0 {
1272 styled_spans.push(Span::styled(
1273 " ".repeat(fill),
1274 Style::default().bg(bubble_bg),
1275 ));
1276 }
1277 styled_spans.push(Span::styled(pad_right, Style::default().bg(bubble_bg)));
1278 lines.push(Line::from(styled_spans));
1279 }
1280
1281 {
1283 let mut bottom_spans: Vec<Span> = Vec::new();
1284 bottom_spans.push(Span::styled(
1285 " ".repeat(bubble_total_w),
1286 Style::default().bg(bubble_bg),
1287 ));
1288 lines.push(Line::from(bottom_spans));
1289 }
1290 }
1291 "system" => {
1292 lines.push(Line::from(""));
1294 let wrapped = wrap_text(&msg.content, inner_width.saturating_sub(8));
1295 for wl in wrapped {
1296 lines.push(Line::from(Span::styled(
1297 format!(" {} {}", "sys", wl),
1298 Style::default().fg(Color::Rgb(100, 100, 120)),
1299 )));
1300 }
1301 }
1302 _ => {}
1303 }
1304 }
1305 lines.push(Line::from(""));
1307
1308 (lines, msg_start_lines)
1309}
1310
1311fn markdown_to_lines(md: &str, max_width: usize) -> Vec<Line<'static>> {
1315 use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
1316
1317 let content_width = max_width.saturating_sub(2);
1319
1320 let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
1321 let parser = Parser::new_ext(md, options);
1322
1323 let mut lines: Vec<Line<'static>> = Vec::new();
1324 let mut current_spans: Vec<Span<'static>> = Vec::new();
1325 let mut style_stack: Vec<Style> = vec![Style::default().fg(Color::Rgb(220, 220, 230))];
1326 let mut in_code_block = false;
1327 let mut code_block_content = String::new();
1328 let mut code_block_lang = String::new();
1329 let mut list_depth: usize = 0;
1330 let mut ordered_index: Option<u64> = None;
1331 let mut heading_level: Option<u8> = None;
1332 let mut in_blockquote = false;
1334 let mut in_table = false;
1336 let mut table_rows: Vec<Vec<String>> = Vec::new(); let mut current_row: Vec<String> = Vec::new();
1338 let mut current_cell = String::new();
1339 let mut table_alignments: Vec<pulldown_cmark::Alignment> = Vec::new();
1340
1341 let base_style = Style::default().fg(Color::Rgb(220, 220, 230));
1342
1343 let flush_line = |current_spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
1344 if !current_spans.is_empty() {
1345 lines.push(Line::from(current_spans.drain(..).collect::<Vec<_>>()));
1346 }
1347 };
1348
1349 for event in parser {
1350 match event {
1351 Event::Start(Tag::Heading { level, .. }) => {
1352 flush_line(&mut current_spans, &mut lines);
1353 heading_level = Some(level as u8);
1354 if !lines.is_empty() {
1355 lines.push(Line::from(""));
1356 }
1357 let heading_style = match level as u8 {
1359 1 => Style::default()
1360 .fg(Color::Rgb(100, 180, 255))
1361 .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1362 2 => Style::default()
1363 .fg(Color::Rgb(130, 190, 255))
1364 .add_modifier(Modifier::BOLD),
1365 3 => Style::default()
1366 .fg(Color::Rgb(160, 200, 255))
1367 .add_modifier(Modifier::BOLD),
1368 _ => Style::default()
1369 .fg(Color::Rgb(180, 210, 255))
1370 .add_modifier(Modifier::BOLD),
1371 };
1372 style_stack.push(heading_style);
1373 }
1374 Event::End(TagEnd::Heading(level)) => {
1375 flush_line(&mut current_spans, &mut lines);
1376 if (level as u8) <= 2 {
1378 let sep_char = if (level as u8) == 1 { "━" } else { "─" };
1379 lines.push(Line::from(Span::styled(
1380 sep_char.repeat(content_width),
1381 Style::default().fg(Color::Rgb(60, 70, 100)),
1382 )));
1383 }
1384 style_stack.pop();
1385 heading_level = None;
1386 }
1387 Event::Start(Tag::Strong) => {
1388 let current = *style_stack.last().unwrap_or(&base_style);
1389 style_stack.push(current.add_modifier(Modifier::BOLD));
1390 }
1391 Event::End(TagEnd::Strong) => {
1392 style_stack.pop();
1393 }
1394 Event::Start(Tag::Emphasis) => {
1395 let current = *style_stack.last().unwrap_or(&base_style);
1396 style_stack.push(current.add_modifier(Modifier::ITALIC));
1397 }
1398 Event::End(TagEnd::Emphasis) => {
1399 style_stack.pop();
1400 }
1401 Event::Start(Tag::Strikethrough) => {
1402 let current = *style_stack.last().unwrap_or(&base_style);
1403 style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
1404 }
1405 Event::End(TagEnd::Strikethrough) => {
1406 style_stack.pop();
1407 }
1408 Event::Start(Tag::CodeBlock(kind)) => {
1409 flush_line(&mut current_spans, &mut lines);
1410 in_code_block = true;
1411 code_block_content.clear();
1412 code_block_lang = match kind {
1413 CodeBlockKind::Fenced(lang) => lang.to_string(),
1414 CodeBlockKind::Indented => String::new(),
1415 };
1416 let label = if code_block_lang.is_empty() {
1418 " code ".to_string()
1419 } else {
1420 format!(" {} ", code_block_lang)
1421 };
1422 let label_w = display_width(&label);
1423 let border_fill = content_width.saturating_sub(2 + label_w);
1424 let top_border = format!("┌─{}{}", label, "─".repeat(border_fill));
1425 lines.push(Line::from(Span::styled(
1426 top_border,
1427 Style::default().fg(Color::Rgb(80, 90, 110)),
1428 )));
1429 }
1430 Event::End(TagEnd::CodeBlock) => {
1431 let code_inner_w = content_width.saturating_sub(4); for code_line in code_block_content.lines() {
1434 let wrapped = wrap_text(code_line, code_inner_w);
1435 for wl in wrapped {
1436 let highlighted = highlight_code_line(&wl, &code_block_lang);
1437 let text_w: usize =
1438 highlighted.iter().map(|s| display_width(&s.content)).sum();
1439 let fill = code_inner_w.saturating_sub(text_w);
1440 let mut spans_vec = Vec::new();
1441 spans_vec.push(Span::styled(
1442 "│ ",
1443 Style::default().fg(Color::Rgb(80, 90, 110)),
1444 ));
1445 for hs in highlighted {
1446 spans_vec.push(Span::styled(
1447 hs.content.to_string(),
1448 hs.style.bg(Color::Rgb(30, 30, 42)),
1449 ));
1450 }
1451 spans_vec.push(Span::styled(
1452 format!("{} │", " ".repeat(fill)),
1453 Style::default()
1454 .fg(Color::Rgb(80, 90, 110))
1455 .bg(Color::Rgb(30, 30, 42)),
1456 ));
1457 lines.push(Line::from(spans_vec));
1458 }
1459 }
1460 let bottom_border = format!("└{}", "─".repeat(content_width.saturating_sub(1)));
1461 lines.push(Line::from(Span::styled(
1462 bottom_border,
1463 Style::default().fg(Color::Rgb(80, 90, 110)),
1464 )));
1465 in_code_block = false;
1466 code_block_content.clear();
1467 code_block_lang.clear();
1468 }
1469 Event::Code(text) => {
1470 if in_table {
1471 current_cell.push('`');
1473 current_cell.push_str(&text);
1474 current_cell.push('`');
1475 } else {
1476 current_spans.push(Span::styled(
1478 format!(" {} ", text),
1479 Style::default()
1480 .fg(Color::Rgb(230, 190, 120))
1481 .bg(Color::Rgb(45, 45, 60)),
1482 ));
1483 }
1484 }
1485 Event::Start(Tag::List(start)) => {
1486 flush_line(&mut current_spans, &mut lines);
1487 list_depth += 1;
1488 ordered_index = start;
1489 }
1490 Event::End(TagEnd::List(_)) => {
1491 flush_line(&mut current_spans, &mut lines);
1492 list_depth = list_depth.saturating_sub(1);
1493 ordered_index = None;
1494 }
1495 Event::Start(Tag::Item) => {
1496 flush_line(&mut current_spans, &mut lines);
1497 let indent = " ".repeat(list_depth);
1498 let bullet = if let Some(ref mut idx) = ordered_index {
1499 let s = format!("{}{}. ", indent, idx);
1500 *idx += 1;
1501 s
1502 } else {
1503 format!("{}- ", indent)
1504 };
1505 current_spans.push(Span::styled(
1506 bullet,
1507 Style::default().fg(Color::Rgb(160, 180, 220)),
1508 ));
1509 }
1510 Event::End(TagEnd::Item) => {
1511 flush_line(&mut current_spans, &mut lines);
1512 }
1513 Event::Start(Tag::Paragraph) => {
1514 if !lines.is_empty() && !in_code_block && heading_level.is_none() {
1515 let last_empty = lines.last().map(|l| l.spans.is_empty()).unwrap_or(false);
1516 if !last_empty {
1517 lines.push(Line::from(""));
1518 }
1519 }
1520 }
1521 Event::End(TagEnd::Paragraph) => {
1522 flush_line(&mut current_spans, &mut lines);
1523 }
1524 Event::Start(Tag::BlockQuote(_)) => {
1525 flush_line(&mut current_spans, &mut lines);
1526 in_blockquote = true;
1527 style_stack.push(Style::default().fg(Color::Rgb(150, 160, 180)));
1528 }
1529 Event::End(TagEnd::BlockQuote(_)) => {
1530 flush_line(&mut current_spans, &mut lines);
1531 in_blockquote = false;
1532 style_stack.pop();
1533 }
1534 Event::Text(text) => {
1535 if in_code_block {
1536 code_block_content.push_str(&text);
1537 } else if in_table {
1538 current_cell.push_str(&text);
1540 } else {
1541 let style = *style_stack.last().unwrap_or(&base_style);
1542 let text_str = text.to_string();
1543
1544 if let Some(level) = heading_level {
1546 let (prefix, prefix_style) = match level {
1547 1 => (
1548 ">> ",
1549 Style::default()
1550 .fg(Color::Rgb(100, 180, 255))
1551 .add_modifier(Modifier::BOLD),
1552 ),
1553 2 => (
1554 ">> ",
1555 Style::default()
1556 .fg(Color::Rgb(130, 190, 255))
1557 .add_modifier(Modifier::BOLD),
1558 ),
1559 3 => (
1560 "> ",
1561 Style::default()
1562 .fg(Color::Rgb(160, 200, 255))
1563 .add_modifier(Modifier::BOLD),
1564 ),
1565 _ => (
1566 "> ",
1567 Style::default()
1568 .fg(Color::Rgb(180, 210, 255))
1569 .add_modifier(Modifier::BOLD),
1570 ),
1571 };
1572 current_spans.push(Span::styled(prefix.to_string(), prefix_style));
1573 heading_level = None; }
1575
1576 let existing_w: usize = current_spans
1578 .iter()
1579 .map(|s| display_width(&s.content))
1580 .sum();
1581
1582 let effective_prefix_w = if in_blockquote { 2 } else { 0 }; let wrap_w = content_width.saturating_sub(effective_prefix_w + existing_w);
1585
1586 for (i, line) in text_str.split('\n').enumerate() {
1587 if i > 0 {
1588 flush_line(&mut current_spans, &mut lines);
1589 if in_blockquote {
1590 current_spans.push(Span::styled(
1591 "| ".to_string(),
1592 Style::default().fg(Color::Rgb(80, 100, 140)),
1593 ));
1594 }
1595 }
1596 if !line.is_empty() {
1597 let effective_wrap = if i == 0 {
1599 wrap_w
1600 } else {
1601 content_width.saturating_sub(effective_prefix_w)
1602 };
1603 let wrapped = wrap_text(line, effective_wrap);
1604 for (j, wl) in wrapped.iter().enumerate() {
1605 if j > 0 {
1606 flush_line(&mut current_spans, &mut lines);
1607 if in_blockquote {
1608 current_spans.push(Span::styled(
1609 "| ".to_string(),
1610 Style::default().fg(Color::Rgb(80, 100, 140)),
1611 ));
1612 }
1613 }
1614 current_spans.push(Span::styled(wl.clone(), style));
1615 }
1616 }
1617 }
1618 }
1619 }
1620 Event::SoftBreak => {
1621 if in_table {
1622 current_cell.push(' ');
1623 } else {
1624 current_spans.push(Span::raw(" "));
1625 }
1626 }
1627 Event::HardBreak => {
1628 if in_table {
1629 current_cell.push(' ');
1630 } else {
1631 flush_line(&mut current_spans, &mut lines);
1632 }
1633 }
1634 Event::Rule => {
1635 flush_line(&mut current_spans, &mut lines);
1636 lines.push(Line::from(Span::styled(
1637 "─".repeat(content_width),
1638 Style::default().fg(Color::Rgb(70, 75, 90)),
1639 )));
1640 }
1641 Event::Start(Tag::Table(alignments)) => {
1643 flush_line(&mut current_spans, &mut lines);
1644 in_table = true;
1645 table_rows.clear();
1646 table_alignments = alignments;
1647 }
1648 Event::End(TagEnd::Table) => {
1649 flush_line(&mut current_spans, &mut lines);
1651 in_table = false;
1652
1653 if !table_rows.is_empty() {
1654 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
1655 if num_cols > 0 {
1656 let mut col_widths: Vec<usize> = vec![0; num_cols];
1658 for row in &table_rows {
1659 for (i, cell) in row.iter().enumerate() {
1660 let w = display_width(cell);
1661 if w > col_widths[i] {
1662 col_widths[i] = w;
1663 }
1664 }
1665 }
1666
1667 let sep_w = num_cols + 1; let pad_w = num_cols * 2; let avail = content_width.saturating_sub(sep_w + pad_w);
1671 let max_col_w = avail * 2 / 3;
1673 for cw in col_widths.iter_mut() {
1674 if *cw > max_col_w {
1675 *cw = max_col_w;
1676 }
1677 }
1678 let total_col_w: usize = col_widths.iter().sum();
1679 if total_col_w > avail && total_col_w > 0 {
1680 let mut remaining = avail;
1682 for (i, cw) in col_widths.iter_mut().enumerate() {
1683 if i == num_cols - 1 {
1684 *cw = remaining.max(1);
1686 } else {
1687 *cw = ((*cw) * avail / total_col_w).max(1);
1688 remaining = remaining.saturating_sub(*cw);
1689 }
1690 }
1691 }
1692
1693 let table_style = Style::default().fg(Color::Rgb(180, 180, 200));
1694 let header_style = Style::default()
1695 .fg(Color::Rgb(120, 180, 255))
1696 .add_modifier(Modifier::BOLD);
1697 let border_style = Style::default().fg(Color::Rgb(60, 70, 100));
1698
1699 let mut top = String::from("┌");
1701 for (i, cw) in col_widths.iter().enumerate() {
1702 top.push_str(&"─".repeat(cw + 2));
1703 if i < num_cols - 1 {
1704 top.push('┬');
1705 }
1706 }
1707 top.push('┐');
1708 lines.push(Line::from(Span::styled(top, border_style)));
1709
1710 for (row_idx, row) in table_rows.iter().enumerate() {
1711 let mut row_spans: Vec<Span> = Vec::new();
1713 row_spans.push(Span::styled("│", border_style));
1714 for (i, cw) in col_widths.iter().enumerate() {
1715 let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
1716 let cell_w = display_width(cell_text);
1717 let text = if cell_w > *cw {
1718 let mut t = String::new();
1720 let mut w = 0;
1721 for ch in cell_text.chars() {
1722 let chw = char_width(ch);
1723 if w + chw > *cw {
1724 break;
1725 }
1726 t.push(ch);
1727 w += chw;
1728 }
1729 let fill = cw.saturating_sub(w);
1730 format!(" {}{} ", t, " ".repeat(fill))
1731 } else {
1732 let fill = cw.saturating_sub(cell_w);
1734 let align = table_alignments
1735 .get(i)
1736 .copied()
1737 .unwrap_or(pulldown_cmark::Alignment::None);
1738 match align {
1739 pulldown_cmark::Alignment::Center => {
1740 let left = fill / 2;
1741 let right = fill - left;
1742 format!(
1743 " {}{}{} ",
1744 " ".repeat(left),
1745 cell_text,
1746 " ".repeat(right)
1747 )
1748 }
1749 pulldown_cmark::Alignment::Right => {
1750 format!(" {}{} ", " ".repeat(fill), cell_text)
1751 }
1752 _ => {
1753 format!(" {}{} ", cell_text, " ".repeat(fill))
1754 }
1755 }
1756 };
1757 let style = if row_idx == 0 {
1758 header_style
1759 } else {
1760 table_style
1761 };
1762 row_spans.push(Span::styled(text, style));
1763 row_spans.push(Span::styled("│", border_style));
1764 }
1765 lines.push(Line::from(row_spans));
1766
1767 if row_idx == 0 {
1769 let mut sep = String::from("├");
1770 for (i, cw) in col_widths.iter().enumerate() {
1771 sep.push_str(&"─".repeat(cw + 2));
1772 if i < num_cols - 1 {
1773 sep.push('┼');
1774 }
1775 }
1776 sep.push('┤');
1777 lines.push(Line::from(Span::styled(sep, border_style)));
1778 }
1779 }
1780
1781 let mut bottom = String::from("└");
1783 for (i, cw) in col_widths.iter().enumerate() {
1784 bottom.push_str(&"─".repeat(cw + 2));
1785 if i < num_cols - 1 {
1786 bottom.push('┴');
1787 }
1788 }
1789 bottom.push('┘');
1790 lines.push(Line::from(Span::styled(bottom, border_style)));
1791 }
1792 }
1793 table_rows.clear();
1794 table_alignments.clear();
1795 }
1796 Event::Start(Tag::TableHead) => {
1797 current_row.clear();
1798 }
1799 Event::End(TagEnd::TableHead) => {
1800 table_rows.push(current_row.clone());
1801 current_row.clear();
1802 }
1803 Event::Start(Tag::TableRow) => {
1804 current_row.clear();
1805 }
1806 Event::End(TagEnd::TableRow) => {
1807 table_rows.push(current_row.clone());
1808 current_row.clear();
1809 }
1810 Event::Start(Tag::TableCell) => {
1811 current_cell.clear();
1812 }
1813 Event::End(TagEnd::TableCell) => {
1814 current_row.push(current_cell.clone());
1815 current_cell.clear();
1816 }
1817 _ => {}
1818 }
1819 }
1820
1821 if !current_spans.is_empty() {
1823 lines.push(Line::from(current_spans));
1824 }
1825
1826 if lines.is_empty() {
1828 let wrapped = wrap_text(md, content_width);
1829 for wl in wrapped {
1830 lines.push(Line::from(Span::styled(wl, base_style)));
1831 }
1832 }
1833
1834 lines
1835}
1836
1837fn highlight_code_line<'a>(line: &'a str, lang: &str) -> Vec<Span<'static>> {
1840 let lang_lower = lang.to_lowercase();
1841 let keywords: &[&str] = match lang_lower.as_str() {
1842 "rust" | "rs" => &[
1843 "fn", "let", "mut", "pub", "use", "mod", "struct", "enum", "impl", "trait", "for",
1844 "while", "loop", "if", "else", "match", "return", "self", "Self", "where", "async",
1845 "await", "move", "ref", "type", "const", "static", "crate", "super", "as", "in",
1846 "true", "false", "Some", "None", "Ok", "Err",
1847 ],
1848 "python" | "py" => &[
1849 "def", "class", "return", "if", "elif", "else", "for", "while", "import", "from", "as",
1850 "with", "try", "except", "finally", "raise", "pass", "break", "continue", "yield",
1851 "lambda", "and", "or", "not", "in", "is", "True", "False", "None", "global",
1852 "nonlocal", "assert", "del", "async", "await", "self", "print",
1853 ],
1854 "javascript" | "js" | "typescript" | "ts" | "jsx" | "tsx" => &[
1855 "function",
1856 "const",
1857 "let",
1858 "var",
1859 "return",
1860 "if",
1861 "else",
1862 "for",
1863 "while",
1864 "class",
1865 "new",
1866 "this",
1867 "import",
1868 "export",
1869 "from",
1870 "default",
1871 "async",
1872 "await",
1873 "try",
1874 "catch",
1875 "finally",
1876 "throw",
1877 "typeof",
1878 "instanceof",
1879 "true",
1880 "false",
1881 "null",
1882 "undefined",
1883 "of",
1884 "in",
1885 "switch",
1886 "case",
1887 ],
1888 "go" | "golang" => &[
1889 "func",
1890 "package",
1891 "import",
1892 "return",
1893 "if",
1894 "else",
1895 "for",
1896 "range",
1897 "struct",
1898 "interface",
1899 "type",
1900 "var",
1901 "const",
1902 "defer",
1903 "go",
1904 "chan",
1905 "select",
1906 "case",
1907 "switch",
1908 "default",
1909 "break",
1910 "continue",
1911 "map",
1912 "true",
1913 "false",
1914 "nil",
1915 "make",
1916 "append",
1917 "len",
1918 "cap",
1919 ],
1920 "java" | "kotlin" | "kt" => &[
1921 "public",
1922 "private",
1923 "protected",
1924 "class",
1925 "interface",
1926 "extends",
1927 "implements",
1928 "return",
1929 "if",
1930 "else",
1931 "for",
1932 "while",
1933 "new",
1934 "this",
1935 "import",
1936 "package",
1937 "static",
1938 "final",
1939 "void",
1940 "int",
1941 "String",
1942 "boolean",
1943 "true",
1944 "false",
1945 "null",
1946 "try",
1947 "catch",
1948 "throw",
1949 "throws",
1950 "fun",
1951 "val",
1952 "var",
1953 "when",
1954 "object",
1955 "companion",
1956 ],
1957 "sh" | "bash" | "zsh" | "shell" => &[
1958 "if",
1959 "then",
1960 "else",
1961 "elif",
1962 "fi",
1963 "for",
1964 "while",
1965 "do",
1966 "done",
1967 "case",
1968 "esac",
1969 "function",
1970 "return",
1971 "exit",
1972 "echo",
1973 "export",
1974 "local",
1975 "readonly",
1976 "set",
1977 "unset",
1978 "shift",
1979 "source",
1980 "in",
1981 "true",
1982 "false",
1983 "read",
1984 "declare",
1985 "typeset",
1986 "trap",
1987 "eval",
1988 "exec",
1989 "test",
1990 "select",
1991 "until",
1992 "break",
1993 "continue",
1994 "printf",
1995 "go",
1997 "build",
1998 "run",
1999 "test",
2000 "fmt",
2001 "vet",
2002 "mod",
2003 "get",
2004 "install",
2005 "clean",
2006 "doc",
2007 "list",
2008 "version",
2009 "env",
2010 "generate",
2011 "tool",
2012 "proxy",
2013 "GOPATH",
2014 "GOROOT",
2015 "GOBIN",
2016 "GOMODCACHE",
2017 "GOPROXY",
2018 "GOSUMDB",
2019 "cargo",
2021 "new",
2022 "init",
2023 "add",
2024 "remove",
2025 "update",
2026 "check",
2027 "clippy",
2028 "rustfmt",
2029 "rustc",
2030 "rustup",
2031 "publish",
2032 "install",
2033 "uninstall",
2034 "search",
2035 "tree",
2036 "locate_project",
2037 "metadata",
2038 "audit",
2039 "watch",
2040 "expand",
2041 ],
2042 "c" | "cpp" | "c++" | "h" | "hpp" => &[
2043 "int",
2044 "char",
2045 "float",
2046 "double",
2047 "void",
2048 "long",
2049 "short",
2050 "unsigned",
2051 "signed",
2052 "const",
2053 "static",
2054 "extern",
2055 "struct",
2056 "union",
2057 "enum",
2058 "typedef",
2059 "sizeof",
2060 "return",
2061 "if",
2062 "else",
2063 "for",
2064 "while",
2065 "do",
2066 "switch",
2067 "case",
2068 "break",
2069 "continue",
2070 "default",
2071 "goto",
2072 "auto",
2073 "register",
2074 "volatile",
2075 "class",
2076 "public",
2077 "private",
2078 "protected",
2079 "virtual",
2080 "override",
2081 "template",
2082 "namespace",
2083 "using",
2084 "new",
2085 "delete",
2086 "try",
2087 "catch",
2088 "throw",
2089 "nullptr",
2090 "true",
2091 "false",
2092 "this",
2093 "include",
2094 "define",
2095 "ifdef",
2096 "ifndef",
2097 "endif",
2098 ],
2099 "sql" => &[
2100 "SELECT",
2101 "FROM",
2102 "WHERE",
2103 "INSERT",
2104 "UPDATE",
2105 "DELETE",
2106 "CREATE",
2107 "DROP",
2108 "ALTER",
2109 "TABLE",
2110 "INDEX",
2111 "INTO",
2112 "VALUES",
2113 "SET",
2114 "AND",
2115 "OR",
2116 "NOT",
2117 "NULL",
2118 "JOIN",
2119 "LEFT",
2120 "RIGHT",
2121 "INNER",
2122 "OUTER",
2123 "ON",
2124 "GROUP",
2125 "BY",
2126 "ORDER",
2127 "ASC",
2128 "DESC",
2129 "HAVING",
2130 "LIMIT",
2131 "OFFSET",
2132 "UNION",
2133 "AS",
2134 "DISTINCT",
2135 "COUNT",
2136 "SUM",
2137 "AVG",
2138 "MIN",
2139 "MAX",
2140 "LIKE",
2141 "IN",
2142 "BETWEEN",
2143 "EXISTS",
2144 "CASE",
2145 "WHEN",
2146 "THEN",
2147 "ELSE",
2148 "END",
2149 "BEGIN",
2150 "COMMIT",
2151 "ROLLBACK",
2152 "PRIMARY",
2153 "KEY",
2154 "FOREIGN",
2155 "REFERENCES",
2156 "select",
2157 "from",
2158 "where",
2159 "insert",
2160 "update",
2161 "delete",
2162 "create",
2163 "drop",
2164 "alter",
2165 "table",
2166 "index",
2167 "into",
2168 "values",
2169 "set",
2170 "and",
2171 "or",
2172 "not",
2173 "null",
2174 "join",
2175 "left",
2176 "right",
2177 "inner",
2178 "outer",
2179 "on",
2180 "group",
2181 "by",
2182 "order",
2183 "asc",
2184 "desc",
2185 "having",
2186 "limit",
2187 "offset",
2188 "union",
2189 "as",
2190 "distinct",
2191 "count",
2192 "sum",
2193 "avg",
2194 "min",
2195 "max",
2196 "like",
2197 "in",
2198 "between",
2199 "exists",
2200 "case",
2201 "when",
2202 "then",
2203 "else",
2204 "end",
2205 "begin",
2206 "commit",
2207 "rollback",
2208 "primary",
2209 "key",
2210 "foreign",
2211 "references",
2212 ],
2213 "yaml" | "yml" => &["true", "false", "null", "yes", "no", "on", "off"],
2214 "toml" => &[
2215 "true",
2216 "false",
2217 "true",
2218 "false",
2219 "name",
2221 "version",
2222 "edition",
2223 "authors",
2224 "dependencies",
2225 "dev-dependencies",
2226 "build-dependencies",
2227 "features",
2228 "workspace",
2229 "members",
2230 "exclude",
2231 "include",
2232 "path",
2233 "git",
2234 "branch",
2235 "tag",
2236 "rev",
2237 "package",
2238 "lib",
2239 "bin",
2240 "example",
2241 "test",
2242 "bench",
2243 "doc",
2244 "profile",
2245 "release",
2246 "debug",
2247 "opt-level",
2248 "lto",
2249 "codegen-units",
2250 "panic",
2251 "strip",
2252 "default",
2253 "features",
2254 "optional",
2255 "repository",
2257 "homepage",
2258 "documentation",
2259 "license",
2260 "license-file",
2261 "keywords",
2262 "categories",
2263 "readme",
2264 "description",
2265 "resolver",
2266 ],
2267 "css" | "scss" | "less" => &[
2268 "color",
2269 "background",
2270 "border",
2271 "margin",
2272 "padding",
2273 "display",
2274 "position",
2275 "width",
2276 "height",
2277 "font",
2278 "text",
2279 "flex",
2280 "grid",
2281 "align",
2282 "justify",
2283 "important",
2284 "none",
2285 "auto",
2286 "inherit",
2287 "initial",
2288 "unset",
2289 ],
2290 "dockerfile" | "docker" => &[
2291 "FROM",
2292 "RUN",
2293 "CMD",
2294 "LABEL",
2295 "EXPOSE",
2296 "ENV",
2297 "ADD",
2298 "COPY",
2299 "ENTRYPOINT",
2300 "VOLUME",
2301 "USER",
2302 "WORKDIR",
2303 "ARG",
2304 "ONBUILD",
2305 "STOPSIGNAL",
2306 "HEALTHCHECK",
2307 "SHELL",
2308 "AS",
2309 ],
2310 "ruby" | "rb" => &[
2311 "def", "end", "class", "module", "if", "elsif", "else", "unless", "while", "until",
2312 "for", "do", "begin", "rescue", "ensure", "raise", "return", "yield", "require",
2313 "include", "attr", "self", "true", "false", "nil", "puts", "print",
2314 ],
2315 _ => &[
2316 "fn", "function", "def", "class", "return", "if", "else", "for", "while", "import",
2317 "export", "const", "let", "var", "true", "false", "null", "nil", "None", "self",
2318 "this",
2319 ],
2320 };
2321
2322 let comment_prefix = match lang_lower.as_str() {
2323 "python" | "py" | "sh" | "bash" | "zsh" | "shell" | "ruby" | "rb" | "yaml" | "yml"
2324 | "toml" | "dockerfile" | "docker" => "#",
2325 "sql" => "--",
2326 "css" | "scss" | "less" => "/*",
2327 _ => "//",
2328 };
2329
2330 let code_style = Style::default().fg(Color::Rgb(200, 200, 210));
2332 let kw_style = Style::default().fg(Color::Rgb(198, 120, 221));
2334 let str_style = Style::default().fg(Color::Rgb(152, 195, 121));
2336 let comment_style = Style::default()
2338 .fg(Color::Rgb(92, 99, 112))
2339 .add_modifier(Modifier::ITALIC);
2340 let num_style = Style::default().fg(Color::Rgb(209, 154, 102));
2342 let type_style = Style::default().fg(Color::Rgb(229, 192, 123));
2344
2345 let trimmed = line.trim_start();
2346
2347 if trimmed.starts_with(comment_prefix) {
2349 return vec![Span::styled(line.to_string(), comment_style)];
2350 }
2351
2352 let mut spans = Vec::new();
2354 let mut chars = line.chars().peekable();
2355 let mut buf = String::new();
2356
2357 while let Some(&ch) = chars.peek() {
2358 if ch == '"' || ch == '\'' || ch == '`' {
2360 if !buf.is_empty() {
2362 spans.extend(colorize_tokens(
2363 &buf, keywords, code_style, kw_style, num_style, type_style,
2364 ));
2365 buf.clear();
2366 }
2367 let quote = ch;
2368 let mut s = String::new();
2369 s.push(ch);
2370 chars.next();
2371 while let Some(&c) = chars.peek() {
2372 s.push(c);
2373 chars.next();
2374 if c == quote && !s.ends_with("\\\\") {
2375 break;
2376 }
2377 }
2378 spans.push(Span::styled(s, str_style));
2379 continue;
2380 }
2381 if ch == '$'
2383 && matches!(
2384 lang_lower.as_str(),
2385 "sh" | "bash" | "zsh" | "shell" | "dockerfile" | "docker"
2386 )
2387 {
2388 if !buf.is_empty() {
2389 spans.extend(colorize_tokens(
2390 &buf, keywords, code_style, kw_style, num_style, type_style,
2391 ));
2392 buf.clear();
2393 }
2394 let var_style = Style::default().fg(Color::Rgb(86, 182, 194));
2395 let mut var = String::new();
2396 var.push(ch);
2397 chars.next();
2398 if let Some(&next_ch) = chars.peek() {
2399 if next_ch == '{' {
2400 var.push(next_ch);
2402 chars.next();
2403 while let Some(&c) = chars.peek() {
2404 var.push(c);
2405 chars.next();
2406 if c == '}' {
2407 break;
2408 }
2409 }
2410 } else if next_ch == '(' {
2411 var.push(next_ch);
2413 chars.next();
2414 let mut depth = 1;
2415 while let Some(&c) = chars.peek() {
2416 var.push(c);
2417 chars.next();
2418 if c == '(' {
2419 depth += 1;
2420 }
2421 if c == ')' {
2422 depth -= 1;
2423 if depth == 0 {
2424 break;
2425 }
2426 }
2427 }
2428 } else if next_ch.is_alphanumeric()
2429 || next_ch == '_'
2430 || next_ch == '@'
2431 || next_ch == '#'
2432 || next_ch == '?'
2433 || next_ch == '!'
2434 {
2435 while let Some(&c) = chars.peek() {
2437 if c.is_alphanumeric() || c == '_' {
2438 var.push(c);
2439 chars.next();
2440 } else {
2441 break;
2442 }
2443 }
2444 }
2445 }
2446 spans.push(Span::styled(var, var_style));
2447 continue;
2448 }
2449 if ch == '/' || ch == '#' {
2451 let rest: String = chars.clone().collect();
2452 if rest.starts_with(comment_prefix) {
2453 if !buf.is_empty() {
2454 spans.extend(colorize_tokens(
2455 &buf, keywords, code_style, kw_style, num_style, type_style,
2456 ));
2457 buf.clear();
2458 }
2459 spans.push(Span::styled(rest, comment_style));
2460 break;
2461 }
2462 }
2463 buf.push(ch);
2464 chars.next();
2465 }
2466
2467 if !buf.is_empty() {
2468 spans.extend(colorize_tokens(
2469 &buf, keywords, code_style, kw_style, num_style, type_style,
2470 ));
2471 }
2472
2473 if spans.is_empty() {
2474 spans.push(Span::styled(line.to_string(), code_style));
2475 }
2476
2477 spans
2478}
2479
2480fn colorize_tokens<'a>(
2482 text: &str,
2483 keywords: &[&str],
2484 default_style: Style,
2485 kw_style: Style,
2486 num_style: Style,
2487 type_style: Style,
2488) -> Vec<Span<'static>> {
2489 let mut spans = Vec::new();
2490 let mut current_word = String::new();
2491 let mut current_non_word = String::new();
2492
2493 for ch in text.chars() {
2494 if ch.is_alphanumeric() || ch == '_' {
2495 if !current_non_word.is_empty() {
2496 spans.push(Span::styled(current_non_word.clone(), default_style));
2497 current_non_word.clear();
2498 }
2499 current_word.push(ch);
2500 } else {
2501 if !current_word.is_empty() {
2502 let style = if keywords.contains(¤t_word.as_str()) {
2503 kw_style
2504 } else if current_word
2505 .chars()
2506 .next()
2507 .map(|c| c.is_ascii_digit())
2508 .unwrap_or(false)
2509 {
2510 num_style
2511 } else if current_word
2512 .chars()
2513 .next()
2514 .map(|c| c.is_uppercase())
2515 .unwrap_or(false)
2516 {
2517 type_style
2518 } else {
2519 default_style
2520 };
2521 spans.push(Span::styled(current_word.clone(), style));
2522 current_word.clear();
2523 }
2524 current_non_word.push(ch);
2525 }
2526 }
2527
2528 if !current_non_word.is_empty() {
2530 spans.push(Span::styled(current_non_word, default_style));
2531 }
2532 if !current_word.is_empty() {
2533 let style = if keywords.contains(¤t_word.as_str()) {
2534 kw_style
2535 } else if current_word
2536 .chars()
2537 .next()
2538 .map(|c| c.is_ascii_digit())
2539 .unwrap_or(false)
2540 {
2541 num_style
2542 } else if current_word
2543 .chars()
2544 .next()
2545 .map(|c| c.is_uppercase())
2546 .unwrap_or(false)
2547 {
2548 type_style
2549 } else {
2550 default_style
2551 };
2552 spans.push(Span::styled(current_word, style));
2553 }
2554
2555 spans
2556}
2557
2558fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
2560 if max_width == 0 {
2561 return vec![text.to_string()];
2562 }
2563 let mut result = Vec::new();
2564 let mut current_line = String::new();
2565 let mut current_width = 0;
2566
2567 for ch in text.chars() {
2568 let ch_width = char_width(ch);
2569 if current_width + ch_width > max_width && !current_line.is_empty() {
2570 result.push(current_line.clone());
2571 current_line.clear();
2572 current_width = 0;
2573 }
2574 current_line.push(ch);
2575 current_width += ch_width;
2576 }
2577 if !current_line.is_empty() {
2578 result.push(current_line);
2579 }
2580 if result.is_empty() {
2581 result.push(String::new());
2582 }
2583 result
2584}
2585
2586fn display_width(s: &str) -> usize {
2589 s.chars().map(|c| char_width(c)).sum()
2590}
2591
2592fn char_width(c: char) -> usize {
2594 if c.is_ascii() {
2595 return 1;
2596 }
2597 let cp = c as u32;
2599 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)
2616 || (0x2600..=0x26FF).contains(&cp)
2617 || (0x2700..=0x27BF).contains(&cp)
2618 {
2619 2
2620 } else {
2621 1
2622 }
2623}
2624
2625fn draw_input(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2627 let usable_width = area.width.saturating_sub(2 + 4) as usize;
2629
2630 let chars: Vec<char> = app.input.chars().collect();
2631
2632 let before_all: String = chars[..app.cursor_pos].iter().collect();
2634 let before_width = display_width(&before_all);
2635
2636 let scroll_offset_chars = if before_width >= usable_width {
2638 let target_width = before_width.saturating_sub(usable_width / 2);
2640 let mut w = 0;
2641 let mut skip = 0;
2642 for (i, &ch) in chars.iter().enumerate() {
2643 if w >= target_width {
2644 skip = i;
2645 break;
2646 }
2647 w += char_width(ch);
2648 }
2649 skip
2650 } else {
2651 0
2652 };
2653
2654 let visible_chars = &chars[scroll_offset_chars..];
2656 let cursor_in_visible = app.cursor_pos - scroll_offset_chars;
2657
2658 let before: String = visible_chars[..cursor_in_visible].iter().collect();
2659 let cursor_ch = if cursor_in_visible < visible_chars.len() {
2660 visible_chars[cursor_in_visible].to_string()
2661 } else {
2662 " ".to_string()
2663 };
2664 let after: String = if cursor_in_visible < visible_chars.len() {
2665 visible_chars[cursor_in_visible + 1..].iter().collect()
2666 } else {
2667 String::new()
2668 };
2669
2670 let prompt_style = if app.is_loading {
2671 Style::default().fg(Color::Rgb(255, 200, 80))
2672 } else {
2673 Style::default().fg(Color::Rgb(100, 200, 130))
2674 };
2675 let prompt_text = if app.is_loading { " .. " } else { " > " };
2676
2677 let full_visible = format!("{}{}{}", before, cursor_ch, after);
2679 let inner_height = area.height.saturating_sub(2) as usize; let wrapped_lines = wrap_text(&full_visible, usable_width);
2681
2682 let before_len = before.chars().count();
2684 let cursor_len = cursor_ch.chars().count();
2685 let cursor_global_pos = before_len; let mut cursor_line_idx: usize = 0;
2687 {
2688 let mut cumulative = 0usize;
2689 for (li, wl) in wrapped_lines.iter().enumerate() {
2690 let line_char_count = wl.chars().count();
2691 if cumulative + line_char_count > cursor_global_pos {
2692 cursor_line_idx = li;
2693 break;
2694 }
2695 cumulative += line_char_count;
2696 cursor_line_idx = li; }
2698 }
2699
2700 let line_scroll = if wrapped_lines.len() <= inner_height {
2702 0
2703 } else if cursor_line_idx < inner_height {
2704 0
2705 } else {
2706 cursor_line_idx.saturating_sub(inner_height - 1)
2708 };
2709
2710 let mut display_lines: Vec<Line> = Vec::new();
2712 let mut char_offset: usize = 0;
2713 for wl in wrapped_lines.iter().take(line_scroll) {
2715 char_offset += wl.chars().count();
2716 }
2717
2718 for (_line_idx, wl) in wrapped_lines
2719 .iter()
2720 .skip(line_scroll)
2721 .enumerate()
2722 .take(inner_height.max(1))
2723 {
2724 let mut spans: Vec<Span> = Vec::new();
2725 if _line_idx == 0 && line_scroll == 0 {
2726 spans.push(Span::styled(prompt_text, prompt_style));
2727 } else {
2728 spans.push(Span::styled(" ", Style::default())); }
2730
2731 let line_chars: Vec<char> = wl.chars().collect();
2733 let mut seg_start = 0;
2734 for (ci, &ch) in line_chars.iter().enumerate() {
2735 let global_idx = char_offset + ci;
2736 let is_cursor = global_idx >= before_len && global_idx < before_len + cursor_len;
2737
2738 if is_cursor {
2739 if ci > seg_start {
2741 let seg: String = line_chars[seg_start..ci].iter().collect();
2742 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
2743 }
2744 spans.push(Span::styled(
2745 ch.to_string(),
2746 Style::default()
2747 .fg(Color::Rgb(22, 22, 30))
2748 .bg(Color::Rgb(200, 210, 240)),
2749 ));
2750 seg_start = ci + 1;
2751 }
2752 }
2753 if seg_start < line_chars.len() {
2755 let seg: String = line_chars[seg_start..].iter().collect();
2756 spans.push(Span::styled(seg, Style::default().fg(Color::White)));
2757 }
2758
2759 char_offset += line_chars.len();
2760 display_lines.push(Line::from(spans));
2761 }
2762
2763 if display_lines.is_empty() {
2764 display_lines.push(Line::from(vec![
2765 Span::styled(prompt_text, prompt_style),
2766 Span::styled(
2767 " ",
2768 Style::default()
2769 .fg(Color::Rgb(22, 22, 30))
2770 .bg(Color::Rgb(200, 210, 240)),
2771 ),
2772 ]));
2773 }
2774
2775 let input_widget = Paragraph::new(display_lines).block(
2776 Block::default()
2777 .borders(Borders::ALL)
2778 .border_type(ratatui::widgets::BorderType::Rounded)
2779 .border_style(if app.is_loading {
2780 Style::default().fg(Color::Rgb(120, 100, 50))
2781 } else {
2782 Style::default().fg(Color::Rgb(60, 100, 80))
2783 })
2784 .title(Span::styled(
2785 " 输入消息 ",
2786 Style::default().fg(Color::Rgb(140, 140, 170)),
2787 ))
2788 .style(Style::default().bg(Color::Rgb(26, 26, 38))),
2789 );
2790
2791 f.render_widget(input_widget, area);
2792
2793 if !app.is_loading {
2796 let prompt_w: u16 = 4; let border_left: u16 = 1; let cursor_col_in_line = {
2801 let mut col = 0usize;
2802 let mut char_count = 0usize;
2803 let mut skip_chars = 0usize;
2805 for wl in wrapped_lines.iter().take(line_scroll) {
2806 skip_chars += wl.chars().count();
2807 }
2808 for wl in wrapped_lines.iter().skip(line_scroll) {
2810 let line_len = wl.chars().count();
2811 if skip_chars + char_count + line_len > cursor_global_pos {
2812 let pos_in_line = cursor_global_pos - (skip_chars + char_count);
2814 col = wl.chars().take(pos_in_line).map(|c| char_width(c)).sum();
2815 break;
2816 }
2817 char_count += line_len;
2818 }
2819 col as u16
2820 };
2821
2822 let cursor_row_in_display = (cursor_line_idx - line_scroll) as u16;
2824
2825 let cursor_x = area.x + border_left + prompt_w + cursor_col_in_line;
2826 let cursor_y = area.y + 1 + cursor_row_in_display; if cursor_x < area.x + area.width && cursor_y < area.y + area.height {
2830 f.set_cursor_position((cursor_x, cursor_y));
2831 }
2832 }
2833}
2834
2835fn draw_hint_bar(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2837 let hints = match app.mode {
2838 ChatMode::Chat => {
2839 vec![
2840 ("Enter", "发送"),
2841 ("↑↓", "滚动"),
2842 ("Ctrl+T", "切换模型"),
2843 ("Ctrl+L", "清空"),
2844 ("Ctrl+Y", "复制"),
2845 ("Ctrl+B", "浏览"),
2846 ("Ctrl+S", "流式切换"),
2847 ("?/F1", "帮助"),
2848 ("Esc", "退出"),
2849 ]
2850 }
2851 ChatMode::SelectModel => {
2852 vec![("↑↓/jk", "移动"), ("Enter", "确认"), ("Esc", "取消")]
2853 }
2854 ChatMode::Browse => {
2855 vec![("↑↓", "选择消息"), ("y/Enter", "复制"), ("Esc", "返回")]
2856 }
2857 ChatMode::Help => {
2858 vec![("任意键", "返回")]
2859 }
2860 };
2861
2862 let mut spans: Vec<Span> = Vec::new();
2863 spans.push(Span::styled(" ", Style::default()));
2864 for (i, (key, desc)) in hints.iter().enumerate() {
2865 if i > 0 {
2866 spans.push(Span::styled(
2867 " │ ",
2868 Style::default().fg(Color::Rgb(50, 50, 65)),
2869 ));
2870 }
2871 spans.push(Span::styled(
2872 format!(" {} ", key),
2873 Style::default()
2874 .fg(Color::Rgb(22, 22, 30))
2875 .bg(Color::Rgb(100, 110, 140)),
2876 ));
2877 spans.push(Span::styled(
2878 format!(" {}", desc),
2879 Style::default().fg(Color::Rgb(120, 120, 150)),
2880 ));
2881 }
2882
2883 let hint_bar =
2884 Paragraph::new(Line::from(spans)).style(Style::default().bg(Color::Rgb(22, 22, 30)));
2885 f.render_widget(hint_bar, area);
2886}
2887
2888fn draw_toast(f: &mut ratatui::Frame, area: Rect, app: &ChatApp) {
2890 if let Some((ref msg, is_error, _)) = app.toast {
2891 let text_width = display_width(msg);
2892 let toast_width = (text_width + 10).min(area.width as usize).max(16) as u16;
2894 let toast_height: u16 = 3;
2895
2896 let x = area.width.saturating_sub(toast_width + 1);
2898 let y: u16 = 1;
2899
2900 if x + toast_width <= area.width && y + toast_height <= area.height {
2901 let toast_area = Rect::new(x, y, toast_width, toast_height);
2902
2903 let clear = Block::default().style(Style::default().bg(if is_error {
2905 Color::Rgb(60, 20, 20)
2906 } else {
2907 Color::Rgb(20, 50, 30)
2908 }));
2909 f.render_widget(clear, toast_area);
2910
2911 let (icon, border_color, text_color) = if is_error {
2912 ("❌", Color::Rgb(200, 70, 70), Color::Rgb(255, 130, 130))
2913 } else {
2914 ("✅", Color::Rgb(60, 160, 80), Color::Rgb(140, 230, 160))
2915 };
2916
2917 let toast_widget = Paragraph::new(Line::from(vec![
2918 Span::styled(format!(" {} ", icon), Style::default()),
2919 Span::styled(msg.as_str(), Style::default().fg(text_color)),
2920 ]))
2921 .block(
2922 Block::default()
2923 .borders(Borders::ALL)
2924 .border_type(ratatui::widgets::BorderType::Rounded)
2925 .border_style(Style::default().fg(border_color))
2926 .style(Style::default().bg(if is_error {
2927 Color::Rgb(50, 18, 18)
2928 } else {
2929 Color::Rgb(18, 40, 25)
2930 })),
2931 );
2932 f.render_widget(toast_widget, toast_area);
2933 }
2934 }
2935}
2936
2937fn draw_model_selector(f: &mut ratatui::Frame, area: Rect, app: &mut ChatApp) {
2939 let items: Vec<ListItem> = app
2940 .agent_config
2941 .providers
2942 .iter()
2943 .enumerate()
2944 .map(|(i, p)| {
2945 let is_active = i == app.agent_config.active_index;
2946 let marker = if is_active { " ● " } else { " ○ " };
2947 let style = if is_active {
2948 Style::default()
2949 .fg(Color::Rgb(120, 220, 160))
2950 .add_modifier(Modifier::BOLD)
2951 } else {
2952 Style::default().fg(Color::Rgb(180, 180, 200))
2953 };
2954 let detail = format!("{}{} ({})", marker, p.name, p.model);
2955 ListItem::new(Line::from(Span::styled(detail, style)))
2956 })
2957 .collect();
2958
2959 let list = List::new(items)
2960 .block(
2961 Block::default()
2962 .borders(Borders::ALL)
2963 .border_type(ratatui::widgets::BorderType::Rounded)
2964 .border_style(Style::default().fg(Color::Rgb(180, 160, 80)))
2965 .title(Span::styled(
2966 " 🔄 选择模型 ",
2967 Style::default()
2968 .fg(Color::Rgb(230, 210, 120))
2969 .add_modifier(Modifier::BOLD),
2970 ))
2971 .style(Style::default().bg(Color::Rgb(28, 28, 40))),
2972 )
2973 .highlight_style(
2974 Style::default()
2975 .bg(Color::Rgb(50, 55, 80))
2976 .fg(Color::White)
2977 .add_modifier(Modifier::BOLD),
2978 )
2979 .highlight_symbol(" ▸ ");
2980
2981 f.render_stateful_widget(list, area, &mut app.model_list_state);
2982}
2983
2984fn draw_help(f: &mut ratatui::Frame, area: Rect) {
2986 let separator = Line::from(Span::styled(
2987 " ─────────────────────────────────────────",
2988 Style::default().fg(Color::Rgb(50, 55, 70)),
2989 ));
2990
2991 let help_lines = vec![
2992 Line::from(""),
2993 Line::from(Span::styled(
2994 " 📖 快捷键帮助",
2995 Style::default()
2996 .fg(Color::Rgb(120, 180, 255))
2997 .add_modifier(Modifier::BOLD),
2998 )),
2999 Line::from(""),
3000 separator.clone(),
3001 Line::from(""),
3002 Line::from(vec![
3003 Span::styled(
3004 " Enter ",
3005 Style::default()
3006 .fg(Color::Rgb(230, 210, 120))
3007 .add_modifier(Modifier::BOLD),
3008 ),
3009 Span::styled("发送消息", Style::default().fg(Color::Rgb(200, 200, 220))),
3010 ]),
3011 Line::from(vec![
3012 Span::styled(
3013 " ↑ / ↓ ",
3014 Style::default()
3015 .fg(Color::Rgb(230, 210, 120))
3016 .add_modifier(Modifier::BOLD),
3017 ),
3018 Span::styled(
3019 "滚动对话记录",
3020 Style::default().fg(Color::Rgb(200, 200, 220)),
3021 ),
3022 ]),
3023 Line::from(vec![
3024 Span::styled(
3025 " ← / → ",
3026 Style::default()
3027 .fg(Color::Rgb(230, 210, 120))
3028 .add_modifier(Modifier::BOLD),
3029 ),
3030 Span::styled(
3031 "移动输入光标",
3032 Style::default().fg(Color::Rgb(200, 200, 220)),
3033 ),
3034 ]),
3035 Line::from(vec![
3036 Span::styled(
3037 " Ctrl+T ",
3038 Style::default()
3039 .fg(Color::Rgb(230, 210, 120))
3040 .add_modifier(Modifier::BOLD),
3041 ),
3042 Span::styled("切换模型", Style::default().fg(Color::Rgb(200, 200, 220))),
3043 ]),
3044 Line::from(vec![
3045 Span::styled(
3046 " Ctrl+L ",
3047 Style::default()
3048 .fg(Color::Rgb(230, 210, 120))
3049 .add_modifier(Modifier::BOLD),
3050 ),
3051 Span::styled(
3052 "清空对话历史",
3053 Style::default().fg(Color::Rgb(200, 200, 220)),
3054 ),
3055 ]),
3056 Line::from(vec![
3057 Span::styled(
3058 " Ctrl+Y ",
3059 Style::default()
3060 .fg(Color::Rgb(230, 210, 120))
3061 .add_modifier(Modifier::BOLD),
3062 ),
3063 Span::styled(
3064 "复制最后一条 AI 回复",
3065 Style::default().fg(Color::Rgb(200, 200, 220)),
3066 ),
3067 ]),
3068 Line::from(vec![
3069 Span::styled(
3070 " Ctrl+B ",
3071 Style::default()
3072 .fg(Color::Rgb(230, 210, 120))
3073 .add_modifier(Modifier::BOLD),
3074 ),
3075 Span::styled(
3076 "浏览消息 (↑↓选择, y/Enter复制)",
3077 Style::default().fg(Color::Rgb(200, 200, 220)),
3078 ),
3079 ]),
3080 Line::from(vec![
3081 Span::styled(
3082 " Ctrl+S ",
3083 Style::default()
3084 .fg(Color::Rgb(230, 210, 120))
3085 .add_modifier(Modifier::BOLD),
3086 ),
3087 Span::styled(
3088 "切换流式/整体输出",
3089 Style::default().fg(Color::Rgb(200, 200, 220)),
3090 ),
3091 ]),
3092 Line::from(vec![
3093 Span::styled(
3094 " Esc / Ctrl+C ",
3095 Style::default()
3096 .fg(Color::Rgb(230, 210, 120))
3097 .add_modifier(Modifier::BOLD),
3098 ),
3099 Span::styled("退出对话", Style::default().fg(Color::Rgb(200, 200, 220))),
3100 ]),
3101 Line::from(vec![
3102 Span::styled(
3103 " ? / F1 ",
3104 Style::default()
3105 .fg(Color::Rgb(230, 210, 120))
3106 .add_modifier(Modifier::BOLD),
3107 ),
3108 Span::styled(
3109 "显示 / 关闭此帮助",
3110 Style::default().fg(Color::Rgb(200, 200, 220)),
3111 ),
3112 ]),
3113 Line::from(""),
3114 separator,
3115 Line::from(""),
3116 Line::from(Span::styled(
3117 " 📁 配置文件:",
3118 Style::default()
3119 .fg(Color::Rgb(120, 180, 255))
3120 .add_modifier(Modifier::BOLD),
3121 )),
3122 Line::from(Span::styled(
3123 format!(" {}", agent_config_path().display()),
3124 Style::default().fg(Color::Rgb(100, 100, 130)),
3125 )),
3126 ];
3127
3128 let help_block = Block::default()
3129 .borders(Borders::ALL)
3130 .border_type(ratatui::widgets::BorderType::Rounded)
3131 .border_style(Style::default().fg(Color::Rgb(80, 100, 140)))
3132 .title(Span::styled(
3133 " 帮助 (按任意键返回) ",
3134 Style::default().fg(Color::Rgb(140, 140, 170)),
3135 ))
3136 .style(Style::default().bg(Color::Rgb(24, 24, 34)));
3137 let help_widget = Paragraph::new(help_lines).block(help_block);
3138 f.render_widget(help_widget, area);
3139}
3140
3141fn handle_chat_mode(app: &mut ChatApp, key: KeyEvent) -> bool {
3143 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
3145 return true;
3146 }
3147
3148 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('t') {
3150 if !app.agent_config.providers.is_empty() {
3151 app.mode = ChatMode::SelectModel;
3152 app.model_list_state
3153 .select(Some(app.agent_config.active_index));
3154 }
3155 return false;
3156 }
3157
3158 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('l') {
3160 app.clear_session();
3161 return false;
3162 }
3163
3164 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('y') {
3166 if let Some(last_ai) = app
3167 .session
3168 .messages
3169 .iter()
3170 .rev()
3171 .find(|m| m.role == "assistant")
3172 {
3173 if copy_to_clipboard(&last_ai.content) {
3174 app.show_toast("已复制最后一条 AI 回复", false);
3175 } else {
3176 app.show_toast("复制到剪切板失败", true);
3177 }
3178 } else {
3179 app.show_toast("暂无 AI 回复可复制", true);
3180 }
3181 return false;
3182 }
3183
3184 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('b') {
3186 if !app.session.messages.is_empty() {
3187 app.browse_msg_index = app.session.messages.len() - 1;
3189 app.mode = ChatMode::Browse;
3190 app.msg_lines_cache = None; } else {
3192 app.show_toast("暂无消息可浏览", true);
3193 }
3194 return false;
3195 }
3196
3197 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
3199 app.agent_config.stream_mode = !app.agent_config.stream_mode;
3200 let _ = save_agent_config(&app.agent_config);
3201 let mode_str = if app.agent_config.stream_mode {
3202 "流式输出"
3203 } else {
3204 "整体输出"
3205 };
3206 app.show_toast(&format!("已切换为: {}", mode_str), false);
3207 return false;
3208 }
3209
3210 let char_count = app.input.chars().count();
3211
3212 match key.code {
3213 KeyCode::Esc => return true,
3214
3215 KeyCode::Enter => {
3216 if !app.is_loading {
3217 app.send_message();
3218 }
3219 }
3220
3221 KeyCode::Up => app.scroll_up(),
3223 KeyCode::Down => app.scroll_down(),
3224 KeyCode::PageUp => {
3225 for _ in 0..10 {
3226 app.scroll_up();
3227 }
3228 }
3229 KeyCode::PageDown => {
3230 for _ in 0..10 {
3231 app.scroll_down();
3232 }
3233 }
3234
3235 KeyCode::Left => {
3237 if app.cursor_pos > 0 {
3238 app.cursor_pos -= 1;
3239 }
3240 }
3241 KeyCode::Right => {
3242 if app.cursor_pos < char_count {
3243 app.cursor_pos += 1;
3244 }
3245 }
3246 KeyCode::Home => app.cursor_pos = 0,
3247 KeyCode::End => app.cursor_pos = char_count,
3248
3249 KeyCode::Backspace => {
3251 if app.cursor_pos > 0 {
3252 let start = app
3253 .input
3254 .char_indices()
3255 .nth(app.cursor_pos - 1)
3256 .map(|(i, _)| i)
3257 .unwrap_or(0);
3258 let end = app
3259 .input
3260 .char_indices()
3261 .nth(app.cursor_pos)
3262 .map(|(i, _)| i)
3263 .unwrap_or(app.input.len());
3264 app.input.drain(start..end);
3265 app.cursor_pos -= 1;
3266 }
3267 }
3268 KeyCode::Delete => {
3269 if app.cursor_pos < char_count {
3270 let start = app
3271 .input
3272 .char_indices()
3273 .nth(app.cursor_pos)
3274 .map(|(i, _)| i)
3275 .unwrap_or(app.input.len());
3276 let end = app
3277 .input
3278 .char_indices()
3279 .nth(app.cursor_pos + 1)
3280 .map(|(i, _)| i)
3281 .unwrap_or(app.input.len());
3282 app.input.drain(start..end);
3283 }
3284 }
3285
3286 KeyCode::F(1) => {
3288 app.mode = ChatMode::Help;
3289 }
3290 KeyCode::Char('?') if app.input.is_empty() => {
3292 app.mode = ChatMode::Help;
3293 }
3294 KeyCode::Char(c) => {
3295 let byte_idx = app
3296 .input
3297 .char_indices()
3298 .nth(app.cursor_pos)
3299 .map(|(i, _)| i)
3300 .unwrap_or(app.input.len());
3301 app.input.insert_str(byte_idx, &c.to_string());
3302 app.cursor_pos += 1;
3303 }
3304
3305 _ => {}
3306 }
3307
3308 false
3309}
3310
3311fn handle_browse_mode(app: &mut ChatApp, key: KeyEvent) {
3313 let msg_count = app.session.messages.len();
3314 if msg_count == 0 {
3315 app.mode = ChatMode::Chat;
3316 app.msg_lines_cache = None;
3317 return;
3318 }
3319
3320 match key.code {
3321 KeyCode::Esc => {
3322 app.mode = ChatMode::Chat;
3323 app.msg_lines_cache = None; }
3325 KeyCode::Up | KeyCode::Char('k') => {
3326 if app.browse_msg_index > 0 {
3327 app.browse_msg_index -= 1;
3328 app.msg_lines_cache = None; }
3330 }
3331 KeyCode::Down | KeyCode::Char('j') => {
3332 if app.browse_msg_index < msg_count - 1 {
3333 app.browse_msg_index += 1;
3334 app.msg_lines_cache = None; }
3336 }
3337 KeyCode::Enter | KeyCode::Char('y') => {
3338 if let Some(msg) = app.session.messages.get(app.browse_msg_index) {
3340 let content = msg.content.clone();
3341 let role_label = if msg.role == "assistant" {
3342 "AI"
3343 } else if msg.role == "user" {
3344 "用户"
3345 } else {
3346 "系统"
3347 };
3348 if copy_to_clipboard(&content) {
3349 app.show_toast(
3350 &format!("已复制第 {} 条{}消息", app.browse_msg_index + 1, role_label),
3351 false,
3352 );
3353 } else {
3354 app.show_toast("复制到剪切板失败", true);
3355 }
3356 }
3357 }
3358 _ => {}
3359 }
3360}
3361
3362fn handle_select_model(app: &mut ChatApp, key: KeyEvent) {
3364 let count = app.agent_config.providers.len();
3365 match key.code {
3366 KeyCode::Esc => {
3367 app.mode = ChatMode::Chat;
3368 }
3369 KeyCode::Up | KeyCode::Char('k') => {
3370 if count > 0 {
3371 let i = app
3372 .model_list_state
3373 .selected()
3374 .map(|i| if i == 0 { count - 1 } else { i - 1 })
3375 .unwrap_or(0);
3376 app.model_list_state.select(Some(i));
3377 }
3378 }
3379 KeyCode::Down | KeyCode::Char('j') => {
3380 if count > 0 {
3381 let i = app
3382 .model_list_state
3383 .selected()
3384 .map(|i| if i >= count - 1 { 0 } else { i + 1 })
3385 .unwrap_or(0);
3386 app.model_list_state.select(Some(i));
3387 }
3388 }
3389 KeyCode::Enter => {
3390 app.switch_model();
3391 }
3392 _ => {}
3393 }
3394}
3395
3396fn copy_to_clipboard(content: &str) -> bool {
3398 use std::process::{Command, Stdio};
3399
3400 let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
3401 ("pbcopy", vec![])
3402 } else if cfg!(target_os = "linux") {
3403 if Command::new("which")
3404 .arg("xclip")
3405 .output()
3406 .map(|o| o.status.success())
3407 .unwrap_or(false)
3408 {
3409 ("xclip", vec!["-selection", "clipboard"])
3410 } else {
3411 ("xsel", vec!["--clipboard", "--input"])
3412 }
3413 } else {
3414 return false;
3415 };
3416
3417 let child = Command::new(cmd).args(&args).stdin(Stdio::piped()).spawn();
3418
3419 match child {
3420 Ok(mut child) => {
3421 if let Some(ref mut stdin) = child.stdin {
3422 let _ = stdin.write_all(content.as_bytes());
3423 }
3424 child.wait().map(|s| s.success()).unwrap_or(false)
3425 }
3426 Err(_) => false,
3427 }
3428}