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    pub history: Vec<String>,
223    pub history_index: Option<usize>,
224    pub history_draft: String,
225}
226
227impl App {
228    pub fn new(
229        model_name: String,
230        provider_name: String,
231        agent_name: String,
232        theme_name: &str,
233        vim_mode: bool,
234        context_window: u32,
235    ) -> Self {
236        Self {
237            messages: Vec::new(),
238            input: String::new(),
239            cursor_pos: 0,
240            scroll_offset: 0,
241            max_scroll: 0,
242            scroll_position: 0.0,
243            scroll_velocity: 0.0,
244            is_streaming: false,
245            current_response: String::new(),
246            current_thinking: String::new(),
247            should_quit: false,
248            mode: AppMode::Insert,
249            usage: TokenUsage::default(),
250            model_name,
251            provider_name,
252            agent_name,
253            theme: Theme::from_config(theme_name),
254            tick_count: 0,
255            layout: LayoutRects::default(),
256            pending_tool_name: None,
257            pending_tool_input: String::new(),
258            current_tool_calls: Vec::new(),
259            error_message: None,
260            model_selector: ModelSelector::new(),
261            agent_selector: AgentSelector::new(),
262            command_palette: CommandPalette::new(),
263            thinking_selector: ThinkingSelector::new(),
264            session_selector: SessionSelector::new(),
265            help_popup: HelpPopup::new(),
266            streaming_started: None,
267            thinking_expanded: false,
268            thinking_budget: 0,
269            last_escape_time: None,
270            follow_bottom: true,
271            paste_blocks: Vec::new(),
272            attachments: Vec::new(),
273            conversation_title: None,
274            vim_mode,
275            selection: TextSelection::default(),
276            visual_lines: Vec::new(),
277            content_width: 0,
278            context_window,
279            last_input_tokens: 0,
280            esc_hint_until: None,
281            todos: Vec::new(),
282            message_line_map: Vec::new(),
283            context_menu: MessageContextMenu::new(),
284            pending_question: None,
285            pending_permission: None,
286            message_queue: VecDeque::new(),
287            history: Vec::new(),
288            history_index: None,
289            history_draft: String::new(),
290        }
291    }
292
293    pub fn streaming_elapsed_secs(&self) -> Option<f64> {
294        self.streaming_started
295            .map(|start| start.elapsed().as_secs_f64())
296    }
297
298    pub fn thinking_level(&self) -> ThinkingLevel {
299        ThinkingLevel::from_budget(self.thinking_budget)
300    }
301
302    pub fn handle_agent_event(&mut self, event: AgentEvent) {
303        match event {
304            AgentEvent::TextDelta(text) => {
305                self.current_response.push_str(&text);
306            }
307            AgentEvent::ThinkingDelta(text) => {
308                self.current_thinking.push_str(&text);
309            }
310            AgentEvent::TextComplete(text) => {
311                if !text.is_empty() || !self.current_response.is_empty() {
312                    let content = if self.current_response.is_empty() {
313                        text
314                    } else {
315                        self.current_response.clone()
316                    };
317                    let thinking = if self.current_thinking.is_empty() {
318                        None
319                    } else {
320                        Some(self.current_thinking.clone())
321                    };
322                    self.messages.push(ChatMessage {
323                        role: "assistant".to_string(),
324                        content,
325                        tool_calls: std::mem::take(&mut self.current_tool_calls),
326                        thinking,
327                        model: Some(self.model_name.clone()),
328                    });
329                }
330                self.current_response.clear();
331                self.current_thinking.clear();
332            }
333            AgentEvent::ToolCallStart { name, .. } => {
334                self.pending_tool_name = Some(name);
335                self.pending_tool_input.clear();
336            }
337            AgentEvent::ToolCallInputDelta(delta) => {
338                self.pending_tool_input.push_str(&delta);
339            }
340            AgentEvent::ToolCallExecuting { name, input, .. } => {
341                self.pending_tool_name = Some(name.clone());
342                self.pending_tool_input = input;
343            }
344            AgentEvent::ToolCallResult {
345                name,
346                output,
347                is_error,
348                ..
349            } => {
350                let input = std::mem::take(&mut self.pending_tool_input);
351                let category = ToolCategory::from_name(&name);
352                let detail = extract_tool_detail(&name, &input);
353                self.current_tool_calls.push(ToolCallDisplay {
354                    name: name.clone(),
355                    input,
356                    output: Some(output),
357                    is_error,
358                    category,
359                    detail,
360                });
361                self.pending_tool_name = None;
362            }
363            AgentEvent::Done { usage } => {
364                self.is_streaming = false;
365                self.streaming_started = None;
366                self.last_input_tokens = usage.input_tokens;
367                self.usage.input_tokens += usage.input_tokens;
368                self.usage.output_tokens += usage.output_tokens;
369            }
370            AgentEvent::Error(msg) => {
371                self.is_streaming = false;
372                self.streaming_started = None;
373                self.error_message = Some(msg);
374            }
375            AgentEvent::Compacting => {
376                self.messages.push(ChatMessage {
377                    role: "compact".to_string(),
378                    content: "\u{26a1} compacting context\u{2026}".to_string(),
379                    tool_calls: Vec::new(),
380                    thinking: None,
381                    model: None,
382                });
383            }
384            AgentEvent::TitleGenerated(title) => {
385                self.conversation_title = Some(title);
386            }
387            AgentEvent::Compacted { messages_removed } => {
388                if let Some(last) = self.messages.last_mut()
389                    && last.role == "compact"
390                {
391                    last.content = format!(
392                        "\u{26a1} compacted \u{2014} {} messages summarized",
393                        messages_removed
394                    );
395                }
396            }
397            AgentEvent::TodoUpdate(items) => {
398                self.todos = items;
399            }
400            AgentEvent::Question {
401                question,
402                options,
403                responder,
404                ..
405            } => {
406                self.pending_question = Some(PendingQuestion {
407                    question,
408                    options,
409                    selected: 0,
410                    custom_input: String::new(),
411                    responder: Some(responder),
412                });
413            }
414            AgentEvent::PermissionRequest {
415                tool_name,
416                input_summary,
417                responder,
418            } => {
419                self.pending_permission = Some(PendingPermission {
420                    tool_name,
421                    input_summary,
422                    selected: 0,
423                    responder: Some(responder),
424                });
425            }
426        }
427    }
428
429    pub fn take_input(&mut self) -> Option<String> {
430        let trimmed = self.input.trim().to_string();
431        if trimmed.is_empty() && self.attachments.is_empty() {
432            return None;
433        }
434        let display = if self.attachments.is_empty() {
435            trimmed.clone()
436        } else {
437            let att_names: Vec<String> = self
438                .attachments
439                .iter()
440                .map(|a| {
441                    Path::new(&a.path)
442                        .file_name()
443                        .map(|f| f.to_string_lossy().to_string())
444                        .unwrap_or_else(|| a.path.clone())
445                })
446                .collect();
447            if trimmed.is_empty() {
448                format!("[{}]", att_names.join(", "))
449            } else {
450                format!("{} [{}]", trimmed, att_names.join(", "))
451            }
452        };
453        self.messages.push(ChatMessage {
454            role: "user".to_string(),
455            content: display,
456            tool_calls: Vec::new(),
457            thinking: None,
458            model: None,
459        });
460        self.input.clear();
461        self.cursor_pos = 0;
462        self.paste_blocks.clear();
463        self.history.push(trimmed.clone());
464        self.history_index = None;
465        self.history_draft.clear();
466        self.is_streaming = true;
467        self.streaming_started = Some(Instant::now());
468        self.current_response.clear();
469        self.current_thinking.clear();
470        self.current_tool_calls.clear();
471        self.error_message = None;
472        self.scroll_to_bottom();
473        Some(trimmed)
474    }
475
476    pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
477        std::mem::take(&mut self.attachments)
478    }
479
480    pub fn queue_input(&mut self) -> bool {
481        let trimmed = self.input.trim().to_string();
482        if trimmed.is_empty() && self.attachments.is_empty() {
483            return false;
484        }
485        let display = if self.attachments.is_empty() {
486            trimmed.clone()
487        } else {
488            let names: Vec<String> = self
489                .attachments
490                .iter()
491                .map(|a| {
492                    Path::new(&a.path)
493                        .file_name()
494                        .map(|f| f.to_string_lossy().to_string())
495                        .unwrap_or_else(|| a.path.clone())
496                })
497                .collect();
498            if trimmed.is_empty() {
499                format!("[{}]", names.join(", "))
500            } else {
501                format!("{} [{}]", trimmed, names.join(", "))
502            }
503        };
504        self.messages.push(ChatMessage {
505            role: "user".to_string(),
506            content: display,
507            tool_calls: Vec::new(),
508            thinking: None,
509            model: None,
510        });
511        let images: Vec<(String, String)> = self
512            .attachments
513            .drain(..)
514            .map(|a| (a.media_type, a.data))
515            .collect();
516        self.history.push(trimmed.clone());
517        self.history_index = None;
518        self.history_draft.clear();
519        self.message_queue.push_back(QueuedMessage {
520            text: trimmed,
521            images,
522        });
523        self.input.clear();
524        self.cursor_pos = 0;
525        self.paste_blocks.clear();
526        self.scroll_to_bottom();
527        true
528    }
529
530    pub fn input_height(&self) -> u16 {
531        if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
532            return 3;
533        }
534        let lines = if self.input.is_empty() {
535            1
536        } else {
537            self.input.lines().count() + if self.input.ends_with('\n') { 1 } else { 0 }
538        };
539        (lines as u16 + 1).clamp(3, 12)
540    }
541
542    pub fn handle_paste(&mut self, text: String) {
543        let line_count = text.lines().count();
544        if line_count >= PASTE_COLLAPSE_THRESHOLD {
545            let start = self.cursor_pos;
546            self.input.insert_str(self.cursor_pos, &text);
547            let end = start + text.len();
548            self.cursor_pos = end;
549            self.paste_blocks.push(PasteBlock {
550                start,
551                end,
552                line_count,
553            });
554        } else {
555            self.input.insert_str(self.cursor_pos, &text);
556            self.cursor_pos += text.len();
557        }
558    }
559
560    pub fn paste_block_at_cursor(&self) -> Option<usize> {
561        self.paste_blocks
562            .iter()
563            .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
564    }
565
566    pub fn delete_paste_block(&mut self, idx: usize) {
567        let pb = self.paste_blocks.remove(idx);
568        let len = pb.end - pb.start;
569        self.input.replace_range(pb.start..pb.end, "");
570        self.cursor_pos = pb.start;
571        for remaining in &mut self.paste_blocks {
572            if remaining.start >= pb.end {
573                remaining.start -= len;
574                remaining.end -= len;
575            }
576        }
577    }
578
579    pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
580        let resolved = if path.starts_with('~') {
581            if let Ok(home) = std::env::var("HOME") {
582                path.replacen('~', &home, 1)
583            } else {
584                path.to_string()
585            }
586        } else {
587            path.to_string()
588        };
589
590        let fs_path = Path::new(&resolved);
591        if !fs_path.exists() {
592            return Err(format!("file not found: {}", path));
593        }
594
595        let media_type = media_type_for_path(&resolved)
596            .ok_or_else(|| format!("unsupported image format: {}", path))?;
597
598        let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
599        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
600
601        if self.attachments.iter().any(|a| a.path == resolved) {
602            return Ok(());
603        }
604
605        self.attachments.push(ImageAttachment {
606            path: resolved,
607            media_type,
608            data: encoded,
609        });
610        Ok(())
611    }
612
613    pub fn display_input(&self) -> String {
614        if self.paste_blocks.is_empty() {
615            return self.input.clone();
616        }
617        let mut result = String::new();
618        let mut pos = 0;
619        let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
620        sorted_blocks.sort_by_key(|pb| pb.start);
621        for pb in sorted_blocks {
622            if pb.start > pos {
623                result.push_str(&self.input[pos..pb.start]);
624            }
625            result.push_str(&format!("[pasted {} lines]", pb.line_count));
626            pos = pb.end;
627        }
628        if pos < self.input.len() {
629            result.push_str(&self.input[pos..]);
630        }
631        result
632    }
633
634    pub fn scroll_up(&mut self, n: u16) {
635        self.follow_bottom = false;
636        self.scroll_velocity -= n as f64 * 0.25;
637        self.scroll_velocity = self.scroll_velocity.clamp(-40.0, 40.0);
638    }
639
640    pub fn scroll_down(&mut self, n: u16) {
641        self.scroll_velocity += n as f64 * 0.25;
642        self.scroll_velocity = self.scroll_velocity.clamp(-40.0, 40.0);
643    }
644
645    pub fn scroll_to_top(&mut self) {
646        self.follow_bottom = false;
647        self.scroll_position = 0.0;
648        self.scroll_velocity = 0.0;
649    }
650
651    pub fn scroll_to_bottom(&mut self) {
652        self.follow_bottom = true;
653        self.scroll_position = self.max_scroll as f64;
654        self.scroll_velocity = 0.0;
655    }
656
657    pub fn scroll_frac(&self) -> f64 {
658        self.scroll_position - self.scroll_position.floor()
659    }
660
661    pub fn animate_scroll(&mut self) {
662        if self.scroll_velocity.abs() < 0.01 && self.scroll_position == self.scroll_position.round()
663        {
664            return;
665        }
666
667        self.scroll_position += self.scroll_velocity;
668        self.scroll_velocity *= 0.78;
669
670        if self.scroll_velocity.abs() < 0.08 {
671            self.scroll_velocity = 0.0;
672            self.scroll_position = self.scroll_position.round();
673        }
674
675        if self.scroll_position < 0.0 {
676            self.scroll_position = 0.0;
677            self.scroll_velocity = 0.0;
678        }
679        let max = self.max_scroll as f64;
680        if self.scroll_position > max {
681            self.scroll_position = max;
682            self.scroll_velocity = 0.0;
683            self.follow_bottom = true;
684        }
685
686        self.scroll_offset = self.scroll_position.round() as u16;
687    }
688
689    pub fn clear_conversation(&mut self) {
690        self.messages.clear();
691        self.current_response.clear();
692        self.current_thinking.clear();
693        self.current_tool_calls.clear();
694        self.scroll_offset = 0;
695        self.scroll_position = 0.0;
696        self.scroll_velocity = 0.0;
697        self.max_scroll = 0;
698        self.follow_bottom = true;
699        self.usage = TokenUsage::default();
700        self.last_input_tokens = 0;
701        self.error_message = None;
702        self.paste_blocks.clear();
703        self.attachments.clear();
704        self.conversation_title = None;
705        self.selection.clear();
706        self.visual_lines.clear();
707        self.todos.clear();
708        self.message_line_map.clear();
709        self.esc_hint_until = None;
710        self.context_menu.close();
711        self.pending_question = None;
712        self.pending_permission = None;
713        self.message_queue.clear();
714    }
715
716    pub fn insert_char(&mut self, c: char) {
717        self.input.insert(self.cursor_pos, c);
718        self.cursor_pos += c.len_utf8();
719    }
720
721    pub fn delete_char_before(&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            self.input.remove(self.cursor_pos);
730        }
731    }
732
733    pub fn move_cursor_left(&mut self) {
734        if self.cursor_pos > 0 {
735            let prev = self.input[..self.cursor_pos]
736                .chars()
737                .last()
738                .map(|c| c.len_utf8())
739                .unwrap_or(0);
740            self.cursor_pos -= prev;
741        }
742    }
743
744    pub fn move_cursor_right(&mut self) {
745        if self.cursor_pos < self.input.len() {
746            let next = self.input[self.cursor_pos..]
747                .chars()
748                .next()
749                .map(|c| c.len_utf8())
750                .unwrap_or(0);
751            self.cursor_pos += next;
752        }
753    }
754
755    pub fn move_cursor_home(&mut self) {
756        self.cursor_pos = 0;
757    }
758
759    pub fn move_cursor_end(&mut self) {
760        self.cursor_pos = self.input.len();
761    }
762
763    pub fn delete_word_before(&mut self) {
764        if self.cursor_pos == 0 {
765            return;
766        }
767        let before = &self.input[..self.cursor_pos];
768        let trimmed = before.trim_end();
769        let new_end = if trimmed.is_empty() {
770            0
771        } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
772            pos + trimmed[pos..]
773                .chars()
774                .next()
775                .map(|c| c.len_utf8())
776                .unwrap_or(1)
777        } else {
778            0
779        };
780        self.input.replace_range(new_end..self.cursor_pos, "");
781        self.cursor_pos = new_end;
782    }
783
784    pub fn delete_to_end(&mut self) {
785        self.input.truncate(self.cursor_pos);
786    }
787
788    pub fn delete_to_start(&mut self) {
789        self.input.replace_range(..self.cursor_pos, "");
790        self.cursor_pos = 0;
791    }
792
793    pub fn extract_selected_text(&self) -> Option<String> {
794        let ((sc, sr), (ec, er)) = self.selection.ordered()?;
795        if self.visual_lines.is_empty() || self.content_width == 0 {
796            return None;
797        }
798        let mut text = String::new();
799        for row in sr..=er {
800            if row as usize >= self.visual_lines.len() {
801                break;
802            }
803            let line = &self.visual_lines[row as usize];
804            let chars: Vec<char> = line.chars().collect();
805            let start_col = if row == sr {
806                (sc as usize).min(chars.len())
807            } else {
808                0
809            };
810            let end_col = if row == er {
811                (ec as usize).min(chars.len())
812            } else {
813                chars.len()
814            };
815            if start_col <= end_col {
816                let s = start_col.min(chars.len());
817                let e = end_col.min(chars.len());
818                text.extend(&chars[s..e]);
819            }
820            if row < er {
821                text.push('\n');
822            }
823        }
824        Some(text)
825    }
826
827    pub fn move_cursor_up(&mut self) -> bool {
828        let before = &self.input[..self.cursor_pos];
829        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
830        if line_start == 0 {
831            return false;
832        }
833        let col = before[line_start..].chars().count();
834        let prev_end = line_start - 1;
835        let prev_start = self.input[..prev_end]
836            .rfind('\n')
837            .map(|p| p + 1)
838            .unwrap_or(0);
839        let prev_line = &self.input[prev_start..prev_end];
840        let target_col = col.min(prev_line.chars().count());
841        let offset: usize = prev_line
842            .chars()
843            .take(target_col)
844            .map(|c| c.len_utf8())
845            .sum();
846        self.cursor_pos = prev_start + offset;
847        true
848    }
849
850    pub fn move_cursor_down(&mut self) -> bool {
851        let after = &self.input[self.cursor_pos..];
852        let next_nl = after.find('\n');
853        let Some(nl_offset) = next_nl else {
854            return false;
855        };
856        let before = &self.input[..self.cursor_pos];
857        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
858        let col = before[line_start..].chars().count();
859        let next_start = self.cursor_pos + nl_offset + 1;
860        let next_end = self.input[next_start..]
861            .find('\n')
862            .map(|p| next_start + p)
863            .unwrap_or(self.input.len());
864        let next_line = &self.input[next_start..next_end];
865        let target_col = col.min(next_line.chars().count());
866        let offset: usize = next_line
867            .chars()
868            .take(target_col)
869            .map(|c| c.len_utf8())
870            .sum();
871        self.cursor_pos = next_start + offset;
872        true
873    }
874
875    pub fn history_prev(&mut self) {
876        if self.history.is_empty() {
877            return;
878        }
879        match self.history_index {
880            None => {
881                self.history_draft = self.input.clone();
882                self.history_index = Some(self.history.len() - 1);
883            }
884            Some(0) => return,
885            Some(i) => {
886                self.history_index = Some(i - 1);
887            }
888        }
889        self.input = self.history[self.history_index.unwrap()].clone();
890        self.cursor_pos = self.input.len();
891        self.paste_blocks.clear();
892    }
893
894    pub fn history_next(&mut self) {
895        let Some(idx) = self.history_index else {
896            return;
897        };
898        if idx + 1 >= self.history.len() {
899            self.history_index = None;
900            self.input = std::mem::take(&mut self.history_draft);
901        } else {
902            self.history_index = Some(idx + 1);
903            self.input = self.history[idx + 1].clone();
904        }
905        self.cursor_pos = self.input.len();
906        self.paste_blocks.clear();
907    }
908}
909
910pub fn copy_to_clipboard(text: &str) {
911    let encoded =
912        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
913    let osc = format!("\x1b]52;c;{}\x07", encoded);
914    let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
915
916    #[cfg(target_os = "macos")]
917    {
918        use std::process::{Command, Stdio};
919        if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
920            if let Some(ref mut stdin) = child.stdin {
921                let _ = std::io::Write::write_all(stdin, text.as_bytes());
922            }
923            let _ = child.wait();
924        }
925    }
926
927    #[cfg(target_os = "linux")]
928    {
929        use std::process::{Command, Stdio};
930        let result = Command::new("xclip")
931            .args(["-selection", "clipboard"])
932            .stdin(Stdio::piped())
933            .spawn();
934        if let Ok(mut child) = result {
935            if let Some(ref mut stdin) = child.stdin {
936                let _ = std::io::Write::write_all(stdin, text.as_bytes());
937            }
938            let _ = child.wait();
939        }
940    }
941}