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 current_tool: Option<String>,
247    pub current_tool_detail: Option<String>,
248    pub tool_history: Vec<SubagentToolEntry>,
249    pub tokens: u32,
250    pub cost: f64,
251    pub text_lines: Vec<String>,
252}
253
254#[derive(Debug)]
255pub struct PendingPermission {
256    pub tool_name: String,
257    pub input_summary: String,
258    pub selected: usize,
259    pub responder: Option<QuestionResponder>,
260}
261
262pub struct QueuedMessage {
263    pub text: String,
264    pub images: Vec<(String, String)>,
265}
266
267#[derive(PartialEq, Clone, Copy)]
268pub enum AppMode {
269    Normal,
270    Insert,
271}
272
273#[derive(Default)]
274pub struct LayoutRects {
275    pub header: Rect,
276    pub messages: Rect,
277    pub input: Rect,
278    pub status: Rect,
279    pub model_selector: Option<Rect>,
280    pub agent_selector: Option<Rect>,
281    pub command_palette: Option<Rect>,
282    pub thinking_selector: Option<Rect>,
283    pub session_selector: Option<Rect>,
284    pub help_popup: Option<Rect>,
285    pub context_menu: Option<Rect>,
286    pub question_popup: Option<Rect>,
287    pub permission_popup: Option<Rect>,
288    pub file_picker: Option<Rect>,
289    pub login_popup: Option<Rect>,
290    pub welcome_screen: Option<Rect>,
291    pub aside_popup: Option<Rect>,
292}
293
294pub struct RenderCache {
295    pub lines: Vec<Line<'static>>,
296    pub line_to_msg: Vec<usize>,
297    pub line_to_tool: Vec<Option<(usize, usize)>>,
298    pub total_visual: u32,
299    pub width: u16,
300    pub wrap_heights: Vec<u32>,
301}
302
303pub struct App {
304    pub messages: Vec<ChatMessage>,
305    pub input: String,
306    pub cursor_pos: usize,
307    pub scroll_offset: u32,
308    pub max_scroll: u32,
309    pub is_streaming: bool,
310    pub current_response: String,
311    pub current_thinking: String,
312    pub should_quit: bool,
313    pub mode: AppMode,
314    pub usage: TokenUsage,
315    pub model_name: String,
316    pub provider_name: String,
317    pub agent_name: String,
318    pub theme: Theme,
319    pub tick_count: u64,
320    pub layout: LayoutRects,
321
322    pub pending_tool_name: Option<String>,
323    pub pending_tool_input: String,
324    pub current_tool_calls: Vec<ToolCallDisplay>,
325    pub streaming_segments: Vec<StreamSegment>,
326    pub status_message: Option<StatusMessage>,
327    pub model_selector: ModelSelector,
328    pub agent_selector: AgentSelector,
329    pub command_palette: CommandPalette,
330    pub thinking_selector: ThinkingSelector,
331    pub session_selector: SessionSelector,
332    pub help_popup: HelpPopup,
333    pub streaming_started: Option<Instant>,
334
335    pub thinking_expanded: bool,
336    pub thinking_budget: u32,
337    pub last_escape_time: Option<Instant>,
338    pub follow_bottom: bool,
339
340    pub paste_blocks: Vec<PasteBlock>,
341    pub attachments: Vec<ImageAttachment>,
342    pub conversation_title: Option<String>,
343    pub vim_mode: bool,
344
345    pub selection: TextSelection,
346
347    pub content_width: u16,
348
349    pub context_window: u32,
350    pub last_input_tokens: u32,
351
352    pub esc_hint_until: Option<Instant>,
353    pub todos: Vec<TodoItem>,
354    pub message_line_map: Vec<usize>,
355    pub tool_line_map: Vec<Option<(usize, usize)>>,
356    pub expanded_tool_calls: HashSet<(usize, usize)>,
357    pub context_menu: MessageContextMenu,
358    pub pending_question: Option<PendingQuestion>,
359    pub pending_permission: Option<PendingPermission>,
360    pub message_queue: VecDeque<QueuedMessage>,
361    pub history: Vec<String>,
362    pub history_index: Option<usize>,
363    pub history_draft: String,
364    pub skill_entries: Vec<(String, String)>,
365    pub custom_command_names: Vec<String>,
366    pub rename_input: String,
367    pub rename_visible: bool,
368    pub favorite_models: Vec<String>,
369    pub file_picker: FilePicker,
370    pub login_popup: LoginPopup,
371    pub welcome_screen: WelcomeScreen,
372    pub aside_popup: AsidePopup,
373    pub chips: Vec<InputChip>,
374    pub active_subagent: Option<SubagentState>,
375    pub background_subagents: Vec<BackgroundSubagentInfo>,
376
377    pub render_dirty: bool,
378    pub render_cache: Option<RenderCache>,
379    pub tool_call_complete_ticks: HashMap<(usize, usize), u64>,
380    pub input_at_top: bool,
381
382    pub cached_model_groups: Option<Vec<(String, Vec<String>)>>,
383    pub model_fetch_rx: Option<ModelFetchReceiver>,
384
385    pub cursor_shape: CursorShape,
386    pub cursor_blink: bool,
387    pub cursor_shape_normal: Option<CursorShape>,
388    pub cursor_blink_normal: Option<bool>,
389}
390impl App {
391    #[allow(clippy::too_many_arguments)]
392    pub fn new(
393        model_name: String,
394        provider_name: String,
395        agent_name: String,
396        theme_name: &str,
397        vim_mode: bool,
398        cursor_shape: CursorShape,
399        cursor_blink: bool,
400        cursor_shape_normal: Option<CursorShape>,
401        cursor_blink_normal: Option<bool>,
402    ) -> Self {
403        Self {
404            messages: Vec::new(),
405            input: String::new(),
406            cursor_pos: 0,
407            scroll_offset: 0,
408            max_scroll: 0,
409            is_streaming: false,
410            current_response: String::new(),
411            current_thinking: String::new(),
412            should_quit: false,
413            mode: AppMode::Insert,
414            usage: TokenUsage::default(),
415            model_name,
416            provider_name,
417            agent_name,
418            theme: Theme::from_config(theme_name),
419            tick_count: 0,
420            layout: LayoutRects::default(),
421            pending_tool_name: None,
422            pending_tool_input: String::new(),
423            current_tool_calls: Vec::new(),
424            streaming_segments: Vec::new(),
425            status_message: None,
426            model_selector: ModelSelector::new(),
427            agent_selector: AgentSelector::new(),
428            command_palette: CommandPalette::new(),
429            thinking_selector: ThinkingSelector::new(),
430            session_selector: SessionSelector::new(),
431            help_popup: HelpPopup::new(),
432            streaming_started: None,
433            thinking_expanded: false,
434            thinking_budget: 0,
435            last_escape_time: None,
436            follow_bottom: true,
437            paste_blocks: Vec::new(),
438            attachments: Vec::new(),
439            conversation_title: None,
440            vim_mode,
441            selection: TextSelection::default(),
442
443            content_width: 0,
444            context_window: 0,
445            last_input_tokens: 0,
446            esc_hint_until: None,
447            todos: Vec::new(),
448            message_line_map: Vec::new(),
449            tool_line_map: Vec::new(),
450            expanded_tool_calls: HashSet::new(),
451            context_menu: MessageContextMenu::new(),
452            pending_question: None,
453            pending_permission: None,
454            message_queue: VecDeque::new(),
455            history: Vec::new(),
456            history_index: None,
457            history_draft: String::new(),
458            skill_entries: Vec::new(),
459            custom_command_names: Vec::new(),
460            rename_input: String::new(),
461            rename_visible: false,
462            favorite_models: Vec::new(),
463            file_picker: FilePicker::new(),
464            login_popup: LoginPopup::new(),
465            welcome_screen: WelcomeScreen::new(),
466            aside_popup: AsidePopup::new(),
467            chips: Vec::new(),
468            active_subagent: None,
469            background_subagents: Vec::new(),
470            render_dirty: true,
471            render_cache: None,
472            tool_call_complete_ticks: HashMap::new(),
473            input_at_top: false,
474            cached_model_groups: None,
475            model_fetch_rx: None,
476            cursor_shape,
477            cursor_blink,
478            cursor_shape_normal,
479            cursor_blink_normal,
480        }
481    }
482
483    pub fn mark_dirty(&mut self) {
484        self.render_dirty = true;
485    }
486
487    pub fn streaming_elapsed_secs(&self) -> Option<f64> {
488        self.streaming_started
489            .map(|start| start.elapsed().as_secs_f64())
490    }
491
492    pub fn thinking_level(&self) -> ThinkingLevel {
493        ThinkingLevel::from_budget(self.thinking_budget)
494    }
495
496    pub fn handle_agent_event(&mut self, event: AgentEvent) {
497        match event {
498            AgentEvent::TextDelta(text) => {
499                self.current_response.push_str(&text);
500                self.mark_dirty();
501            }
502            AgentEvent::ThinkingDelta(text) => {
503                self.current_thinking.push_str(&text);
504                self.mark_dirty();
505            }
506            AgentEvent::TextComplete(text) => {
507                if !text.is_empty()
508                    || !self.current_response.is_empty()
509                    || !self.streaming_segments.is_empty()
510                {
511                    if !self.current_response.is_empty() {
512                        self.streaming_segments
513                            .push(StreamSegment::Text(std::mem::take(
514                                &mut self.current_response,
515                            )));
516                    }
517                    let content: String = self
518                        .streaming_segments
519                        .iter()
520                        .filter_map(|s| {
521                            if let StreamSegment::Text(t) = s {
522                                Some(t.as_str())
523                            } else {
524                                None
525                            }
526                        })
527                        .collect();
528                    let content = if content.is_empty() {
529                        text.clone()
530                    } else {
531                        content
532                    };
533                    let thinking = if self.current_thinking.is_empty() {
534                        None
535                    } else {
536                        Some(self.current_thinking.clone())
537                    };
538                    self.messages.push(ChatMessage {
539                        role: "assistant".to_string(),
540                        content,
541                        tool_calls: std::mem::take(&mut self.current_tool_calls),
542                        thinking,
543                        model: Some(self.model_name.clone()),
544                        segments: Some(std::mem::take(&mut self.streaming_segments)),
545                        chips: None,
546                    });
547                    self.mark_dirty();
548                }
549                self.current_response.clear();
550                self.current_thinking.clear();
551                self.streaming_segments.clear();
552                self.is_streaming = false;
553                self.streaming_started = None;
554                self.scroll_to_bottom();
555            }
556            AgentEvent::ToolCallStart { name, .. } => {
557                self.pending_tool_name = Some(name);
558                self.pending_tool_input.clear();
559                self.mark_dirty();
560            }
561            AgentEvent::ToolCallInputDelta(delta) => {
562                self.pending_tool_input.push_str(&delta);
563                self.mark_dirty();
564            }
565            AgentEvent::ToolCallExecuting { name, input, .. } => {
566                self.pending_tool_name = Some(name.clone());
567                self.pending_tool_input = input;
568                self.mark_dirty();
569            }
570            AgentEvent::ToolCallResult {
571                name,
572                output,
573                is_error,
574                ..
575            } => {
576                if !self.current_response.is_empty() {
577                    self.streaming_segments
578                        .push(StreamSegment::Text(std::mem::take(
579                            &mut self.current_response,
580                        )));
581                }
582                let input = std::mem::take(&mut self.pending_tool_input);
583                let category = ToolCategory::from_name(&name);
584                let detail = extract_tool_detail(&name, &input);
585                let display = ToolCallDisplay {
586                    name: name.clone(),
587                    input,
588                    output: Some(output),
589                    is_error,
590                    category,
591                    detail,
592                };
593                self.current_tool_calls.push(display.clone());
594                self.streaming_segments
595                    .push(StreamSegment::ToolCall(display));
596                self.pending_tool_name = None;
597                self.mark_dirty();
598            }
599            AgentEvent::Done { usage } => {
600                self.is_streaming = false;
601                self.streaming_started = None;
602                self.last_input_tokens = usage.input_tokens;
603                self.usage.input_tokens += usage.input_tokens;
604                self.usage.output_tokens += usage.output_tokens;
605                self.scroll_to_bottom();
606            }
607            AgentEvent::Error(msg) => {
608                self.is_streaming = false;
609                self.streaming_started = None;
610                self.status_message = Some(StatusMessage::error(msg));
611            }
612            AgentEvent::Compacting => {
613                self.messages.push(ChatMessage {
614                    role: "compact".to_string(),
615                    content: "\u{26a1} context compacted".to_string(),
616                    tool_calls: Vec::new(),
617                    thinking: None,
618                    model: None,
619                    segments: None,
620                    chips: None,
621                });
622            }
623            AgentEvent::TitleGenerated(title) => {
624                self.conversation_title = Some(title);
625            }
626            AgentEvent::Compacted { messages_removed } => {
627                if let Some(last) = self.messages.last_mut()
628                    && last.role == "compact"
629                {
630                    last.content = format!(
631                        "\u{26a1} compacted \u{2014} {} messages summarized",
632                        messages_removed
633                    );
634                }
635            }
636            AgentEvent::TodoUpdate(items) => {
637                self.todos = items;
638            }
639            AgentEvent::Question {
640                question,
641                options,
642                responder,
643                ..
644            } => {
645                self.pending_question = Some(PendingQuestion {
646                    question,
647                    options,
648                    selected: 0,
649                    custom_input: String::new(),
650                    responder: Some(responder),
651                });
652            }
653            AgentEvent::PermissionRequest {
654                tool_name,
655                input_summary,
656                responder,
657            } => {
658                self.pending_permission = Some(PendingPermission {
659                    tool_name,
660                    input_summary,
661                    selected: 0,
662                    responder: Some(responder),
663                });
664            }
665            AgentEvent::SubagentStart {
666                id,
667                description,
668                background,
669            } => {
670                if background {
671                    self.background_subagents.push(BackgroundSubagentInfo {
672                        id,
673                        description,
674                        output: String::new(),
675                        tools_completed: 0,
676                        done: false,
677                        started: Instant::now(),
678                        current_tool: None,
679                        current_tool_detail: None,
680                        tool_history: Vec::new(),
681                        tokens: 0,
682                        cost: 0.0,
683                        text_lines: Vec::new(),
684                    });
685                } else {
686                    self.active_subagent = Some(SubagentState {
687                        id,
688                        description,
689                        output: String::new(),
690                        current_tool: None,
691                        current_tool_detail: None,
692                        tools_completed: 0,
693                        background: false,
694                    });
695                }
696            }
697            AgentEvent::SubagentDelta { id, text } => {
698                if let Some(ref mut state) = self.active_subagent
699                    && state.id == id
700                {
701                    state.output.push_str(&text);
702                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
703                    bg.output.push_str(&text);
704                    let lines: Vec<String> = bg.output.lines().map(|l| l.to_string()).collect();
705                    let start = lines.len().saturating_sub(20);
706                    bg.text_lines = lines[start..].to_vec();
707                }
708            }
709            AgentEvent::SubagentToolStart {
710                id,
711                tool_name,
712                detail,
713            } => {
714                if let Some(ref mut state) = self.active_subagent
715                    && state.id == id
716                {
717                    state.current_tool = Some(tool_name);
718                    state.current_tool_detail = Some(detail);
719                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
720                    bg.current_tool = Some(tool_name.clone());
721                    bg.current_tool_detail = Some(detail.clone());
722                    bg.tool_history.push(SubagentToolEntry {
723                        name: tool_name,
724                        detail,
725                        done: false,
726                        is_error: false,
727                    });
728                }
729            }
730            AgentEvent::SubagentToolComplete { id, .. } => {
731                if let Some(ref mut state) = self.active_subagent
732                    && state.id == id
733                {
734                    state.current_tool = None;
735                    state.current_tool_detail = None;
736                    state.tools_completed += 1;
737                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
738                    bg.current_tool = None;
739                    bg.current_tool_detail = None;
740                    bg.tools_completed += 1;
741                    if let Some(entry) = bg.tool_history.iter_mut().rev().find(|e| !e.done) {
742                        entry.done = true;
743                    }
744                }
745            }
746            AgentEvent::SubagentComplete { id, .. } => {
747                if self.active_subagent.as_ref().is_some_and(|s| s.id == id) {
748                    self.active_subagent = None;
749                }
750            }
751            AgentEvent::SubagentBackgroundDone {
752                id, description, ..
753            } => {
754                if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
755                    bg.done = true;
756                }
757                self.status_message = Some(StatusMessage::success(format!(
758                    "Background subagent done: {}",
759                    description
760                )));
761            }
762            AgentEvent::MemoryExtracted {
763                added,
764                updated,
765                deleted,
766            } => {
767                let parts: Vec<String> = [
768                    (added > 0).then(|| format!("+{added}")),
769                    (updated > 0).then(|| format!("~{updated}")),
770                    (deleted > 0).then(|| format!("-{deleted}")),
771                ]
772                .into_iter()
773                .flatten()
774                .collect();
775                if !parts.is_empty() {
776                    self.status_message = Some(StatusMessage::success(format!(
777                        "memory {}",
778                        parts.join(" ")
779                    )));
780                }
781            }
782            AgentEvent::AsideDelta(text) => {
783                self.aside_popup.response.push_str(&text);
784            }
785            AgentEvent::AsideDone => {
786                self.aside_popup.done = true;
787            }
788            AgentEvent::AsideError(msg) => {
789                self.aside_popup.response = format!("Error: {msg}");
790                self.aside_popup.done = true;
791            }
792        }
793        self.mark_dirty();
794    }
795
796    pub fn take_input(&mut self) -> Option<String> {
797        let trimmed = self.input.trim().to_string();
798        if trimmed.is_empty() && self.attachments.is_empty() {
799            return None;
800        }
801        let display = if self.attachments.is_empty() {
802            trimmed.clone()
803        } else {
804            let att_names: Vec<String> = self
805                .attachments
806                .iter()
807                .map(|a| {
808                    Path::new(&a.path)
809                        .file_name()
810                        .map(|f| f.to_string_lossy().to_string())
811                        .unwrap_or_else(|| a.path.clone())
812                })
813                .collect();
814            if trimmed.is_empty() {
815                format!("[{}]", att_names.join(", "))
816            } else {
817                format!("{} [{}]", trimmed, att_names.join(", "))
818            }
819        };
820        let chips = std::mem::take(&mut self.chips);
821        self.messages.push(ChatMessage {
822            role: "user".to_string(),
823            content: display,
824            tool_calls: Vec::new(),
825            thinking: None,
826            model: None,
827            segments: None,
828            chips: if chips.is_empty() { None } else { Some(chips) },
829        });
830        self.input.clear();
831        self.cursor_pos = 0;
832        self.paste_blocks.clear();
833        self.history.push(trimmed.clone());
834        self.history_index = None;
835        self.history_draft.clear();
836        self.is_streaming = true;
837        self.streaming_started = Some(Instant::now());
838        self.current_response.clear();
839        self.current_thinking.clear();
840        self.current_tool_calls.clear();
841        self.streaming_segments.clear();
842        self.status_message = None;
843        self.scroll_to_bottom();
844        self.mark_dirty();
845        Some(trimmed)
846    }
847
848    pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
849        std::mem::take(&mut self.attachments)
850    }
851
852    pub fn queue_input(&mut self) -> bool {
853        let trimmed = self.input.trim().to_string();
854        if trimmed.is_empty() && self.attachments.is_empty() {
855            return false;
856        }
857        let display = if self.attachments.is_empty() {
858            trimmed.clone()
859        } else {
860            let names: Vec<String> = self
861                .attachments
862                .iter()
863                .map(|a| {
864                    Path::new(&a.path)
865                        .file_name()
866                        .map(|f| f.to_string_lossy().to_string())
867                        .unwrap_or_else(|| a.path.clone())
868                })
869                .collect();
870            if trimmed.is_empty() {
871                format!("[{}]", names.join(", "))
872            } else {
873                format!("{} [{}]", trimmed, names.join(", "))
874            }
875        };
876        let chips = std::mem::take(&mut self.chips);
877        self.messages.push(ChatMessage {
878            role: "user".to_string(),
879            content: display,
880            tool_calls: Vec::new(),
881            thinking: None,
882            model: None,
883            segments: None,
884            chips: if chips.is_empty() { None } else { Some(chips) },
885        });
886        let images: Vec<(String, String)> = self
887            .attachments
888            .drain(..)
889            .map(|a| (a.media_type, a.data))
890            .collect();
891        self.history.push(trimmed.clone());
892        self.history_index = None;
893        self.history_draft.clear();
894        self.message_queue.push_back(QueuedMessage {
895            text: trimmed,
896            images,
897        });
898        self.input.clear();
899        self.cursor_pos = 0;
900        self.paste_blocks.clear();
901        self.scroll_to_bottom();
902        self.mark_dirty();
903        true
904    }
905
906    pub fn input_height(&self, width: u16) -> u16 {
907        if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
908            return 1;
909        }
910        let w = width as usize;
911        if w < 4 {
912            return 1;
913        }
914        let has_input = !self.input.is_empty() || !self.attachments.is_empty();
915        if !has_input {
916            return 1;
917        }
918        let mut visual = 0usize;
919        if !self.attachments.is_empty() {
920            visual += 1;
921        }
922        let display = self.display_input();
923        if display.is_empty() {
924            if self.attachments.is_empty() {
925                visual += 1;
926            }
927        } else {
928            for line in display.split('\n') {
929                let total = 2 + line.chars().count();
930                visual += if total == 0 {
931                    1
932                } else {
933                    total.div_ceil(w).max(1)
934                };
935            }
936        }
937        (visual as u16).clamp(1, 12)
938    }
939
940    pub fn handle_paste(&mut self, text: String) {
941        let line_count = text.lines().count();
942        let start = self.cursor_pos;
943        let len = text.len();
944        self.input.insert_str(start, &text);
945        self.adjust_chips(start, 0, len);
946        self.cursor_pos = start + len;
947        if line_count >= PASTE_COLLAPSE_THRESHOLD {
948            self.paste_blocks.push(PasteBlock {
949                start,
950                end: start + len,
951                line_count,
952            });
953        }
954    }
955
956    pub fn paste_block_at_cursor(&self) -> Option<usize> {
957        self.paste_blocks
958            .iter()
959            .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
960    }
961
962    pub fn delete_paste_block(&mut self, idx: usize) {
963        let pb = self.paste_blocks.remove(idx);
964        let len = pb.end - pb.start;
965        self.input.replace_range(pb.start..pb.end, "");
966        self.cursor_pos = pb.start;
967        for remaining in &mut self.paste_blocks {
968            if remaining.start >= pb.end {
969                remaining.start -= len;
970                remaining.end -= len;
971            }
972        }
973    }
974
975    pub fn chip_at_cursor(&self) -> Option<usize> {
976        self.chips
977            .iter()
978            .position(|c| self.cursor_pos > c.start && self.cursor_pos <= c.end)
979    }
980
981    pub fn delete_chip(&mut self, idx: usize) {
982        let chip = self.chips.remove(idx);
983        let len = chip.end - chip.start;
984        self.input.replace_range(chip.start..chip.end, "");
985        self.cursor_pos = chip.start;
986        self.adjust_chips(chip.start, len, 0);
987    }
988
989    pub fn adjust_chips(&mut self, edit_start: usize, old_len: usize, new_len: usize) {
990        let edit_end = edit_start + old_len;
991        let delta = new_len as isize - old_len as isize;
992        self.chips.retain_mut(|c| {
993            if c.start >= edit_end {
994                c.start = (c.start as isize + delta) as usize;
995                c.end = (c.end as isize + delta) as usize;
996                true
997            } else {
998                c.end <= edit_start
999            }
1000        });
1001    }
1002
1003    pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
1004        let resolved = if path.starts_with('~') {
1005            if let Ok(home) = std::env::var("HOME") {
1006                path.replacen('~', &home, 1)
1007            } else {
1008                path.to_string()
1009            }
1010        } else {
1011            path.to_string()
1012        };
1013
1014        let fs_path = Path::new(&resolved);
1015        if !fs_path.exists() {
1016            return Err(format!("file not found: {}", path));
1017        }
1018
1019        let media_type = media_type_for_path(&resolved)
1020            .ok_or_else(|| format!("unsupported image format: {}", path))?;
1021
1022        let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
1023        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
1024
1025        if self.attachments.iter().any(|a| a.path == resolved) {
1026            return Ok(());
1027        }
1028
1029        self.attachments.push(ImageAttachment {
1030            path: resolved,
1031            media_type,
1032            data: encoded,
1033        });
1034        Ok(())
1035    }
1036
1037    pub fn insert_file_reference(&mut self, path: &str) {
1038        let text = format!("@{} ", path);
1039        let start = self.cursor_pos;
1040        let len = text.len();
1041        self.input.insert_str(start, &text);
1042        self.adjust_chips(start, 0, len);
1043        let chip_end = start + 1 + path.len();
1044        self.chips.push(InputChip {
1045            start,
1046            end: chip_end,
1047            kind: ChipKind::File,
1048        });
1049        self.cursor_pos = start + len;
1050    }
1051
1052    pub fn display_input(&self) -> String {
1053        if self.paste_blocks.is_empty() {
1054            return self.input.clone();
1055        }
1056        let mut result = String::new();
1057        let mut pos = 0;
1058        let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
1059        sorted_blocks.sort_by_key(|pb| pb.start);
1060        for pb in sorted_blocks {
1061            if pb.start > pos {
1062                result.push_str(&self.input[pos..pb.start]);
1063            }
1064            result.push_str(&format!("[pasted {} lines]", pb.line_count));
1065            pos = pb.end;
1066        }
1067        if pos < self.input.len() {
1068            result.push_str(&self.input[pos..]);
1069        }
1070        result
1071    }
1072
1073    pub fn scroll_up(&mut self, n: u32) {
1074        self.follow_bottom = false;
1075        self.scroll_offset = self.scroll_offset.saturating_sub(n);
1076    }
1077
1078    pub fn scroll_down(&mut self, n: u32) {
1079        let target = self.scroll_offset.saturating_add(n);
1080        if target >= self.max_scroll {
1081            self.follow_bottom = true;
1082            self.scroll_offset = self.max_scroll;
1083        } else {
1084            self.scroll_offset = target;
1085        }
1086    }
1087
1088    pub fn scroll_to_top(&mut self) {
1089        self.follow_bottom = false;
1090        self.scroll_offset = 0;
1091    }
1092
1093    pub fn scroll_to_bottom(&mut self) {
1094        self.follow_bottom = true;
1095        self.scroll_offset = self.max_scroll;
1096    }
1097
1098    pub fn clear_conversation(&mut self) {
1099        self.messages.clear();
1100        self.current_response.clear();
1101        self.current_thinking.clear();
1102        self.current_tool_calls.clear();
1103        self.streaming_segments.clear();
1104        self.scroll_offset = 0;
1105        self.max_scroll = 0;
1106        self.follow_bottom = true;
1107        self.usage = TokenUsage::default();
1108        self.last_input_tokens = 0;
1109        self.status_message = None;
1110        self.paste_blocks.clear();
1111        self.chips.clear();
1112        self.attachments.clear();
1113        self.conversation_title = None;
1114        self.selection.clear();
1115
1116        self.todos.clear();
1117        self.message_line_map.clear();
1118        self.tool_line_map.clear();
1119        self.expanded_tool_calls.clear();
1120        self.esc_hint_until = None;
1121        self.context_menu.close();
1122        self.pending_question = None;
1123        self.pending_permission = None;
1124        self.active_subagent = None;
1125        self.background_subagents.clear();
1126        self.message_queue.clear();
1127        self.render_cache = None;
1128        self.tool_call_complete_ticks.clear();
1129        self.mark_dirty();
1130    }
1131
1132    pub fn insert_char(&mut self, c: char) {
1133        let pos = self.cursor_pos;
1134        self.input.insert(pos, c);
1135        let len = c.len_utf8();
1136        self.adjust_chips(pos, 0, len);
1137        self.cursor_pos += len;
1138    }
1139
1140    pub fn delete_char_before(&mut self) {
1141        if self.cursor_pos > 0 {
1142            let prev = self.input[..self.cursor_pos]
1143                .chars()
1144                .last()
1145                .map(|c| c.len_utf8())
1146                .unwrap_or(0);
1147            self.cursor_pos -= prev;
1148            self.input.remove(self.cursor_pos);
1149            self.adjust_chips(self.cursor_pos, prev, 0);
1150        }
1151    }
1152
1153    pub fn move_cursor_left(&mut self) {
1154        if self.cursor_pos > 0 {
1155            let prev = self.input[..self.cursor_pos]
1156                .chars()
1157                .last()
1158                .map(|c| c.len_utf8())
1159                .unwrap_or(0);
1160            self.cursor_pos -= prev;
1161        }
1162    }
1163
1164    pub fn move_cursor_right(&mut self) {
1165        if self.cursor_pos < self.input.len() {
1166            let next = self.input[self.cursor_pos..]
1167                .chars()
1168                .next()
1169                .map(|c| c.len_utf8())
1170                .unwrap_or(0);
1171            self.cursor_pos += next;
1172        }
1173    }
1174
1175    pub fn move_cursor_home(&mut self) {
1176        self.cursor_pos = 0;
1177    }
1178
1179    pub fn move_cursor_end(&mut self) {
1180        self.cursor_pos = self.input.len();
1181    }
1182
1183    pub fn delete_word_before(&mut self) {
1184        if self.cursor_pos == 0 {
1185            return;
1186        }
1187        let before = &self.input[..self.cursor_pos];
1188        let trimmed = before.trim_end();
1189        let new_end = if trimmed.is_empty() {
1190            0
1191        } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
1192            pos + trimmed[pos..]
1193                .chars()
1194                .next()
1195                .map(|c| c.len_utf8())
1196                .unwrap_or(1)
1197        } else {
1198            0
1199        };
1200        let old_len = self.cursor_pos - new_end;
1201        self.input.replace_range(new_end..self.cursor_pos, "");
1202        self.adjust_chips(new_end, old_len, 0);
1203        self.cursor_pos = new_end;
1204    }
1205
1206    pub fn delete_to_end(&mut self) {
1207        let old_len = self.input.len() - self.cursor_pos;
1208        self.input.truncate(self.cursor_pos);
1209        self.adjust_chips(self.cursor_pos, old_len, 0);
1210    }
1211
1212    pub fn delete_to_start(&mut self) {
1213        let old_len = self.cursor_pos;
1214        self.input.replace_range(..self.cursor_pos, "");
1215        self.adjust_chips(0, old_len, 0);
1216        self.cursor_pos = 0;
1217    }
1218
1219    pub fn extract_selected_text(&self) -> Option<String> {
1220        let ((sc, sr), (ec, er)) = self.selection.ordered()?;
1221        let cache = self.render_cache.as_ref()?;
1222        if cache.lines.is_empty() || self.content_width == 0 {
1223            return None;
1224        }
1225        let mut text = String::new();
1226        for row in sr..=er {
1227            if row as usize >= cache.lines.len() {
1228                break;
1229            }
1230            let line_text: String = cache.lines[row as usize]
1231                .spans
1232                .iter()
1233                .map(|s| s.content.as_ref())
1234                .collect();
1235            let chars: Vec<char> = line_text.chars().collect();
1236            let start_col = if row == sr {
1237                (sc as usize).min(chars.len())
1238            } else {
1239                0
1240            };
1241            let end_col = if row == er {
1242                (ec as usize).min(chars.len())
1243            } else {
1244                chars.len()
1245            };
1246            if start_col <= end_col {
1247                let s = start_col.min(chars.len());
1248                let e = end_col.min(chars.len());
1249                text.extend(&chars[s..e]);
1250            }
1251            if row < er {
1252                text.push('\n');
1253            }
1254        }
1255        Some(text)
1256    }
1257
1258    pub fn move_cursor_up(&mut self) -> bool {
1259        let before = &self.input[..self.cursor_pos];
1260        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1261        if line_start == 0 {
1262            return false;
1263        }
1264        let col = before[line_start..].chars().count();
1265        let prev_end = line_start - 1;
1266        let prev_start = self.input[..prev_end]
1267            .rfind('\n')
1268            .map(|p| p + 1)
1269            .unwrap_or(0);
1270        let prev_line = &self.input[prev_start..prev_end];
1271        let target_col = col.min(prev_line.chars().count());
1272        let offset: usize = prev_line
1273            .chars()
1274            .take(target_col)
1275            .map(|c| c.len_utf8())
1276            .sum();
1277        self.cursor_pos = prev_start + offset;
1278        true
1279    }
1280
1281    pub fn move_cursor_down(&mut self) -> bool {
1282        let after = &self.input[self.cursor_pos..];
1283        let next_nl = after.find('\n');
1284        let Some(nl_offset) = next_nl else {
1285            return false;
1286        };
1287        let before = &self.input[..self.cursor_pos];
1288        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1289        let col = before[line_start..].chars().count();
1290        let next_start = self.cursor_pos + nl_offset + 1;
1291        let next_end = self.input[next_start..]
1292            .find('\n')
1293            .map(|p| next_start + p)
1294            .unwrap_or(self.input.len());
1295        let next_line = &self.input[next_start..next_end];
1296        let target_col = col.min(next_line.chars().count());
1297        let offset: usize = next_line
1298            .chars()
1299            .take(target_col)
1300            .map(|c| c.len_utf8())
1301            .sum();
1302        self.cursor_pos = next_start + offset;
1303        true
1304    }
1305
1306    pub fn history_prev(&mut self) {
1307        if self.history.is_empty() {
1308            return;
1309        }
1310        match self.history_index {
1311            None => {
1312                self.history_draft = self.input.clone();
1313                self.history_index = Some(self.history.len() - 1);
1314            }
1315            Some(0) => return,
1316            Some(i) => {
1317                self.history_index = Some(i - 1);
1318            }
1319        }
1320        self.input = self.history[self.history_index.unwrap()].clone();
1321        self.cursor_pos = self.input.len();
1322        self.paste_blocks.clear();
1323        self.chips.clear();
1324    }
1325
1326    pub fn history_next(&mut self) {
1327        let Some(idx) = self.history_index else {
1328            return;
1329        };
1330        if idx + 1 >= self.history.len() {
1331            self.history_index = None;
1332            self.input = std::mem::take(&mut self.history_draft);
1333        } else {
1334            self.history_index = Some(idx + 1);
1335            self.input = self.history[idx + 1].clone();
1336        }
1337        self.cursor_pos = self.input.len();
1338        self.paste_blocks.clear();
1339        self.chips.clear();
1340    }
1341}
1342
1343pub fn copy_to_clipboard(text: &str) {
1344    let encoded =
1345        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
1346    let osc = format!("\x1b]52;c;{}\x07", encoded);
1347    let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
1348
1349    #[cfg(target_os = "macos")]
1350    {
1351        use std::process::{Command, Stdio};
1352        if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
1353            if let Some(ref mut stdin) = child.stdin {
1354                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1355            }
1356            let _ = child.wait();
1357        }
1358    }
1359
1360    #[cfg(target_os = "linux")]
1361    {
1362        use std::process::{Command, Stdio};
1363        let result = Command::new("xclip")
1364            .args(["-selection", "clipboard"])
1365            .stdin(Stdio::piped())
1366            .spawn();
1367        if let Ok(mut child) = result {
1368            if let Some(ref mut stdin) = child.stdin {
1369                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1370            }
1371            let _ = child.wait();
1372        }
1373    }
1374}