Skip to main content

dot/tui/
app.rs

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}