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