Skip to main content

dot/tui/
app.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3use std::time::Instant;
4
5use ratatui::layout::Rect;
6use ratatui::text::Line;
7
8use crate::agent::{AgentEvent, QuestionResponder, TodoItem};
9use crate::config::CursorShape;
10use crate::tui::theme::Theme;
11use crate::tui::tools::{StreamSegment, ToolCallDisplay, ToolCategory, extract_tool_detail};
12use crate::tui::widgets::{
13    AgentSelector, AsidePopup, CommandPalette, FilePicker, HelpPopup, LoginPopup,
14    MessageContextMenu, ModelSelector, SessionSelector, ThinkingLevel, ThinkingSelector,
15    WelcomeScreen,
16};
17
18type ModelFetchReceiver =
19    tokio::sync::oneshot::Receiver<(Vec<(String, Vec<String>)>, String, String)>;
20
21pub struct ChatMessage {
22    pub role: String,
23    pub content: String,
24    pub tool_calls: Vec<ToolCallDisplay>,
25    pub thinking: Option<String>,
26    pub model: Option<String>,
27    /// Interleaved text and tool calls in display order. When Some, used for rendering; when None, fall back to content + tool_calls.
28    pub segments: Option<Vec<StreamSegment>>,
29    /// Chip ranges for user messages: @file and /skill mentions. Byte offsets into content.
30    pub chips: Option<Vec<InputChip>>,
31}
32
33pub struct TokenUsage {
34    pub input_tokens: u32,
35    pub output_tokens: u32,
36    pub total_cost: f64,
37}
38
39impl Default for TokenUsage {
40    fn default() -> Self {
41        Self {
42            input_tokens: 0,
43            output_tokens: 0,
44            total_cost: 0.0,
45        }
46    }
47}
48
49#[derive(Debug, Clone)]
50pub struct PasteBlock {
51    pub start: usize,
52    pub end: usize,
53    pub line_count: usize,
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub enum ChipKind {
58    File,
59    Skill,
60}
61
62#[derive(Debug, Clone)]
63pub struct InputChip {
64    pub start: usize,
65    pub end: usize,
66    pub kind: ChipKind,
67}
68
69#[derive(Debug, Clone)]
70pub struct ImageAttachment {
71    pub path: String,
72    pub media_type: String,
73    pub data: String,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub enum StatusLevel {
78    Error,
79    Info,
80    Success,
81}
82
83pub struct StatusMessage {
84    pub text: String,
85    pub level: StatusLevel,
86    pub created: Instant,
87}
88
89impl StatusMessage {
90    pub fn error(text: impl Into<String>) -> Self {
91        Self {
92            text: text.into(),
93            level: StatusLevel::Error,
94            created: Instant::now(),
95        }
96    }
97
98    pub fn info(text: impl Into<String>) -> Self {
99        Self {
100            text: text.into(),
101            level: StatusLevel::Info,
102            created: Instant::now(),
103        }
104    }
105
106    pub fn success(text: impl Into<String>) -> Self {
107        Self {
108            text: text.into(),
109            level: StatusLevel::Success,
110            created: Instant::now(),
111        }
112    }
113
114    pub fn expired(&self) -> bool {
115        let ttl = match self.level {
116            StatusLevel::Error => std::time::Duration::from_secs(8),
117            StatusLevel::Info => std::time::Duration::from_secs(3),
118            StatusLevel::Success => std::time::Duration::from_secs(4),
119        };
120        self.created.elapsed() > ttl
121    }
122}
123
124const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
125
126#[derive(Default)]
127pub struct TextSelection {
128    pub anchor: Option<(u16, u32)>,
129    pub end: Option<(u16, u32)>,
130    pub active: bool,
131}
132
133impl TextSelection {
134    pub fn start(&mut self, col: u16, visual_row: u32) {
135        self.anchor = Some((col, visual_row));
136        self.end = Some((col, visual_row));
137        self.active = true;
138    }
139
140    pub fn update(&mut self, col: u16, visual_row: u32) {
141        self.end = Some((col, visual_row));
142    }
143
144    pub fn clear(&mut self) {
145        self.anchor = None;
146        self.end = None;
147        self.active = false;
148    }
149
150    pub fn ordered(&self) -> Option<((u16, u32), (u16, u32))> {
151        let a = self.anchor?;
152        let e = self.end?;
153        if a.1 < e.1 || (a.1 == e.1 && a.0 <= e.0) {
154            Some((a, e))
155        } else {
156            Some((e, a))
157        }
158    }
159
160    pub fn is_empty_selection(&self) -> bool {
161        match (self.anchor, self.end) {
162            (Some(a), Some(e)) => a == e,
163            _ => true,
164        }
165    }
166}
167
168pub fn media_type_for_path(path: &str) -> Option<String> {
169    let ext = Path::new(path).extension()?.to_str()?.to_lowercase();
170    match ext.as_str() {
171        "png" => Some("image/png".into()),
172        "jpg" | "jpeg" => Some("image/jpeg".into()),
173        "gif" => Some("image/gif".into()),
174        "webp" => Some("image/webp".into()),
175        "bmp" => Some("image/bmp".into()),
176        "svg" => Some("image/svg+xml".into()),
177        _ => None,
178    }
179}
180
181pub fn is_image_path(path: &str) -> bool {
182    Path::new(path)
183        .extension()
184        .and_then(|e| e.to_str())
185        .map(|e| IMAGE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
186        .unwrap_or(false)
187}
188
189/// Normalizes pasted text that may be a file path or file:// URL into a path string.
190/// Returns None if the input does not look like a valid path.
191pub fn normalize_paste_path(s: &str) -> Option<String> {
192    let s = s.trim().trim_matches('"').trim_matches('\'');
193    if s.is_empty() {
194        return None;
195    }
196    if let Ok(u) = url::Url::parse(s)
197        && u.scheme() == "file"
198    {
199        let path = u.path();
200        if path.is_empty() || path == "/" {
201            return None;
202        }
203        return Some(path.to_string());
204    }
205    if s.starts_with('/') || s.starts_with('~') || s.starts_with("./") {
206        return Some(s.to_string());
207    }
208    None
209}
210
211pub const PASTE_COLLAPSE_THRESHOLD: usize = 5;
212
213#[derive(Debug)]
214pub struct PendingQuestion {
215    pub question: String,
216    pub options: Vec<String>,
217    pub selected: usize,
218    pub custom_input: String,
219    pub responder: Option<QuestionResponder>,
220}
221
222pub struct SubagentState {
223    pub id: String,
224    pub description: String,
225    pub output: String,
226    pub current_tool: Option<String>,
227    pub current_tool_detail: Option<String>,
228    pub tools_completed: usize,
229    pub background: bool,
230}
231
232pub struct SubagentToolEntry {
233    pub name: String,
234    pub detail: String,
235    pub done: bool,
236    pub is_error: bool,
237}
238
239pub struct BackgroundSubagentInfo {
240    pub id: String,
241    pub description: String,
242    pub output: String,
243    pub tools_completed: usize,
244    pub done: bool,
245    pub started: Instant,
246    pub finished_at: Option<Instant>,
247    pub current_tool: Option<String>,
248    pub current_tool_detail: Option<String>,
249    pub tool_history: Vec<SubagentToolEntry>,
250    pub tokens: u32,
251    pub cost: f64,
252    pub text_lines: Vec<String>,
253}
254
255#[derive(Debug)]
256pub struct PendingPermission {
257    pub tool_name: String,
258    pub input_summary: String,
259    pub selected: usize,
260    pub responder: Option<QuestionResponder>,
261}
262
263pub struct QueuedMessage {
264    pub text: String,
265    pub images: Vec<(String, String)>,
266}
267
268#[derive(PartialEq, Clone, Copy)]
269pub enum AppMode {
270    Normal,
271    Insert,
272}
273
274#[derive(Default)]
275pub struct LayoutRects {
276    pub header: Rect,
277    pub messages: Rect,
278    pub input: Rect,
279    pub status: Rect,
280    pub model_selector: Option<Rect>,
281    pub agent_selector: Option<Rect>,
282    pub command_palette: Option<Rect>,
283    pub thinking_selector: Option<Rect>,
284    pub session_selector: Option<Rect>,
285    pub help_popup: Option<Rect>,
286    pub context_menu: Option<Rect>,
287    pub question_popup: Option<Rect>,
288    pub permission_popup: Option<Rect>,
289    pub file_picker: Option<Rect>,
290    pub login_popup: Option<Rect>,
291    pub welcome_screen: Option<Rect>,
292    pub aside_popup: Option<Rect>,
293    pub subagent_panel: Option<Rect>,
294}
295
296pub struct RenderCache {
297    pub lines: Vec<Line<'static>>,
298    pub line_to_msg: Vec<usize>,
299    pub line_to_tool: Vec<Option<(usize, usize)>>,
300    pub total_visual: u32,
301    pub width: u16,
302    pub wrap_heights: Vec<u32>,
303}
304
305pub struct MessageCache {
306    pub lines: Vec<Line<'static>>,
307    pub line_to_msg: Vec<usize>,
308    pub line_to_tool: Vec<Option<(usize, usize)>>,
309    pub message_count: usize,
310    pub width: u16,
311    pub expanded_snapshot: HashSet<(usize, usize)>,
312    pub thinking_expanded: bool,
313}
314
315pub struct SegmentCache {
316    pub lines: Vec<Line<'static>>,
317    pub line_to_tool: Vec<Option<(usize, usize)>>,
318    pub segment_count: usize,
319    pub width: u16,
320    pub prev_was_tool: bool,
321    pub tool_idx_base: usize,
322}
323
324pub struct App {
325    pub messages: Vec<ChatMessage>,
326    pub input: String,
327    pub cursor_pos: usize,
328    pub input_selection: Option<(usize, usize)>,
329    pub scroll_offset: u32,
330    pub max_scroll: u32,
331    pub is_streaming: bool,
332    pub current_response: String,
333    pub current_thinking: String,
334    pub should_quit: bool,
335    pub mode: AppMode,
336    pub usage: TokenUsage,
337    pub model_name: String,
338    pub provider_name: String,
339    pub agent_name: String,
340    pub theme: Theme,
341    pub tick_count: u64,
342    pub layout: LayoutRects,
343
344    pub pending_tool_name: Option<String>,
345    pub pending_tool_input: String,
346    pub current_tool_calls: Vec<ToolCallDisplay>,
347    pub streaming_segments: Vec<StreamSegment>,
348    pub status_message: Option<StatusMessage>,
349    pub model_selector: ModelSelector,
350    pub agent_selector: AgentSelector,
351    pub command_palette: CommandPalette,
352    pub thinking_selector: ThinkingSelector,
353    pub session_selector: SessionSelector,
354    pub help_popup: HelpPopup,
355    pub streaming_started: Option<Instant>,
356
357    pub thinking_expanded: bool,
358    pub thinking_budget: u32,
359    pub auto_opened_thinking: bool,
360    pub thinking_collapse_at: Option<Instant>,
361    pub last_escape_time: Option<Instant>,
362    pub follow_bottom: bool,
363
364    pub paste_blocks: Vec<PasteBlock>,
365    pub attachments: Vec<ImageAttachment>,
366    pub conversation_title: Option<String>,
367    pub vim_mode: bool,
368
369    pub selection: TextSelection,
370
371    pub content_width: u16,
372
373    pub context_window: u32,
374    pub last_input_tokens: u32,
375
376    pub esc_hint_until: Option<Instant>,
377    pub todos: Vec<TodoItem>,
378    pub message_line_map: Vec<usize>,
379    pub tool_line_map: Vec<Option<(usize, usize)>>,
380    pub expanded_tool_calls: HashSet<(usize, usize)>,
381    pub context_menu: MessageContextMenu,
382    pub pending_question: Option<PendingQuestion>,
383    pub pending_permission: Option<PendingPermission>,
384    pub message_queue: VecDeque<QueuedMessage>,
385    pub history: Vec<String>,
386    pub history_index: Option<usize>,
387    pub history_draft: String,
388    pub skill_entries: Vec<(String, String)>,
389    pub custom_command_names: Vec<String>,
390    pub rename_input: String,
391    pub rename_visible: bool,
392    pub favorite_models: Vec<String>,
393    pub file_picker: FilePicker,
394    pub login_popup: LoginPopup,
395    pub welcome_screen: WelcomeScreen,
396    pub aside_popup: AsidePopup,
397    pub chips: Vec<InputChip>,
398    pub active_subagent: Option<SubagentState>,
399    pub background_subagents: Vec<BackgroundSubagentInfo>,
400    pub subagent_panel_expanded: bool,
401
402    pub render_dirty: bool,
403    pub render_cache: Option<RenderCache>,
404    pub message_cache: Option<MessageCache>,
405    pub segment_cache: Option<SegmentCache>,
406    pub tool_call_complete_ticks: HashMap<(usize, usize), u64>,
407    pub input_at_top: bool,
408
409    pub cached_model_groups: Option<Vec<(String, Vec<String>)>>,
410    pub model_fetch_rx: Option<ModelFetchReceiver>,
411
412    pub cursor_shape: CursorShape,
413    pub cursor_blink: bool,
414    pub cursor_shape_normal: Option<CursorShape>,
415    pub cursor_blink_normal: Option<bool>,
416}
417impl App {
418    #[allow(clippy::too_many_arguments)]
419    pub fn new(
420        model_name: String,
421        provider_name: String,
422        agent_name: String,
423        theme_name: &str,
424        vim_mode: bool,
425        cursor_shape: CursorShape,
426        cursor_blink: bool,
427        cursor_shape_normal: Option<CursorShape>,
428        cursor_blink_normal: Option<bool>,
429    ) -> Self {
430        Self {
431            messages: Vec::new(),
432            input: String::new(),
433            cursor_pos: 0,
434            input_selection: None,
435            scroll_offset: 0,
436            max_scroll: 0,
437            is_streaming: false,
438            current_response: String::new(),
439            current_thinking: String::new(),
440            should_quit: false,
441            mode: AppMode::Insert,
442            usage: TokenUsage::default(),
443            model_name,
444            provider_name,
445            agent_name,
446            theme: Theme::from_config(theme_name),
447            tick_count: 0,
448            layout: LayoutRects::default(),
449            pending_tool_name: None,
450            pending_tool_input: String::new(),
451            current_tool_calls: Vec::new(),
452            streaming_segments: Vec::new(),
453            status_message: None,
454            model_selector: ModelSelector::new(),
455            agent_selector: AgentSelector::new(),
456            command_palette: CommandPalette::new(),
457            thinking_selector: ThinkingSelector::new(),
458            session_selector: SessionSelector::new(),
459            help_popup: HelpPopup::new(),
460            streaming_started: None,
461            thinking_expanded: false,
462            thinking_budget: 0,
463            auto_opened_thinking: false,
464            thinking_collapse_at: None,
465            last_escape_time: None,
466            follow_bottom: true,
467            paste_blocks: Vec::new(),
468            attachments: Vec::new(),
469            conversation_title: None,
470            vim_mode,
471            selection: TextSelection::default(),
472
473            content_width: 0,
474            context_window: 0,
475            last_input_tokens: 0,
476            esc_hint_until: None,
477            todos: Vec::new(),
478            message_line_map: Vec::new(),
479            tool_line_map: Vec::new(),
480            expanded_tool_calls: HashSet::new(),
481            context_menu: MessageContextMenu::new(),
482            pending_question: None,
483            pending_permission: None,
484            message_queue: VecDeque::new(),
485            history: Vec::new(),
486            history_index: None,
487            history_draft: String::new(),
488            skill_entries: Vec::new(),
489            custom_command_names: Vec::new(),
490            rename_input: String::new(),
491            rename_visible: false,
492            favorite_models: Vec::new(),
493            file_picker: FilePicker::new(),
494            login_popup: LoginPopup::new(),
495            welcome_screen: WelcomeScreen::new(),
496            aside_popup: AsidePopup::new(),
497            chips: Vec::new(),
498            active_subagent: None,
499            background_subagents: Vec::new(),
500            subagent_panel_expanded: false,
501            render_dirty: true,
502            render_cache: None,
503            message_cache: None,
504            segment_cache: None,
505            tool_call_complete_ticks: HashMap::new(),
506            input_at_top: false,
507            cached_model_groups: None,
508            model_fetch_rx: None,
509            cursor_shape,
510            cursor_blink,
511            cursor_shape_normal,
512            cursor_blink_normal,
513        }
514    }
515
516    pub fn mark_dirty(&mut self) {
517        self.render_dirty = true;
518    }
519
520    pub fn streaming_elapsed_secs(&self) -> Option<f64> {
521        self.streaming_started
522            .map(|start| start.elapsed().as_secs_f64())
523    }
524
525    pub fn thinking_level(&self) -> ThinkingLevel {
526        ThinkingLevel::from_budget(self.thinking_budget)
527    }
528
529    pub fn handle_agent_event(&mut self, event: AgentEvent) {
530        match event {
531            AgentEvent::TextDelta(text) => {
532                self.current_response.push_str(&text);
533                self.mark_dirty();
534            }
535            AgentEvent::ThinkingDelta(text) => {
536                let was_empty = self.current_thinking.is_empty();
537                self.current_thinking.push_str(&text);
538                if was_empty {
539                    self.thinking_expanded = true;
540                    self.auto_opened_thinking = true;
541                }
542                self.mark_dirty();
543            }
544            AgentEvent::TextComplete(text) => {
545                if !text.is_empty()
546                    || !self.current_response.is_empty()
547                    || !self.streaming_segments.is_empty()
548                {
549                    if !self.current_response.is_empty() {
550                        self.streaming_segments
551                            .push(StreamSegment::Text(std::mem::take(
552                                &mut self.current_response,
553                            )));
554                    }
555                    let content: String = self
556                        .streaming_segments
557                        .iter()
558                        .filter_map(|s| {
559                            if let StreamSegment::Text(t) = s {
560                                Some(t.as_str())
561                            } else {
562                                None
563                            }
564                        })
565                        .collect();
566                    let content = if content.is_empty() {
567                        text.clone()
568                    } else {
569                        content
570                    };
571                    let thinking = if self.current_thinking.is_empty() {
572                        None
573                    } else {
574                        Some(self.current_thinking.clone())
575                    };
576                    self.messages.push(ChatMessage {
577                        role: "assistant".to_string(),
578                        content,
579                        tool_calls: std::mem::take(&mut self.current_tool_calls),
580                        thinking,
581                        model: Some(self.model_name.clone()),
582                        segments: Some(std::mem::take(&mut self.streaming_segments)),
583                        chips: None,
584                    });
585                    self.mark_dirty();
586                }
587                self.current_response.clear();
588                self.current_thinking.clear();
589                self.streaming_segments.clear();
590                self.segment_cache = None;
591                self.is_streaming = false;
592                self.streaming_started = None;
593                if self.auto_opened_thinking {
594                    self.thinking_collapse_at =
595                        Some(Instant::now() + std::time::Duration::from_secs(4));
596                }
597                self.scroll_to_bottom();
598            }
599            AgentEvent::ToolCallStart { name, .. } => {
600                self.pending_tool_name = Some(name);
601                self.pending_tool_input.clear();
602                self.mark_dirty();
603            }
604            AgentEvent::ToolCallInputDelta(delta) => {
605                self.pending_tool_input.push_str(&delta);
606                self.mark_dirty();
607            }
608            AgentEvent::ToolCallExecuting { name, input, .. } => {
609                self.pending_tool_name = Some(name.clone());
610                self.pending_tool_input = input;
611                self.mark_dirty();
612            }
613            AgentEvent::ToolCallResult {
614                name,
615                output,
616                is_error,
617                ..
618            } => {
619                if !self.current_response.is_empty() {
620                    self.streaming_segments
621                        .push(StreamSegment::Text(std::mem::take(
622                            &mut self.current_response,
623                        )));
624                }
625                let input = std::mem::take(&mut self.pending_tool_input);
626                let category = ToolCategory::from_name(&name);
627                let detail = extract_tool_detail(&name, &input);
628                let display = ToolCallDisplay {
629                    name: name.clone(),
630                    input,
631                    output: Some(output),
632                    is_error,
633                    category,
634                    detail,
635                };
636                let should_auto_expand = matches!(
637                    display.category,
638                    ToolCategory::MultiEdit | ToolCategory::Patch | ToolCategory::FileWrite
639                );
640                let tool_idx = self
641                    .streaming_segments
642                    .iter()
643                    .filter(|s| matches!(s, StreamSegment::ToolCall(_)))
644                    .count();
645                self.current_tool_calls.push(display.clone());
646                self.streaming_segments
647                    .push(StreamSegment::ToolCall(display));
648                if should_auto_expand {
649                    let stream_msg_idx = self.messages.len();
650                    self.expanded_tool_calls.insert((stream_msg_idx, tool_idx));
651                }
652                self.pending_tool_name = None;
653                self.mark_dirty();
654            }
655            AgentEvent::Done { usage } => {
656                self.is_streaming = false;
657                self.streaming_started = None;
658                self.last_input_tokens = usage.input_tokens;
659                self.usage.input_tokens += usage.input_tokens;
660                self.usage.output_tokens += usage.output_tokens;
661                if self.auto_opened_thinking && self.thinking_collapse_at.is_none() {
662                    self.thinking_expanded = false;
663                    self.auto_opened_thinking = false;
664                }
665                self.scroll_to_bottom();
666            }
667            AgentEvent::Error(msg) => {
668                self.is_streaming = false;
669                self.streaming_started = None;
670                self.status_message = Some(StatusMessage::error(msg));
671            }
672            AgentEvent::Compacting => {
673                self.messages.push(ChatMessage {
674                    role: "compact".to_string(),
675                    content: "\u{26a1} context compacted".to_string(),
676                    tool_calls: Vec::new(),
677                    thinking: None,
678                    model: None,
679                    segments: None,
680                    chips: None,
681                });
682            }
683            AgentEvent::TitleGenerated(title) => {
684                self.conversation_title = Some(title);
685            }
686            AgentEvent::Compacted { messages_removed } => {
687                if let Some(last) = self.messages.last_mut()
688                    && last.role == "compact"
689                {
690                    last.content = format!(
691                        "\u{26a1} compacted \u{2014} {} messages summarized",
692                        messages_removed
693                    );
694                    self.message_cache = None;
695                }
696            }
697            AgentEvent::TodoUpdate(items) => {
698                self.todos = items;
699            }
700            AgentEvent::Question {
701                question,
702                options,
703                responder,
704                ..
705            } => {
706                self.pending_question = Some(PendingQuestion {
707                    question,
708                    options,
709                    selected: 0,
710                    custom_input: String::new(),
711                    responder: Some(responder),
712                });
713            }
714            AgentEvent::PermissionRequest {
715                tool_name,
716                input_summary,
717                responder,
718            } => {
719                self.pending_permission = Some(PendingPermission {
720                    tool_name,
721                    input_summary,
722                    selected: 0,
723                    responder: Some(responder),
724                });
725            }
726            AgentEvent::SubagentStart {
727                id,
728                description,
729                background,
730            } => {
731                if background {
732                    self.background_subagents.push(BackgroundSubagentInfo {
733                        id,
734                        description,
735                        output: String::new(),
736                        tools_completed: 0,
737                        done: false,
738                        started: Instant::now(),
739                        finished_at: None,
740                        current_tool: None,
741                        current_tool_detail: None,
742                        tool_history: Vec::new(),
743                        tokens: 0,
744                        cost: 0.0,
745                        text_lines: Vec::new(),
746                    });
747                } else {
748                    self.active_subagent = Some(SubagentState {
749                        id,
750                        description,
751                        output: String::new(),
752                        current_tool: None,
753                        current_tool_detail: None,
754                        tools_completed: 0,
755                        background: false,
756                    });
757                }
758            }
759            AgentEvent::SubagentDelta { id, text } => {
760                if let Some(ref mut state) = self.active_subagent
761                    && state.id == id
762                {
763                    state.output.push_str(&text);
764                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
765                    bg.output.push_str(&text);
766                    let lines: Vec<String> = bg.output.lines().map(|l| l.to_string()).collect();
767                    let start = lines.len().saturating_sub(20);
768                    bg.text_lines = lines[start..].to_vec();
769                }
770            }
771            AgentEvent::SubagentToolStart {
772                id,
773                tool_name,
774                detail,
775            } => {
776                if let Some(ref mut state) = self.active_subagent
777                    && state.id == id
778                {
779                    state.current_tool = Some(tool_name);
780                    state.current_tool_detail = Some(detail);
781                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
782                    bg.current_tool = Some(tool_name.clone());
783                    bg.current_tool_detail = Some(detail.clone());
784                    bg.tool_history.push(SubagentToolEntry {
785                        name: tool_name,
786                        detail,
787                        done: false,
788                        is_error: false,
789                    });
790                }
791            }
792            AgentEvent::SubagentToolComplete { id, .. } => {
793                if let Some(ref mut state) = self.active_subagent
794                    && state.id == id
795                {
796                    state.current_tool = None;
797                    state.current_tool_detail = None;
798                    state.tools_completed += 1;
799                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
800                    bg.current_tool = None;
801                    bg.current_tool_detail = None;
802                    bg.tools_completed += 1;
803                    if let Some(entry) = bg.tool_history.iter_mut().rev().find(|e| !e.done) {
804                        entry.done = true;
805                    }
806                }
807            }
808            AgentEvent::SubagentComplete { id, .. } => {
809                if self.active_subagent.as_ref().is_some_and(|s| s.id == id) {
810                    self.active_subagent = None;
811                }
812            }
813            AgentEvent::SubagentBackgroundDone {
814                id, description, ..
815            } => {
816                if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
817                    bg.done = true;
818                    bg.finished_at = Some(Instant::now());
819                }
820                self.status_message = Some(StatusMessage::success(format!(
821                    "Background subagent done: {}",
822                    description
823                )));
824            }
825            AgentEvent::MemoryExtracted {
826                added,
827                updated,
828                deleted,
829            } => {
830                let parts: Vec<String> = [
831                    (added > 0).then(|| format!("+{added}")),
832                    (updated > 0).then(|| format!("~{updated}")),
833                    (deleted > 0).then(|| format!("-{deleted}")),
834                ]
835                .into_iter()
836                .flatten()
837                .collect();
838                if !parts.is_empty() {
839                    self.status_message = Some(StatusMessage::success(format!(
840                        "memory {}",
841                        parts.join(" ")
842                    )));
843                }
844            }
845            AgentEvent::AsideDelta(text) => {
846                self.aside_popup.response.push_str(&text);
847            }
848            AgentEvent::AsideDone => {
849                self.aside_popup.done = true;
850            }
851            AgentEvent::AsideError(msg) => {
852                self.aside_popup.response = format!("Error: {msg}");
853                self.aside_popup.done = true;
854            }
855        }
856        self.mark_dirty();
857    }
858
859    pub fn take_input(&mut self) -> Option<String> {
860        let trimmed = self.input.trim().to_string();
861        if trimmed.is_empty() && self.attachments.is_empty() {
862            return None;
863        }
864        let display = if self.attachments.is_empty() {
865            trimmed.clone()
866        } else {
867            let att_names: Vec<String> = self
868                .attachments
869                .iter()
870                .map(|a| {
871                    Path::new(&a.path)
872                        .file_name()
873                        .map(|f| f.to_string_lossy().to_string())
874                        .unwrap_or_else(|| a.path.clone())
875                })
876                .collect();
877            if trimmed.is_empty() {
878                format!("[{}]", att_names.join(", "))
879            } else {
880                format!("{} [{}]", trimmed, att_names.join(", "))
881            }
882        };
883        let chips = std::mem::take(&mut self.chips);
884        self.messages.push(ChatMessage {
885            role: "user".to_string(),
886            content: display,
887            tool_calls: Vec::new(),
888            thinking: None,
889            model: None,
890            segments: None,
891            chips: if chips.is_empty() { None } else { Some(chips) },
892        });
893        self.input.clear();
894        self.cursor_pos = 0;
895        self.paste_blocks.clear();
896        self.history.push(trimmed.clone());
897        self.history_index = None;
898        self.history_draft.clear();
899        self.is_streaming = true;
900        self.streaming_started = Some(Instant::now());
901        self.current_response.clear();
902        self.current_thinking.clear();
903        self.current_tool_calls.clear();
904        self.streaming_segments.clear();
905        self.status_message = None;
906        self.scroll_to_bottom();
907        self.mark_dirty();
908        Some(trimmed)
909    }
910
911    pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
912        std::mem::take(&mut self.attachments)
913    }
914
915    pub fn queue_input(&mut self) -> bool {
916        let trimmed = self.input.trim().to_string();
917        if trimmed.is_empty() && self.attachments.is_empty() {
918            return false;
919        }
920        let display = if self.attachments.is_empty() {
921            trimmed.clone()
922        } else {
923            let names: Vec<String> = self
924                .attachments
925                .iter()
926                .map(|a| {
927                    Path::new(&a.path)
928                        .file_name()
929                        .map(|f| f.to_string_lossy().to_string())
930                        .unwrap_or_else(|| a.path.clone())
931                })
932                .collect();
933            if trimmed.is_empty() {
934                format!("[{}]", names.join(", "))
935            } else {
936                format!("{} [{}]", trimmed, names.join(", "))
937            }
938        };
939        let chips = std::mem::take(&mut self.chips);
940        self.messages.push(ChatMessage {
941            role: "user".to_string(),
942            content: display,
943            tool_calls: Vec::new(),
944            thinking: None,
945            model: None,
946            segments: None,
947            chips: if chips.is_empty() { None } else { Some(chips) },
948        });
949        let images: Vec<(String, String)> = self
950            .attachments
951            .drain(..)
952            .map(|a| (a.media_type, a.data))
953            .collect();
954        self.history.push(trimmed.clone());
955        self.history_index = None;
956        self.history_draft.clear();
957        self.message_queue.push_back(QueuedMessage {
958            text: trimmed,
959            images,
960        });
961        self.input.clear();
962        self.cursor_pos = 0;
963        self.paste_blocks.clear();
964        self.scroll_to_bottom();
965        self.mark_dirty();
966        true
967    }
968
969    pub fn input_height(&self, width: u16) -> u16 {
970        if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
971            return 1;
972        }
973        let w = width as usize;
974        if w < 4 {
975            return 1;
976        }
977        let has_input = !self.input.is_empty() || !self.attachments.is_empty();
978        if !has_input {
979            return 1;
980        }
981        let mut visual = 0usize;
982        if !self.attachments.is_empty() {
983            visual += 1;
984        }
985        let display = self.display_input();
986        if display.is_empty() {
987            if self.attachments.is_empty() {
988                visual += 1;
989            }
990        } else {
991            for line in display.split('\n') {
992                let total = 2 + line.chars().count();
993                visual += if total == 0 {
994                    1
995                } else {
996                    total.div_ceil(w).max(1)
997                };
998            }
999        }
1000        (visual as u16).clamp(1, 12)
1001    }
1002
1003    pub fn handle_paste(&mut self, text: String) {
1004        self.delete_input_selection();
1005        let line_count = text.lines().count();
1006        let start = self.cursor_pos.min(self.input.len());
1007        let len = text.len();
1008        if !self.input.is_char_boundary(start) {
1009            self.cursor_pos = self.input.len();
1010            return;
1011        }
1012        self.input.insert_str(start, &text);
1013        self.adjust_chips(start, 0, len);
1014        self.cursor_pos = start + len;
1015        if line_count >= PASTE_COLLAPSE_THRESHOLD {
1016            self.paste_blocks.push(PasteBlock {
1017                start,
1018                end: start + len,
1019                line_count,
1020            });
1021        }
1022    }
1023
1024    pub fn paste_block_at_cursor(&self) -> Option<usize> {
1025        self.paste_blocks
1026            .iter()
1027            .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
1028    }
1029
1030    pub fn delete_paste_block(&mut self, idx: usize) {
1031        let pb = self.paste_blocks.remove(idx);
1032        let start = pb.start.min(self.input.len());
1033        let end = pb.end.min(self.input.len()).max(start);
1034        let len = end - start;
1035        self.input.replace_range(start..end, "");
1036        self.cursor_pos = start;
1037        for remaining in &mut self.paste_blocks {
1038            if remaining.start >= pb.end {
1039                remaining.start -= len;
1040                remaining.end -= len;
1041            }
1042        }
1043    }
1044
1045    pub fn chip_at_cursor(&self) -> Option<usize> {
1046        self.chips
1047            .iter()
1048            .position(|c| self.cursor_pos > c.start && self.cursor_pos <= c.end)
1049    }
1050
1051    pub fn delete_chip(&mut self, idx: usize) {
1052        let chip = self.chips.remove(idx);
1053        let start = chip.start.min(self.input.len());
1054        let end = chip.end.min(self.input.len()).max(start);
1055        let len = end - start;
1056        self.input.replace_range(start..end, "");
1057        self.cursor_pos = start;
1058        self.adjust_chips(start, len, 0);
1059    }
1060
1061    pub fn adjust_chips(&mut self, edit_start: usize, old_len: usize, new_len: usize) {
1062        let edit_end = edit_start + old_len;
1063        let delta = new_len as isize - old_len as isize;
1064        self.chips.retain_mut(|c| {
1065            if c.start >= edit_end {
1066                c.start = (c.start as isize + delta) as usize;
1067                c.end = (c.end as isize + delta) as usize;
1068                true
1069            } else {
1070                c.end <= edit_start
1071            }
1072        });
1073    }
1074
1075    pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
1076        let resolved = if path.starts_with('~') {
1077            if let Ok(home) = std::env::var("HOME") {
1078                path.replacen('~', &home, 1)
1079            } else {
1080                path.to_string()
1081            }
1082        } else {
1083            path.to_string()
1084        };
1085
1086        let fs_path = Path::new(&resolved);
1087        if !fs_path.exists() {
1088            return Err(format!("file not found: {}", path));
1089        }
1090
1091        let media_type = media_type_for_path(&resolved)
1092            .ok_or_else(|| format!("unsupported image format: {}", path))?;
1093
1094        let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
1095        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
1096
1097        if self.attachments.iter().any(|a| a.path == resolved) {
1098            return Ok(());
1099        }
1100
1101        self.attachments.push(ImageAttachment {
1102            path: resolved,
1103            media_type,
1104            data: encoded,
1105        });
1106        Ok(())
1107    }
1108
1109    pub fn insert_file_reference(&mut self, path: &str) {
1110        let text = format!("@{} ", path);
1111        let start = self.cursor_pos;
1112        let len = text.len();
1113        self.input.insert_str(start, &text);
1114        self.adjust_chips(start, 0, len);
1115        let chip_end = start + 1 + path.len();
1116        self.chips.push(InputChip {
1117            start,
1118            end: chip_end,
1119            kind: ChipKind::File,
1120        });
1121        self.cursor_pos = start + len;
1122    }
1123
1124    pub fn display_cursor_pos(&self) -> usize {
1125        if self.paste_blocks.is_empty() {
1126            return self.cursor_pos;
1127        }
1128        let mut sorted: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
1129        sorted.sort_by_key(|pb| pb.start);
1130        let cursor = self.cursor_pos;
1131        let mut raw_pos = 0usize;
1132        let mut display_pos = 0usize;
1133        for pb in &sorted {
1134            if cursor <= pb.start {
1135                display_pos += cursor - raw_pos;
1136                return display_pos;
1137            }
1138            display_pos += pb.start - raw_pos;
1139            let marker_len = format!("[pasted {} lines]", pb.line_count).len();
1140            if cursor <= pb.end {
1141                display_pos += marker_len;
1142                return display_pos;
1143            }
1144            display_pos += marker_len;
1145            raw_pos = pb.end;
1146        }
1147        display_pos += cursor - raw_pos;
1148        display_pos
1149    }
1150
1151    pub fn display_input(&self) -> String {
1152        if self.paste_blocks.is_empty() {
1153            return self.input.clone();
1154        }
1155        let mut result = String::new();
1156        let mut pos = 0;
1157        let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
1158        sorted_blocks.sort_by_key(|pb| pb.start);
1159        for pb in sorted_blocks {
1160            if pb.start > pos {
1161                result.push_str(&self.input[pos..pb.start]);
1162            }
1163            result.push_str(&format!("[pasted {} lines]", pb.line_count));
1164            pos = pb.end;
1165        }
1166        if pos < self.input.len() {
1167            result.push_str(&self.input[pos..]);
1168        }
1169        result
1170    }
1171
1172    pub fn scroll_up(&mut self, n: u32) {
1173        self.follow_bottom = false;
1174        self.scroll_offset = self.scroll_offset.saturating_sub(n);
1175    }
1176
1177    pub fn scroll_down(&mut self, n: u32) {
1178        let target = self.scroll_offset.saturating_add(n);
1179        if target >= self.max_scroll {
1180            self.follow_bottom = true;
1181            self.scroll_offset = self.max_scroll;
1182        } else {
1183            self.scroll_offset = target;
1184        }
1185    }
1186
1187    pub fn scroll_to_top(&mut self) {
1188        self.follow_bottom = false;
1189        self.scroll_offset = 0;
1190    }
1191
1192    pub fn scroll_to_bottom(&mut self) {
1193        self.follow_bottom = true;
1194        self.scroll_offset = self.max_scroll;
1195    }
1196
1197    pub fn clear_conversation(&mut self) {
1198        self.messages.clear();
1199        self.current_response.clear();
1200        self.current_thinking.clear();
1201        self.current_tool_calls.clear();
1202        self.streaming_segments.clear();
1203        self.scroll_offset = 0;
1204        self.max_scroll = 0;
1205        self.follow_bottom = true;
1206        self.usage = TokenUsage::default();
1207        self.last_input_tokens = 0;
1208        self.status_message = None;
1209        self.paste_blocks.clear();
1210        self.chips.clear();
1211        self.attachments.clear();
1212        self.conversation_title = None;
1213        self.selection.clear();
1214
1215        self.todos.clear();
1216        self.message_line_map.clear();
1217        self.tool_line_map.clear();
1218        self.expanded_tool_calls.clear();
1219        self.esc_hint_until = None;
1220        self.context_menu.close();
1221        self.pending_question = None;
1222        self.pending_permission = None;
1223        self.active_subagent = None;
1224        self.background_subagents.clear();
1225        self.message_queue.clear();
1226        self.render_cache = None;
1227        self.message_cache = None;
1228        self.segment_cache = None;
1229        self.tool_call_complete_ticks.clear();
1230        self.auto_opened_thinking = false;
1231        self.thinking_collapse_at = None;
1232        self.mark_dirty();
1233    }
1234
1235    pub fn insert_char(&mut self, c: char) {
1236        let pos = self.cursor_pos.min(self.input.len());
1237        if !self.input.is_char_boundary(pos) {
1238            self.cursor_pos = self.input.len();
1239            return;
1240        }
1241        self.input.insert(pos, c);
1242        let len = c.len_utf8();
1243        self.adjust_chips(pos, 0, len);
1244        self.cursor_pos = pos + len;
1245    }
1246
1247    pub fn delete_char_before(&mut self) {
1248        self.cursor_pos = self.cursor_pos.min(self.input.len());
1249        if self.cursor_pos > 0 {
1250            if !self.input.is_char_boundary(self.cursor_pos) {
1251                self.cursor_pos = self.input.len();
1252                return;
1253            }
1254            let prev = self.input[..self.cursor_pos]
1255                .chars()
1256                .last()
1257                .map(|c| c.len_utf8())
1258                .unwrap_or(0);
1259            self.cursor_pos -= prev;
1260            self.input.remove(self.cursor_pos);
1261            self.adjust_chips(self.cursor_pos, prev, 0);
1262        }
1263    }
1264
1265    pub fn clear_input_selection(&mut self) {
1266        self.input_selection = None;
1267    }
1268
1269    pub fn input_selection_range(&self) -> Option<(usize, usize)> {
1270        let (anchor, _) = self.input_selection?;
1271        let start = anchor.min(self.cursor_pos);
1272        let end = anchor.max(self.cursor_pos);
1273        if start == end {
1274            return None;
1275        }
1276        Some((start, end))
1277    }
1278
1279    pub fn copy_input_selection(&self) -> Option<String> {
1280        let (start, end) = self.input_selection_range()?;
1281        Some(self.input[start..end].to_string())
1282    }
1283
1284    pub fn delete_input_selection(&mut self) -> bool {
1285        let Some((start, end)) = self.input_selection_range() else {
1286            return false;
1287        };
1288        let old_len = end - start;
1289        self.input.replace_range(start..end, "");
1290        self.adjust_chips(start, old_len, 0);
1291        self.cursor_pos = start;
1292        self.input_selection = None;
1293        true
1294    }
1295
1296    pub fn select_left(&mut self) {
1297        if self.input_selection.is_none() {
1298            self.input_selection = Some((self.cursor_pos, self.cursor_pos));
1299        }
1300        if self.cursor_pos > 0 {
1301            let prev = self.input[..self.cursor_pos]
1302                .chars()
1303                .last()
1304                .map(|c| c.len_utf8())
1305                .unwrap_or(0);
1306            self.cursor_pos -= prev;
1307        }
1308    }
1309
1310    pub fn select_right(&mut self) {
1311        if self.input_selection.is_none() {
1312            self.input_selection = Some((self.cursor_pos, self.cursor_pos));
1313        }
1314        if self.cursor_pos < self.input.len() {
1315            let next = self.input[self.cursor_pos..]
1316                .chars()
1317                .next()
1318                .map(|c| c.len_utf8())
1319                .unwrap_or(0);
1320            self.cursor_pos += next;
1321        }
1322    }
1323
1324    pub fn select_home(&mut self) {
1325        if self.input_selection.is_none() {
1326            self.input_selection = Some((self.cursor_pos, self.cursor_pos));
1327        }
1328        self.cursor_pos = 0;
1329    }
1330
1331    pub fn select_end(&mut self) {
1332        if self.input_selection.is_none() {
1333            self.input_selection = Some((self.cursor_pos, self.cursor_pos));
1334        }
1335        self.cursor_pos = self.input.len();
1336    }
1337
1338    pub fn select_all_input(&mut self) {
1339        self.input_selection = Some((0, 0));
1340        self.cursor_pos = self.input.len();
1341    }
1342
1343    pub fn move_cursor_left(&mut self) {
1344        if self.cursor_pos > 0 {
1345            let prev = self.input[..self.cursor_pos]
1346                .chars()
1347                .last()
1348                .map(|c| c.len_utf8())
1349                .unwrap_or(0);
1350            self.cursor_pos -= prev;
1351        }
1352    }
1353
1354    pub fn move_cursor_right(&mut self) {
1355        if self.cursor_pos < self.input.len() {
1356            let next = self.input[self.cursor_pos..]
1357                .chars()
1358                .next()
1359                .map(|c| c.len_utf8())
1360                .unwrap_or(0);
1361            self.cursor_pos += next;
1362        }
1363    }
1364
1365    pub fn move_cursor_home(&mut self) {
1366        self.cursor_pos = 0;
1367    }
1368
1369    pub fn move_cursor_end(&mut self) {
1370        self.cursor_pos = self.input.len();
1371    }
1372
1373    pub fn delete_word_before(&mut self) {
1374        if self.cursor_pos == 0 {
1375            return;
1376        }
1377        let before = &self.input[..self.cursor_pos];
1378        let trimmed = before.trim_end();
1379        let new_end = if trimmed.is_empty() {
1380            0
1381        } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
1382            pos + trimmed[pos..]
1383                .chars()
1384                .next()
1385                .map(|c| c.len_utf8())
1386                .unwrap_or(1)
1387        } else {
1388            0
1389        };
1390        let old_len = self.cursor_pos - new_end;
1391        self.input.replace_range(new_end..self.cursor_pos, "");
1392        self.adjust_chips(new_end, old_len, 0);
1393        self.cursor_pos = new_end;
1394    }
1395
1396    pub fn delete_to_end(&mut self) {
1397        let old_len = self.input.len() - self.cursor_pos;
1398        self.input.truncate(self.cursor_pos);
1399        self.adjust_chips(self.cursor_pos, old_len, 0);
1400    }
1401
1402    pub fn delete_to_start(&mut self) {
1403        let old_len = self.cursor_pos;
1404        self.input.replace_range(..self.cursor_pos, "");
1405        self.adjust_chips(0, old_len, 0);
1406        self.cursor_pos = 0;
1407    }
1408
1409    pub fn extract_selected_text(&self) -> Option<String> {
1410        let ((sc, sr), (ec, er)) = self.selection.ordered()?;
1411        let cache = self.render_cache.as_ref()?;
1412        if cache.lines.is_empty() || self.content_width == 0 {
1413            return None;
1414        }
1415        let mut text = String::new();
1416        for row in sr..=er {
1417            if row as usize >= cache.lines.len() {
1418                break;
1419            }
1420            let line_text: String = cache.lines[row as usize]
1421                .spans
1422                .iter()
1423                .map(|s| s.content.as_ref())
1424                .collect();
1425            let chars: Vec<char> = line_text.chars().collect();
1426            let start_col = if row == sr {
1427                (sc as usize).min(chars.len())
1428            } else {
1429                0
1430            };
1431            let end_col = if row == er {
1432                (ec as usize).min(chars.len())
1433            } else {
1434                chars.len()
1435            };
1436            if start_col <= end_col {
1437                let s = start_col.min(chars.len());
1438                let e = end_col.min(chars.len());
1439                text.extend(&chars[s..e]);
1440            }
1441            if row < er {
1442                text.push('\n');
1443            }
1444        }
1445        Some(text)
1446    }
1447
1448    pub fn move_cursor_up(&mut self) -> bool {
1449        let before = &self.input[..self.cursor_pos];
1450        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1451        if line_start == 0 {
1452            return false;
1453        }
1454        let col = before[line_start..].chars().count();
1455        let prev_end = line_start - 1;
1456        let prev_start = self.input[..prev_end]
1457            .rfind('\n')
1458            .map(|p| p + 1)
1459            .unwrap_or(0);
1460        let prev_line = &self.input[prev_start..prev_end];
1461        let target_col = col.min(prev_line.chars().count());
1462        let offset: usize = prev_line
1463            .chars()
1464            .take(target_col)
1465            .map(|c| c.len_utf8())
1466            .sum();
1467        self.cursor_pos = prev_start + offset;
1468        true
1469    }
1470
1471    pub fn move_cursor_down(&mut self) -> bool {
1472        let after = &self.input[self.cursor_pos..];
1473        let next_nl = after.find('\n');
1474        let Some(nl_offset) = next_nl else {
1475            return false;
1476        };
1477        let before = &self.input[..self.cursor_pos];
1478        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1479        let col = before[line_start..].chars().count();
1480        let next_start = self.cursor_pos + nl_offset + 1;
1481        let next_end = self.input[next_start..]
1482            .find('\n')
1483            .map(|p| next_start + p)
1484            .unwrap_or(self.input.len());
1485        let next_line = &self.input[next_start..next_end];
1486        let target_col = col.min(next_line.chars().count());
1487        let offset: usize = next_line
1488            .chars()
1489            .take(target_col)
1490            .map(|c| c.len_utf8())
1491            .sum();
1492        self.cursor_pos = next_start + offset;
1493        true
1494    }
1495
1496    pub fn history_prev(&mut self) {
1497        if self.history.is_empty() {
1498            return;
1499        }
1500        match self.history_index {
1501            None => {
1502                self.history_draft = self.input.clone();
1503                self.history_index = Some(self.history.len() - 1);
1504            }
1505            Some(0) => return,
1506            Some(i) => {
1507                self.history_index = Some(i - 1);
1508            }
1509        }
1510        self.input = self.history[self.history_index.unwrap()].clone();
1511        self.cursor_pos = self.input.len();
1512        self.paste_blocks.clear();
1513        self.chips.clear();
1514    }
1515
1516    pub fn history_next(&mut self) {
1517        let Some(idx) = self.history_index else {
1518            return;
1519        };
1520        if idx + 1 >= self.history.len() {
1521            self.history_index = None;
1522            self.input = std::mem::take(&mut self.history_draft);
1523        } else {
1524            self.history_index = Some(idx + 1);
1525            self.input = self.history[idx + 1].clone();
1526        }
1527        self.cursor_pos = self.input.len();
1528        self.paste_blocks.clear();
1529        self.chips.clear();
1530    }
1531}
1532
1533pub fn copy_to_clipboard(text: &str) {
1534    let encoded =
1535        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
1536    let osc = format!("\x1b]52;c;{}\x07", encoded);
1537    let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
1538
1539    #[cfg(target_os = "macos")]
1540    {
1541        use std::process::{Command, Stdio};
1542        if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
1543            if let Some(ref mut stdin) = child.stdin {
1544                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1545            }
1546            let _ = child.wait();
1547        }
1548    }
1549
1550    #[cfg(target_os = "linux")]
1551    {
1552        use std::process::{Command, Stdio};
1553        let result = Command::new("xclip")
1554            .args(["-selection", "clipboard"])
1555            .stdin(Stdio::piped())
1556            .spawn();
1557        if let Ok(mut child) = result {
1558            if let Some(ref mut stdin) = child.stdin {
1559                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1560            }
1561            let _ = child.wait();
1562        }
1563    }
1564}