1use std::collections::VecDeque;
2use std::path::Path;
3use std::time::Instant;
4
5use ratatui::layout::Rect;
6
7use crate::agent::{AgentEvent, QuestionResponder, TodoItem};
8use crate::tui::theme::Theme;
9use crate::tui::tools::{ToolCallDisplay, ToolCategory, extract_tool_detail};
10use crate::tui::widgets::{
11 AgentSelector, CommandPalette, HelpPopup, MessageContextMenu, ModelSelector, SessionSelector,
12 ThinkingLevel, ThinkingSelector,
13};
14
15pub struct ChatMessage {
16 pub role: String,
17 pub content: String,
18 pub tool_calls: Vec<ToolCallDisplay>,
19 pub thinking: Option<String>,
20 pub model: Option<String>,
21}
22
23pub struct TokenUsage {
24 pub input_tokens: u32,
25 pub output_tokens: u32,
26 pub total_cost: f64,
27}
28
29impl Default for TokenUsage {
30 fn default() -> Self {
31 Self {
32 input_tokens: 0,
33 output_tokens: 0,
34 total_cost: 0.0,
35 }
36 }
37}
38
39#[derive(Debug, Clone)]
40pub struct PasteBlock {
41 pub start: usize,
42 pub end: usize,
43 pub line_count: usize,
44}
45
46#[derive(Debug, Clone)]
47pub struct ImageAttachment {
48 pub path: String,
49 pub media_type: String,
50 pub data: String,
51}
52
53const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
54
55#[derive(Default)]
56pub struct TextSelection {
57 pub anchor: Option<(u16, u16)>,
58 pub end: Option<(u16, u16)>,
59 pub active: bool,
60}
61
62impl TextSelection {
63 pub fn start(&mut self, col: u16, visual_row: u16) {
64 self.anchor = Some((col, visual_row));
65 self.end = Some((col, visual_row));
66 self.active = true;
67 }
68
69 pub fn update(&mut self, col: u16, visual_row: u16) {
70 self.end = Some((col, visual_row));
71 }
72
73 pub fn clear(&mut self) {
74 self.anchor = None;
75 self.end = None;
76 self.active = false;
77 }
78
79 pub fn ordered(&self) -> Option<((u16, u16), (u16, u16))> {
80 let a = self.anchor?;
81 let e = self.end?;
82 if a.1 < e.1 || (a.1 == e.1 && a.0 <= e.0) {
83 Some((a, e))
84 } else {
85 Some((e, a))
86 }
87 }
88
89 pub fn is_empty_selection(&self) -> bool {
90 match (self.anchor, self.end) {
91 (Some(a), Some(e)) => a == e,
92 _ => true,
93 }
94 }
95}
96
97pub fn media_type_for_path(path: &str) -> Option<String> {
98 let ext = Path::new(path).extension()?.to_str()?.to_lowercase();
99 match ext.as_str() {
100 "png" => Some("image/png".into()),
101 "jpg" | "jpeg" => Some("image/jpeg".into()),
102 "gif" => Some("image/gif".into()),
103 "webp" => Some("image/webp".into()),
104 "bmp" => Some("image/bmp".into()),
105 "svg" => Some("image/svg+xml".into()),
106 _ => None,
107 }
108}
109
110pub fn is_image_path(path: &str) -> bool {
111 Path::new(path)
112 .extension()
113 .and_then(|e| e.to_str())
114 .map(|e| IMAGE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
115 .unwrap_or(false)
116}
117
118pub const PASTE_COLLAPSE_THRESHOLD: usize = 5;
119
120#[derive(Debug)]
121pub struct PendingQuestion {
122 pub question: String,
123 pub options: Vec<String>,
124 pub selected: usize,
125 pub custom_input: String,
126 pub responder: Option<QuestionResponder>,
127}
128
129#[derive(Debug)]
130pub struct PendingPermission {
131 pub tool_name: String,
132 pub input_summary: String,
133 pub selected: usize,
134 pub responder: Option<QuestionResponder>,
135}
136
137pub struct QueuedMessage {
138 pub text: String,
139 pub images: Vec<(String, String)>,
140}
141
142#[derive(PartialEq, Clone, Copy)]
143pub enum AppMode {
144 Normal,
145 Insert,
146}
147
148#[derive(Default)]
149pub struct LayoutRects {
150 pub header: Rect,
151 pub messages: Rect,
152 pub input: Rect,
153 pub status: Rect,
154 pub model_selector: Option<Rect>,
155 pub agent_selector: Option<Rect>,
156 pub command_palette: Option<Rect>,
157 pub thinking_selector: Option<Rect>,
158 pub session_selector: Option<Rect>,
159 pub help_popup: Option<Rect>,
160 pub context_menu: Option<Rect>,
161 pub question_popup: Option<Rect>,
162 pub permission_popup: Option<Rect>,
163}
164
165pub struct App {
166 pub messages: Vec<ChatMessage>,
167 pub input: String,
168 pub cursor_pos: usize,
169 pub scroll_offset: u16,
170 pub max_scroll: u16,
171 pub scroll_position: f64,
172 pub scroll_velocity: f64,
173 pub is_streaming: bool,
174 pub current_response: String,
175 pub current_thinking: String,
176 pub should_quit: bool,
177 pub mode: AppMode,
178 pub usage: TokenUsage,
179 pub model_name: String,
180 pub provider_name: String,
181 pub agent_name: String,
182 pub theme: Theme,
183 pub tick_count: u64,
184 pub layout: LayoutRects,
185
186 pub pending_tool_name: Option<String>,
187 pub pending_tool_input: String,
188 pub current_tool_calls: Vec<ToolCallDisplay>,
189 pub error_message: Option<String>,
190 pub model_selector: ModelSelector,
191 pub agent_selector: AgentSelector,
192 pub command_palette: CommandPalette,
193 pub thinking_selector: ThinkingSelector,
194 pub session_selector: SessionSelector,
195 pub help_popup: HelpPopup,
196 pub streaming_started: Option<Instant>,
197
198 pub thinking_expanded: bool,
199 pub thinking_budget: u32,
200 pub last_escape_time: Option<Instant>,
201 pub follow_bottom: bool,
202
203 pub paste_blocks: Vec<PasteBlock>,
204 pub attachments: Vec<ImageAttachment>,
205 pub conversation_title: Option<String>,
206 pub vim_mode: bool,
207
208 pub selection: TextSelection,
209 pub visual_lines: Vec<String>,
210 pub content_width: u16,
211
212 pub context_window: u32,
213 pub last_input_tokens: u32,
214
215 pub esc_hint_until: Option<Instant>,
216 pub todos: Vec<TodoItem>,
217 pub message_line_map: Vec<usize>,
218 pub context_menu: MessageContextMenu,
219 pub pending_question: Option<PendingQuestion>,
220 pub pending_permission: Option<PendingPermission>,
221 pub message_queue: VecDeque<QueuedMessage>,
222}
223
224impl App {
225 pub fn new(
226 model_name: String,
227 provider_name: String,
228 agent_name: String,
229 theme_name: &str,
230 vim_mode: bool,
231 context_window: u32,
232 ) -> Self {
233 Self {
234 messages: Vec::new(),
235 input: String::new(),
236 cursor_pos: 0,
237 scroll_offset: 0,
238 max_scroll: 0,
239 scroll_position: 0.0,
240 scroll_velocity: 0.0,
241 is_streaming: false,
242 current_response: String::new(),
243 current_thinking: String::new(),
244 should_quit: false,
245 mode: AppMode::Insert,
246 usage: TokenUsage::default(),
247 model_name,
248 provider_name,
249 agent_name,
250 theme: Theme::from_config(theme_name),
251 tick_count: 0,
252 layout: LayoutRects::default(),
253 pending_tool_name: None,
254 pending_tool_input: String::new(),
255 current_tool_calls: Vec::new(),
256 error_message: None,
257 model_selector: ModelSelector::new(),
258 agent_selector: AgentSelector::new(),
259 command_palette: CommandPalette::new(),
260 thinking_selector: ThinkingSelector::new(),
261 session_selector: SessionSelector::new(),
262 help_popup: HelpPopup::new(),
263 streaming_started: None,
264 thinking_expanded: false,
265 thinking_budget: 0,
266 last_escape_time: None,
267 follow_bottom: true,
268 paste_blocks: Vec::new(),
269 attachments: Vec::new(),
270 conversation_title: None,
271 vim_mode,
272 selection: TextSelection::default(),
273 visual_lines: Vec::new(),
274 content_width: 0,
275 context_window,
276 last_input_tokens: 0,
277 esc_hint_until: None,
278 todos: Vec::new(),
279 message_line_map: Vec::new(),
280 context_menu: MessageContextMenu::new(),
281 pending_question: None,
282 pending_permission: None,
283 message_queue: VecDeque::new(),
284 }
285 }
286
287 pub fn streaming_elapsed_secs(&self) -> Option<f64> {
288 self.streaming_started
289 .map(|start| start.elapsed().as_secs_f64())
290 }
291
292 pub fn thinking_level(&self) -> ThinkingLevel {
293 ThinkingLevel::from_budget(self.thinking_budget)
294 }
295
296 pub fn handle_agent_event(&mut self, event: AgentEvent) {
297 match event {
298 AgentEvent::TextDelta(text) => {
299 self.current_response.push_str(&text);
300 }
301 AgentEvent::ThinkingDelta(text) => {
302 self.current_thinking.push_str(&text);
303 }
304 AgentEvent::TextComplete(text) => {
305 if !text.is_empty() || !self.current_response.is_empty() {
306 let content = if self.current_response.is_empty() {
307 text
308 } else {
309 self.current_response.clone()
310 };
311 let thinking = if self.current_thinking.is_empty() {
312 None
313 } else {
314 Some(self.current_thinking.clone())
315 };
316 self.messages.push(ChatMessage {
317 role: "assistant".to_string(),
318 content,
319 tool_calls: std::mem::take(&mut self.current_tool_calls),
320 thinking,
321 model: Some(self.model_name.clone()),
322 });
323 }
324 self.current_response.clear();
325 self.current_thinking.clear();
326 }
327 AgentEvent::ToolCallStart { name, .. } => {
328 self.pending_tool_name = Some(name);
329 self.pending_tool_input.clear();
330 }
331 AgentEvent::ToolCallInputDelta(delta) => {
332 self.pending_tool_input.push_str(&delta);
333 }
334 AgentEvent::ToolCallExecuting { name, input, .. } => {
335 self.pending_tool_name = Some(name.clone());
336 self.pending_tool_input = input;
337 }
338 AgentEvent::ToolCallResult {
339 name,
340 output,
341 is_error,
342 ..
343 } => {
344 let input = std::mem::take(&mut self.pending_tool_input);
345 let category = ToolCategory::from_name(&name);
346 let detail = extract_tool_detail(&name, &input);
347 self.current_tool_calls.push(ToolCallDisplay {
348 name: name.clone(),
349 input,
350 output: Some(output),
351 is_error,
352 category,
353 detail,
354 });
355 self.pending_tool_name = None;
356 }
357 AgentEvent::Done { usage } => {
358 self.is_streaming = false;
359 self.streaming_started = None;
360 self.last_input_tokens = usage.input_tokens;
361 self.usage.input_tokens += usage.input_tokens;
362 self.usage.output_tokens += usage.output_tokens;
363 }
364 AgentEvent::Error(msg) => {
365 self.is_streaming = false;
366 self.streaming_started = None;
367 self.error_message = Some(msg);
368 }
369 AgentEvent::Compacting => {
370 self.messages.push(ChatMessage {
371 role: "compact".to_string(),
372 content: "\u{26a1} compacting context\u{2026}".to_string(),
373 tool_calls: Vec::new(),
374 thinking: None,
375 model: None,
376 });
377 }
378 AgentEvent::TitleGenerated(title) => {
379 self.conversation_title = Some(title);
380 }
381 AgentEvent::Compacted { messages_removed } => {
382 if let Some(last) = self.messages.last_mut()
383 && last.role == "compact"
384 {
385 last.content = format!(
386 "\u{26a1} compacted \u{2014} {} messages summarized",
387 messages_removed
388 );
389 }
390 }
391 AgentEvent::TodoUpdate(items) => {
392 self.todos = items;
393 }
394 AgentEvent::Question {
395 question,
396 options,
397 responder,
398 ..
399 } => {
400 self.pending_question = Some(PendingQuestion {
401 question,
402 options,
403 selected: 0,
404 custom_input: String::new(),
405 responder: Some(responder),
406 });
407 }
408 AgentEvent::PermissionRequest {
409 tool_name,
410 input_summary,
411 responder,
412 } => {
413 self.pending_permission = Some(PendingPermission {
414 tool_name,
415 input_summary,
416 selected: 0,
417 responder: Some(responder),
418 });
419 }
420 }
421 }
422
423 pub fn take_input(&mut self) -> Option<String> {
424 let trimmed = self.input.trim().to_string();
425 if trimmed.is_empty() && self.attachments.is_empty() {
426 return None;
427 }
428 let display = if self.attachments.is_empty() {
429 trimmed.clone()
430 } else {
431 let att_names: Vec<String> = self
432 .attachments
433 .iter()
434 .map(|a| {
435 Path::new(&a.path)
436 .file_name()
437 .map(|f| f.to_string_lossy().to_string())
438 .unwrap_or_else(|| a.path.clone())
439 })
440 .collect();
441 if trimmed.is_empty() {
442 format!("[{}]", att_names.join(", "))
443 } else {
444 format!("{} [{}]", trimmed, att_names.join(", "))
445 }
446 };
447 self.messages.push(ChatMessage {
448 role: "user".to_string(),
449 content: display,
450 tool_calls: Vec::new(),
451 thinking: None,
452 model: None,
453 });
454 self.input.clear();
455 self.cursor_pos = 0;
456 self.paste_blocks.clear();
457 self.is_streaming = true;
458 self.streaming_started = Some(Instant::now());
459 self.current_response.clear();
460 self.current_thinking.clear();
461 self.current_tool_calls.clear();
462 self.error_message = None;
463 self.scroll_to_bottom();
464 Some(trimmed)
465 }
466
467 pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
468 std::mem::take(&mut self.attachments)
469 }
470
471 pub fn queue_input(&mut self) -> bool {
472 let trimmed = self.input.trim().to_string();
473 if trimmed.is_empty() && self.attachments.is_empty() {
474 return false;
475 }
476 let display = if self.attachments.is_empty() {
477 trimmed.clone()
478 } else {
479 let names: Vec<String> = self
480 .attachments
481 .iter()
482 .map(|a| {
483 Path::new(&a.path)
484 .file_name()
485 .map(|f| f.to_string_lossy().to_string())
486 .unwrap_or_else(|| a.path.clone())
487 })
488 .collect();
489 if trimmed.is_empty() {
490 format!("[{}]", names.join(", "))
491 } else {
492 format!("{} [{}]", trimmed, names.join(", "))
493 }
494 };
495 self.messages.push(ChatMessage {
496 role: "user".to_string(),
497 content: display,
498 tool_calls: Vec::new(),
499 thinking: None,
500 model: None,
501 });
502 let images: Vec<(String, String)> = self
503 .attachments
504 .drain(..)
505 .map(|a| (a.media_type, a.data))
506 .collect();
507 self.message_queue.push_back(QueuedMessage {
508 text: trimmed,
509 images,
510 });
511 self.input.clear();
512 self.cursor_pos = 0;
513 self.paste_blocks.clear();
514 self.scroll_to_bottom();
515 true
516 }
517
518 pub fn input_height(&self) -> u16 {
519 if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
520 return 3;
521 }
522 let lines = if self.input.is_empty() {
523 1
524 } else {
525 self.input.lines().count() + if self.input.ends_with('\n') { 1 } else { 0 }
526 };
527 (lines as u16 + 1).clamp(3, 12)
528 }
529
530 pub fn handle_paste(&mut self, text: String) {
531 let line_count = text.lines().count();
532 if line_count >= PASTE_COLLAPSE_THRESHOLD {
533 let start = self.cursor_pos;
534 self.input.insert_str(self.cursor_pos, &text);
535 let end = start + text.len();
536 self.cursor_pos = end;
537 self.paste_blocks.push(PasteBlock {
538 start,
539 end,
540 line_count,
541 });
542 } else {
543 self.input.insert_str(self.cursor_pos, &text);
544 self.cursor_pos += text.len();
545 }
546 }
547
548 pub fn paste_block_at_cursor(&self) -> Option<usize> {
549 self.paste_blocks
550 .iter()
551 .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
552 }
553
554 pub fn delete_paste_block(&mut self, idx: usize) {
555 let pb = self.paste_blocks.remove(idx);
556 let len = pb.end - pb.start;
557 self.input.replace_range(pb.start..pb.end, "");
558 self.cursor_pos = pb.start;
559 for remaining in &mut self.paste_blocks {
560 if remaining.start >= pb.end {
561 remaining.start -= len;
562 remaining.end -= len;
563 }
564 }
565 }
566
567 pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
568 let resolved = if path.starts_with('~') {
569 if let Ok(home) = std::env::var("HOME") {
570 path.replacen('~', &home, 1)
571 } else {
572 path.to_string()
573 }
574 } else {
575 path.to_string()
576 };
577
578 let fs_path = Path::new(&resolved);
579 if !fs_path.exists() {
580 return Err(format!("file not found: {}", path));
581 }
582
583 let media_type = media_type_for_path(&resolved)
584 .ok_or_else(|| format!("unsupported image format: {}", path))?;
585
586 let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
587 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
588
589 if self.attachments.iter().any(|a| a.path == resolved) {
590 return Ok(());
591 }
592
593 self.attachments.push(ImageAttachment {
594 path: resolved,
595 media_type,
596 data: encoded,
597 });
598 Ok(())
599 }
600
601 pub fn display_input(&self) -> String {
602 if self.paste_blocks.is_empty() {
603 return self.input.clone();
604 }
605 let mut result = String::new();
606 let mut pos = 0;
607 let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
608 sorted_blocks.sort_by_key(|pb| pb.start);
609 for pb in sorted_blocks {
610 if pb.start > pos {
611 result.push_str(&self.input[pos..pb.start]);
612 }
613 result.push_str(&format!("[pasted {} lines]", pb.line_count));
614 pos = pb.end;
615 }
616 if pos < self.input.len() {
617 result.push_str(&self.input[pos..]);
618 }
619 result
620 }
621
622 pub fn scroll_up(&mut self, n: u16) {
623 self.follow_bottom = false;
624 self.scroll_velocity -= n as f64 * 0.25;
625 self.scroll_velocity = self.scroll_velocity.clamp(-40.0, 40.0);
626 }
627
628 pub fn scroll_down(&mut self, n: u16) {
629 self.scroll_velocity += n as f64 * 0.25;
630 self.scroll_velocity = self.scroll_velocity.clamp(-40.0, 40.0);
631 }
632
633 pub fn scroll_to_top(&mut self) {
634 self.follow_bottom = false;
635 self.scroll_position = 0.0;
636 self.scroll_velocity = 0.0;
637 }
638
639 pub fn scroll_to_bottom(&mut self) {
640 self.follow_bottom = true;
641 self.scroll_position = self.max_scroll as f64;
642 self.scroll_velocity = 0.0;
643 }
644
645 pub fn scroll_frac(&self) -> f64 {
646 self.scroll_position - self.scroll_position.floor()
647 }
648
649 pub fn animate_scroll(&mut self) {
650 if self.scroll_velocity.abs() < 0.01 && self.scroll_position == self.scroll_position.round()
651 {
652 return;
653 }
654
655 self.scroll_position += self.scroll_velocity;
656 self.scroll_velocity *= 0.78;
657
658 if self.scroll_velocity.abs() < 0.08 {
659 self.scroll_velocity = 0.0;
660 self.scroll_position = self.scroll_position.round();
661 }
662
663 if self.scroll_position < 0.0 {
664 self.scroll_position = 0.0;
665 self.scroll_velocity = 0.0;
666 }
667 let max = self.max_scroll as f64;
668 if self.scroll_position > max {
669 self.scroll_position = max;
670 self.scroll_velocity = 0.0;
671 self.follow_bottom = true;
672 }
673
674 self.scroll_offset = self.scroll_position.round() as u16;
675 }
676
677 pub fn clear_conversation(&mut self) {
678 self.messages.clear();
679 self.current_response.clear();
680 self.current_thinking.clear();
681 self.current_tool_calls.clear();
682 self.scroll_offset = 0;
683 self.scroll_position = 0.0;
684 self.scroll_velocity = 0.0;
685 self.max_scroll = 0;
686 self.follow_bottom = true;
687 self.usage = TokenUsage::default();
688 self.last_input_tokens = 0;
689 self.error_message = None;
690 self.paste_blocks.clear();
691 self.attachments.clear();
692 self.conversation_title = None;
693 self.selection.clear();
694 self.visual_lines.clear();
695 self.todos.clear();
696 self.message_line_map.clear();
697 self.esc_hint_until = None;
698 self.context_menu.close();
699 self.pending_question = None;
700 self.pending_permission = None;
701 self.message_queue.clear();
702 }
703
704 pub fn insert_char(&mut self, c: char) {
705 self.input.insert(self.cursor_pos, c);
706 self.cursor_pos += c.len_utf8();
707 }
708
709 pub fn delete_char_before(&mut self) {
710 if self.cursor_pos > 0 {
711 let prev = self.input[..self.cursor_pos]
712 .chars()
713 .last()
714 .map(|c| c.len_utf8())
715 .unwrap_or(0);
716 self.cursor_pos -= prev;
717 self.input.remove(self.cursor_pos);
718 }
719 }
720
721 pub fn move_cursor_left(&mut self) {
722 if self.cursor_pos > 0 {
723 let prev = self.input[..self.cursor_pos]
724 .chars()
725 .last()
726 .map(|c| c.len_utf8())
727 .unwrap_or(0);
728 self.cursor_pos -= prev;
729 }
730 }
731
732 pub fn move_cursor_right(&mut self) {
733 if self.cursor_pos < self.input.len() {
734 let next = self.input[self.cursor_pos..]
735 .chars()
736 .next()
737 .map(|c| c.len_utf8())
738 .unwrap_or(0);
739 self.cursor_pos += next;
740 }
741 }
742
743 pub fn move_cursor_home(&mut self) {
744 self.cursor_pos = 0;
745 }
746
747 pub fn move_cursor_end(&mut self) {
748 self.cursor_pos = self.input.len();
749 }
750
751 pub fn delete_word_before(&mut self) {
752 if self.cursor_pos == 0 {
753 return;
754 }
755 let before = &self.input[..self.cursor_pos];
756 let trimmed = before.trim_end();
757 let new_end = if trimmed.is_empty() {
758 0
759 } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
760 pos + trimmed[pos..]
761 .chars()
762 .next()
763 .map(|c| c.len_utf8())
764 .unwrap_or(1)
765 } else {
766 0
767 };
768 self.input.replace_range(new_end..self.cursor_pos, "");
769 self.cursor_pos = new_end;
770 }
771
772 pub fn delete_to_end(&mut self) {
773 self.input.truncate(self.cursor_pos);
774 }
775
776 pub fn delete_to_start(&mut self) {
777 self.input.replace_range(..self.cursor_pos, "");
778 self.cursor_pos = 0;
779 }
780
781 pub fn extract_selected_text(&self) -> Option<String> {
782 let ((sc, sr), (ec, er)) = self.selection.ordered()?;
783 if self.visual_lines.is_empty() || self.content_width == 0 {
784 return None;
785 }
786 let mut text = String::new();
787 for row in sr..=er {
788 if row as usize >= self.visual_lines.len() {
789 break;
790 }
791 let line = &self.visual_lines[row as usize];
792 let chars: Vec<char> = line.chars().collect();
793 let start_col = if row == sr {
794 (sc as usize).min(chars.len())
795 } else {
796 0
797 };
798 let end_col = if row == er {
799 (ec as usize).min(chars.len())
800 } else {
801 chars.len()
802 };
803 if start_col <= end_col {
804 let s = start_col.min(chars.len());
805 let e = end_col.min(chars.len());
806 text.extend(&chars[s..e]);
807 }
808 if row < er {
809 text.push('\n');
810 }
811 }
812 Some(text)
813 }
814}
815
816pub fn copy_to_clipboard(text: &str) {
817 let encoded =
818 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
819 let osc = format!("\x1b]52;c;{}\x07", encoded);
820 let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
821
822 #[cfg(target_os = "macos")]
823 {
824 use std::process::{Command, Stdio};
825 if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
826 if let Some(ref mut stdin) = child.stdin {
827 let _ = std::io::Write::write_all(stdin, text.as_bytes());
828 }
829 let _ = child.wait();
830 }
831 }
832
833 #[cfg(target_os = "linux")]
834 {
835 use std::process::{Command, Stdio};
836 let result = Command::new("xclip")
837 .args(["-selection", "clipboard"])
838 .stdin(Stdio::piped())
839 .spawn();
840 if let Ok(mut child) = result {
841 if let Some(ref mut stdin) = child.stdin {
842 let _ = std::io::Write::write_all(stdin, text.as_bytes());
843 }
844 let _ = child.wait();
845 }
846 }
847}