Skip to main content

dot/tui/
app.rs

1use std::collections::{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::tui::theme::Theme;
10use crate::tui::tools::{StreamSegment, ToolCallDisplay, ToolCategory, extract_tool_detail};
11use crate::tui::widgets::{
12    AgentSelector, CommandPalette, FilePicker, HelpPopup, MessageContextMenu, ModelSelector,
13    SessionSelector, ThinkingLevel, ThinkingSelector,
14};
15
16pub struct ChatMessage {
17    pub role: String,
18    pub content: String,
19    pub tool_calls: Vec<ToolCallDisplay>,
20    pub thinking: Option<String>,
21    pub model: Option<String>,
22    /// Interleaved text and tool calls in display order. When Some, used for rendering; when None, fall back to content + tool_calls.
23    pub segments: Option<Vec<StreamSegment>>,
24}
25
26pub struct TokenUsage {
27    pub input_tokens: u32,
28    pub output_tokens: u32,
29    pub total_cost: f64,
30}
31
32impl Default for TokenUsage {
33    fn default() -> Self {
34        Self {
35            input_tokens: 0,
36            output_tokens: 0,
37            total_cost: 0.0,
38        }
39    }
40}
41
42#[derive(Debug, Clone)]
43pub struct PasteBlock {
44    pub start: usize,
45    pub end: usize,
46    pub line_count: usize,
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum ChipKind {
51    File,
52    Skill,
53}
54
55#[derive(Debug, Clone)]
56pub struct InputChip {
57    pub start: usize,
58    pub end: usize,
59    pub kind: ChipKind,
60}
61
62#[derive(Debug, Clone)]
63pub struct ImageAttachment {
64    pub path: String,
65    pub media_type: String,
66    pub data: String,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq)]
70pub enum StatusLevel {
71    Error,
72    Info,
73    Success,
74}
75
76pub struct StatusMessage {
77    pub text: String,
78    pub level: StatusLevel,
79    pub created: Instant,
80}
81
82impl StatusMessage {
83    pub fn error(text: impl Into<String>) -> Self {
84        Self {
85            text: text.into(),
86            level: StatusLevel::Error,
87            created: Instant::now(),
88        }
89    }
90
91    pub fn info(text: impl Into<String>) -> Self {
92        Self {
93            text: text.into(),
94            level: StatusLevel::Info,
95            created: Instant::now(),
96        }
97    }
98
99    pub fn success(text: impl Into<String>) -> Self {
100        Self {
101            text: text.into(),
102            level: StatusLevel::Success,
103            created: Instant::now(),
104        }
105    }
106
107    pub fn expired(&self) -> bool {
108        let ttl = match self.level {
109            StatusLevel::Error => std::time::Duration::from_secs(8),
110            StatusLevel::Info => std::time::Duration::from_secs(3),
111            StatusLevel::Success => std::time::Duration::from_secs(4),
112        };
113        self.created.elapsed() > ttl
114    }
115}
116
117const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
118
119#[derive(Default)]
120pub struct TextSelection {
121    pub anchor: Option<(u16, u16)>,
122    pub end: Option<(u16, u16)>,
123    pub active: bool,
124}
125
126impl TextSelection {
127    pub fn start(&mut self, col: u16, visual_row: u16) {
128        self.anchor = Some((col, visual_row));
129        self.end = Some((col, visual_row));
130        self.active = true;
131    }
132
133    pub fn update(&mut self, col: u16, visual_row: u16) {
134        self.end = Some((col, visual_row));
135    }
136
137    pub fn clear(&mut self) {
138        self.anchor = None;
139        self.end = None;
140        self.active = false;
141    }
142
143    pub fn ordered(&self) -> Option<((u16, u16), (u16, u16))> {
144        let a = self.anchor?;
145        let e = self.end?;
146        if a.1 < e.1 || (a.1 == e.1 && a.0 <= e.0) {
147            Some((a, e))
148        } else {
149            Some((e, a))
150        }
151    }
152
153    pub fn is_empty_selection(&self) -> bool {
154        match (self.anchor, self.end) {
155            (Some(a), Some(e)) => a == e,
156            _ => true,
157        }
158    }
159}
160
161pub fn media_type_for_path(path: &str) -> Option<String> {
162    let ext = Path::new(path).extension()?.to_str()?.to_lowercase();
163    match ext.as_str() {
164        "png" => Some("image/png".into()),
165        "jpg" | "jpeg" => Some("image/jpeg".into()),
166        "gif" => Some("image/gif".into()),
167        "webp" => Some("image/webp".into()),
168        "bmp" => Some("image/bmp".into()),
169        "svg" => Some("image/svg+xml".into()),
170        _ => None,
171    }
172}
173
174pub fn is_image_path(path: &str) -> bool {
175    Path::new(path)
176        .extension()
177        .and_then(|e| e.to_str())
178        .map(|e| IMAGE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
179        .unwrap_or(false)
180}
181
182pub const PASTE_COLLAPSE_THRESHOLD: usize = 5;
183
184#[derive(Debug)]
185pub struct PendingQuestion {
186    pub question: String,
187    pub options: Vec<String>,
188    pub selected: usize,
189    pub custom_input: String,
190    pub responder: Option<QuestionResponder>,
191}
192
193pub struct SubagentState {
194    pub id: String,
195    pub description: String,
196    pub output: String,
197    pub current_tool: Option<String>,
198    pub current_tool_detail: Option<String>,
199    pub tools_completed: usize,
200    pub background: bool,
201}
202
203pub struct BackgroundSubagentInfo {
204    pub id: String,
205    pub description: String,
206    pub output: String,
207    pub tools_completed: usize,
208    pub done: bool,
209}
210
211#[derive(Debug)]
212pub struct PendingPermission {
213    pub tool_name: String,
214    pub input_summary: String,
215    pub selected: usize,
216    pub responder: Option<QuestionResponder>,
217}
218
219pub struct QueuedMessage {
220    pub text: String,
221    pub images: Vec<(String, String)>,
222}
223
224#[derive(PartialEq, Clone, Copy)]
225pub enum AppMode {
226    Normal,
227    Insert,
228}
229
230#[derive(Default)]
231pub struct LayoutRects {
232    pub header: Rect,
233    pub messages: Rect,
234    pub input: Rect,
235    pub status: Rect,
236    pub model_selector: Option<Rect>,
237    pub agent_selector: Option<Rect>,
238    pub command_palette: Option<Rect>,
239    pub thinking_selector: Option<Rect>,
240    pub session_selector: Option<Rect>,
241    pub help_popup: Option<Rect>,
242    pub context_menu: Option<Rect>,
243    pub question_popup: Option<Rect>,
244    pub permission_popup: Option<Rect>,
245    pub file_picker: Option<Rect>,
246}
247
248pub struct RenderCache {
249    pub lines: Vec<Line<'static>>,
250    pub line_to_msg: Vec<usize>,
251    pub line_to_tool: Vec<Option<(usize, usize)>>,
252    pub total_visual: u32,
253    pub width: u16,
254}
255
256pub struct App {
257    pub messages: Vec<ChatMessage>,
258    pub input: String,
259    pub cursor_pos: usize,
260    pub scroll_offset: u16,
261    pub max_scroll: u16,
262    pub is_streaming: bool,
263    pub current_response: String,
264    pub current_thinking: String,
265    pub should_quit: bool,
266    pub mode: AppMode,
267    pub usage: TokenUsage,
268    pub model_name: String,
269    pub provider_name: String,
270    pub agent_name: String,
271    pub theme: Theme,
272    pub tick_count: u64,
273    pub layout: LayoutRects,
274
275    pub pending_tool_name: Option<String>,
276    pub pending_tool_input: String,
277    pub current_tool_calls: Vec<ToolCallDisplay>,
278    pub streaming_segments: Vec<StreamSegment>,
279    pub status_message: Option<StatusMessage>,
280    pub model_selector: ModelSelector,
281    pub agent_selector: AgentSelector,
282    pub command_palette: CommandPalette,
283    pub thinking_selector: ThinkingSelector,
284    pub session_selector: SessionSelector,
285    pub help_popup: HelpPopup,
286    pub streaming_started: Option<Instant>,
287
288    pub thinking_expanded: bool,
289    pub thinking_budget: u32,
290    pub last_escape_time: Option<Instant>,
291    pub follow_bottom: bool,
292
293    pub paste_blocks: Vec<PasteBlock>,
294    pub attachments: Vec<ImageAttachment>,
295    pub conversation_title: Option<String>,
296    pub vim_mode: bool,
297
298    pub selection: TextSelection,
299    pub visual_lines: Vec<String>,
300    pub content_width: u16,
301
302    pub context_window: u32,
303    pub last_input_tokens: u32,
304
305    pub esc_hint_until: Option<Instant>,
306    pub todos: Vec<TodoItem>,
307    pub message_line_map: Vec<usize>,
308    pub tool_line_map: Vec<Option<(usize, usize)>>,
309    pub expanded_tool_calls: HashSet<(usize, usize)>,
310    pub context_menu: MessageContextMenu,
311    pub pending_question: Option<PendingQuestion>,
312    pub pending_permission: Option<PendingPermission>,
313    pub message_queue: VecDeque<QueuedMessage>,
314    pub history: Vec<String>,
315    pub history_index: Option<usize>,
316    pub history_draft: String,
317    pub skill_entries: Vec<(String, String)>,
318    pub custom_command_names: Vec<String>,
319    pub rename_input: String,
320    pub rename_visible: bool,
321    pub favorite_models: Vec<String>,
322    pub file_picker: FilePicker,
323    pub chips: Vec<InputChip>,
324    pub active_subagent: Option<SubagentState>,
325    pub background_subagents: Vec<BackgroundSubagentInfo>,
326
327    pub render_dirty: bool,
328    pub render_cache: Option<RenderCache>,
329}
330impl App {
331    pub fn new(
332        model_name: String,
333        provider_name: String,
334        agent_name: String,
335        theme_name: &str,
336        vim_mode: bool,
337    ) -> Self {
338        Self {
339            messages: Vec::new(),
340            input: String::new(),
341            cursor_pos: 0,
342            scroll_offset: 0,
343            max_scroll: 0,
344            is_streaming: false,
345            current_response: String::new(),
346            current_thinking: String::new(),
347            should_quit: false,
348            mode: AppMode::Insert,
349            usage: TokenUsage::default(),
350            model_name,
351            provider_name,
352            agent_name,
353            theme: Theme::from_config(theme_name),
354            tick_count: 0,
355            layout: LayoutRects::default(),
356            pending_tool_name: None,
357            pending_tool_input: String::new(),
358            current_tool_calls: Vec::new(),
359            streaming_segments: Vec::new(),
360            status_message: None,
361            model_selector: ModelSelector::new(),
362            agent_selector: AgentSelector::new(),
363            command_palette: CommandPalette::new(),
364            thinking_selector: ThinkingSelector::new(),
365            session_selector: SessionSelector::new(),
366            help_popup: HelpPopup::new(),
367            streaming_started: None,
368            thinking_expanded: false,
369            thinking_budget: 0,
370            last_escape_time: None,
371            follow_bottom: true,
372            paste_blocks: Vec::new(),
373            attachments: Vec::new(),
374            conversation_title: None,
375            vim_mode,
376            selection: TextSelection::default(),
377            visual_lines: Vec::new(),
378            content_width: 0,
379            context_window: 0,
380            last_input_tokens: 0,
381            esc_hint_until: None,
382            todos: Vec::new(),
383            message_line_map: Vec::new(),
384            tool_line_map: Vec::new(),
385            expanded_tool_calls: HashSet::new(),
386            context_menu: MessageContextMenu::new(),
387            pending_question: None,
388            pending_permission: None,
389            message_queue: VecDeque::new(),
390            history: Vec::new(),
391            history_index: None,
392            history_draft: String::new(),
393            skill_entries: Vec::new(),
394            custom_command_names: Vec::new(),
395            rename_input: String::new(),
396            rename_visible: false,
397            favorite_models: Vec::new(),
398            file_picker: FilePicker::new(),
399            chips: Vec::new(),
400            active_subagent: None,
401            background_subagents: Vec::new(),
402            render_dirty: true,
403            render_cache: None,
404        }
405    }
406
407    pub fn mark_dirty(&mut self) {
408        self.render_dirty = true;
409    }
410
411    pub fn streaming_elapsed_secs(&self) -> Option<f64> {
412        self.streaming_started
413            .map(|start| start.elapsed().as_secs_f64())
414    }
415
416    pub fn thinking_level(&self) -> ThinkingLevel {
417        ThinkingLevel::from_budget(self.thinking_budget)
418    }
419
420    pub fn handle_agent_event(&mut self, event: AgentEvent) {
421        match event {
422            AgentEvent::TextDelta(text) => {
423                self.current_response.push_str(&text);
424            }
425            AgentEvent::ThinkingDelta(text) => {
426                self.current_thinking.push_str(&text);
427            }
428            AgentEvent::TextComplete(text) => {
429                if !text.is_empty()
430                    || !self.current_response.is_empty()
431                    || !self.streaming_segments.is_empty()
432                {
433                    if !self.current_response.is_empty() {
434                        self.streaming_segments
435                            .push(StreamSegment::Text(std::mem::take(
436                                &mut self.current_response,
437                            )));
438                    }
439                    let content: String = self
440                        .streaming_segments
441                        .iter()
442                        .filter_map(|s| {
443                            if let StreamSegment::Text(t) = s {
444                                Some(t.as_str())
445                            } else {
446                                None
447                            }
448                        })
449                        .collect();
450                    let content = if content.is_empty() {
451                        text.clone()
452                    } else {
453                        content
454                    };
455                    let thinking = if self.current_thinking.is_empty() {
456                        None
457                    } else {
458                        Some(self.current_thinking.clone())
459                    };
460                    self.messages.push(ChatMessage {
461                        role: "assistant".to_string(),
462                        content,
463                        tool_calls: std::mem::take(&mut self.current_tool_calls),
464                        thinking,
465                        model: Some(self.model_name.clone()),
466                        segments: Some(std::mem::take(&mut self.streaming_segments)),
467                    });
468                }
469                self.current_response.clear();
470                self.current_thinking.clear();
471                self.streaming_segments.clear();
472                self.is_streaming = false;
473                self.streaming_started = None;
474                self.scroll_to_bottom();
475            }
476            AgentEvent::ToolCallStart { name, .. } => {
477                self.pending_tool_name = Some(name);
478                self.pending_tool_input.clear();
479            }
480            AgentEvent::ToolCallInputDelta(delta) => {
481                self.pending_tool_input.push_str(&delta);
482            }
483            AgentEvent::ToolCallExecuting { name, input, .. } => {
484                self.pending_tool_name = Some(name.clone());
485                self.pending_tool_input = input;
486            }
487            AgentEvent::ToolCallResult {
488                name,
489                output,
490                is_error,
491                ..
492            } => {
493                if !self.current_response.is_empty() {
494                    self.streaming_segments
495                        .push(StreamSegment::Text(std::mem::take(
496                            &mut self.current_response,
497                        )));
498                }
499                let input = std::mem::take(&mut self.pending_tool_input);
500                let category = ToolCategory::from_name(&name);
501                let detail = extract_tool_detail(&name, &input);
502                let display = ToolCallDisplay {
503                    name: name.clone(),
504                    input,
505                    output: Some(output),
506                    is_error,
507                    category,
508                    detail,
509                };
510                self.current_tool_calls.push(display.clone());
511                self.streaming_segments
512                    .push(StreamSegment::ToolCall(display));
513                self.pending_tool_name = None;
514            }
515            AgentEvent::Done { usage } => {
516                self.is_streaming = false;
517                self.streaming_started = None;
518                self.last_input_tokens = usage.input_tokens;
519                self.usage.input_tokens += usage.input_tokens;
520                self.usage.output_tokens += usage.output_tokens;
521                self.scroll_to_bottom();
522            }
523            AgentEvent::Error(msg) => {
524                self.is_streaming = false;
525                self.streaming_started = None;
526                self.status_message = Some(StatusMessage::error(msg));
527            }
528            AgentEvent::Compacting => {
529                self.messages.push(ChatMessage {
530                    role: "compact".to_string(),
531                    content: "\u{26a1} context compacted".to_string(),
532                    tool_calls: Vec::new(),
533                    thinking: None,
534                    model: None,
535                    segments: None,
536                });
537            }
538            AgentEvent::TitleGenerated(title) => {
539                self.conversation_title = Some(title);
540            }
541            AgentEvent::Compacted { messages_removed } => {
542                if let Some(last) = self.messages.last_mut()
543                    && last.role == "compact"
544                {
545                    last.content = format!(
546                        "\u{26a1} compacted \u{2014} {} messages summarized",
547                        messages_removed
548                    );
549                }
550            }
551            AgentEvent::TodoUpdate(items) => {
552                self.todos = items;
553            }
554            AgentEvent::Question {
555                question,
556                options,
557                responder,
558                ..
559            } => {
560                self.pending_question = Some(PendingQuestion {
561                    question,
562                    options,
563                    selected: 0,
564                    custom_input: String::new(),
565                    responder: Some(responder),
566                });
567            }
568            AgentEvent::PermissionRequest {
569                tool_name,
570                input_summary,
571                responder,
572            } => {
573                self.pending_permission = Some(PendingPermission {
574                    tool_name,
575                    input_summary,
576                    selected: 0,
577                    responder: Some(responder),
578                });
579            }
580            AgentEvent::SubagentStart {
581                id,
582                description,
583                background,
584            } => {
585                if background {
586                    self.background_subagents.push(BackgroundSubagentInfo {
587                        id,
588                        description,
589                        output: String::new(),
590                        tools_completed: 0,
591                        done: false,
592                    });
593                } else {
594                    self.active_subagent = Some(SubagentState {
595                        id,
596                        description,
597                        output: String::new(),
598                        current_tool: None,
599                        current_tool_detail: None,
600                        tools_completed: 0,
601                        background: false,
602                    });
603                }
604            }
605            AgentEvent::SubagentDelta { id, text } => {
606                if let Some(ref mut state) = self.active_subagent
607                    && state.id == id
608                {
609                    state.output.push_str(&text);
610                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
611                    bg.output.push_str(&text);
612                }
613            }
614            AgentEvent::SubagentToolStart {
615                id,
616                tool_name,
617                detail,
618            } => {
619                if let Some(ref mut state) = self.active_subagent
620                    && state.id == id
621                {
622                    state.current_tool = Some(tool_name);
623                    state.current_tool_detail = Some(detail);
624                }
625            }
626            AgentEvent::SubagentToolComplete { id, .. } => {
627                if let Some(ref mut state) = self.active_subagent
628                    && state.id == id
629                {
630                    state.current_tool = None;
631                    state.current_tool_detail = None;
632                    state.tools_completed += 1;
633                } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
634                    bg.tools_completed += 1;
635                }
636            }
637            AgentEvent::SubagentComplete { id, .. } => {
638                if self.active_subagent.as_ref().is_some_and(|s| s.id == id) {
639                    self.active_subagent = None;
640                }
641            }
642            AgentEvent::SubagentBackgroundDone {
643                id, description, ..
644            } => {
645                if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
646                    bg.done = true;
647                }
648                self.status_message = Some(StatusMessage::success(format!(
649                    "Background subagent done: {}",
650                    description
651                )));
652            }
653            AgentEvent::MemoryExtracted {
654                added,
655                updated,
656                deleted,
657            } => {
658                let parts: Vec<String> = [
659                    (added > 0).then(|| format!("+{added}")),
660                    (updated > 0).then(|| format!("~{updated}")),
661                    (deleted > 0).then(|| format!("-{deleted}")),
662                ]
663                .into_iter()
664                .flatten()
665                .collect();
666                if !parts.is_empty() {
667                    self.status_message = Some(StatusMessage::success(format!(
668                        "memory {}",
669                        parts.join(" ")
670                    )));
671                }
672            }
673        }
674        self.mark_dirty();
675    }
676
677    pub fn take_input(&mut self) -> Option<String> {
678        let trimmed = self.input.trim().to_string();
679        if trimmed.is_empty() && self.attachments.is_empty() {
680            return None;
681        }
682        let display = if self.attachments.is_empty() {
683            trimmed.clone()
684        } else {
685            let att_names: Vec<String> = self
686                .attachments
687                .iter()
688                .map(|a| {
689                    Path::new(&a.path)
690                        .file_name()
691                        .map(|f| f.to_string_lossy().to_string())
692                        .unwrap_or_else(|| a.path.clone())
693                })
694                .collect();
695            if trimmed.is_empty() {
696                format!("[{}]", att_names.join(", "))
697            } else {
698                format!("{} [{}]", trimmed, att_names.join(", "))
699            }
700        };
701        self.messages.push(ChatMessage {
702            role: "user".to_string(),
703            content: display,
704            tool_calls: Vec::new(),
705            thinking: None,
706            model: None,
707            segments: None,
708        });
709        self.input.clear();
710        self.cursor_pos = 0;
711        self.paste_blocks.clear();
712        self.chips.clear();
713        self.history.push(trimmed.clone());
714        self.history_index = None;
715        self.history_draft.clear();
716        self.is_streaming = true;
717        self.streaming_started = Some(Instant::now());
718        self.current_response.clear();
719        self.current_thinking.clear();
720        self.current_tool_calls.clear();
721        self.streaming_segments.clear();
722        self.status_message = None;
723        self.scroll_to_bottom();
724        self.mark_dirty();
725        Some(trimmed)
726    }
727
728    pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
729        std::mem::take(&mut self.attachments)
730    }
731
732    pub fn queue_input(&mut self) -> bool {
733        let trimmed = self.input.trim().to_string();
734        if trimmed.is_empty() && self.attachments.is_empty() {
735            return false;
736        }
737        let display = if self.attachments.is_empty() {
738            trimmed.clone()
739        } else {
740            let names: Vec<String> = self
741                .attachments
742                .iter()
743                .map(|a| {
744                    Path::new(&a.path)
745                        .file_name()
746                        .map(|f| f.to_string_lossy().to_string())
747                        .unwrap_or_else(|| a.path.clone())
748                })
749                .collect();
750            if trimmed.is_empty() {
751                format!("[{}]", names.join(", "))
752            } else {
753                format!("{} [{}]", trimmed, names.join(", "))
754            }
755        };
756        self.messages.push(ChatMessage {
757            role: "user".to_string(),
758            content: display,
759            tool_calls: Vec::new(),
760            thinking: None,
761            model: None,
762            segments: None,
763        });
764        let images: Vec<(String, String)> = self
765            .attachments
766            .drain(..)
767            .map(|a| (a.media_type, a.data))
768            .collect();
769        self.history.push(trimmed.clone());
770        self.history_index = None;
771        self.history_draft.clear();
772        self.message_queue.push_back(QueuedMessage {
773            text: trimmed,
774            images,
775        });
776        self.input.clear();
777        self.cursor_pos = 0;
778        self.paste_blocks.clear();
779        self.chips.clear();
780        self.scroll_to_bottom();
781        self.mark_dirty();
782        true
783    }
784
785    pub fn input_height(&self, width: u16) -> u16 {
786        if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
787            return 3;
788        }
789        let w = width as usize;
790        if w < 4 {
791            return 3;
792        }
793        let has_input = !self.input.is_empty() || !self.attachments.is_empty();
794        if !has_input {
795            return 3;
796        }
797        let mut visual = 0usize;
798        if !self.attachments.is_empty() {
799            visual += 1;
800        }
801        let display = self.display_input();
802        if display.is_empty() {
803            if self.attachments.is_empty() {
804                visual += 1;
805            }
806        } else {
807            for line in display.split('\n') {
808                let total = 2 + line.chars().count();
809                visual += if total == 0 {
810                    1
811                } else {
812                    total.div_ceil(w).max(1)
813                };
814            }
815        }
816        (visual as u16 + 1).clamp(3, 12)
817    }
818
819    pub fn handle_paste(&mut self, text: String) {
820        let line_count = text.lines().count();
821        let start = self.cursor_pos;
822        let len = text.len();
823        self.input.insert_str(start, &text);
824        self.adjust_chips(start, 0, len);
825        self.cursor_pos = start + len;
826        if line_count >= PASTE_COLLAPSE_THRESHOLD {
827            self.paste_blocks.push(PasteBlock {
828                start,
829                end: start + len,
830                line_count,
831            });
832        }
833    }
834
835    pub fn paste_block_at_cursor(&self) -> Option<usize> {
836        self.paste_blocks
837            .iter()
838            .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
839    }
840
841    pub fn delete_paste_block(&mut self, idx: usize) {
842        let pb = self.paste_blocks.remove(idx);
843        let len = pb.end - pb.start;
844        self.input.replace_range(pb.start..pb.end, "");
845        self.cursor_pos = pb.start;
846        for remaining in &mut self.paste_blocks {
847            if remaining.start >= pb.end {
848                remaining.start -= len;
849                remaining.end -= len;
850            }
851        }
852    }
853
854    pub fn chip_at_cursor(&self) -> Option<usize> {
855        self.chips
856            .iter()
857            .position(|c| self.cursor_pos > c.start && self.cursor_pos <= c.end)
858    }
859
860    pub fn delete_chip(&mut self, idx: usize) {
861        let chip = self.chips.remove(idx);
862        let len = chip.end - chip.start;
863        self.input.replace_range(chip.start..chip.end, "");
864        self.cursor_pos = chip.start;
865        self.adjust_chips(chip.start, len, 0);
866    }
867
868    pub fn adjust_chips(&mut self, edit_start: usize, old_len: usize, new_len: usize) {
869        let edit_end = edit_start + old_len;
870        let delta = new_len as isize - old_len as isize;
871        self.chips.retain_mut(|c| {
872            if c.start >= edit_end {
873                c.start = (c.start as isize + delta) as usize;
874                c.end = (c.end as isize + delta) as usize;
875                true
876            } else {
877                c.end <= edit_start
878            }
879        });
880    }
881
882    pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
883        let resolved = if path.starts_with('~') {
884            if let Ok(home) = std::env::var("HOME") {
885                path.replacen('~', &home, 1)
886            } else {
887                path.to_string()
888            }
889        } else {
890            path.to_string()
891        };
892
893        let fs_path = Path::new(&resolved);
894        if !fs_path.exists() {
895            return Err(format!("file not found: {}", path));
896        }
897
898        let media_type = media_type_for_path(&resolved)
899            .ok_or_else(|| format!("unsupported image format: {}", path))?;
900
901        let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
902        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
903
904        if self.attachments.iter().any(|a| a.path == resolved) {
905            return Ok(());
906        }
907
908        self.attachments.push(ImageAttachment {
909            path: resolved,
910            media_type,
911            data: encoded,
912        });
913        Ok(())
914    }
915
916    pub fn display_input(&self) -> String {
917        if self.paste_blocks.is_empty() {
918            return self.input.clone();
919        }
920        let mut result = String::new();
921        let mut pos = 0;
922        let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
923        sorted_blocks.sort_by_key(|pb| pb.start);
924        for pb in sorted_blocks {
925            if pb.start > pos {
926                result.push_str(&self.input[pos..pb.start]);
927            }
928            result.push_str(&format!("[pasted {} lines]", pb.line_count));
929            pos = pb.end;
930        }
931        if pos < self.input.len() {
932            result.push_str(&self.input[pos..]);
933        }
934        result
935    }
936
937    pub fn scroll_up(&mut self, n: u16) {
938        self.follow_bottom = false;
939        self.scroll_offset = self.scroll_offset.saturating_sub(n);
940    }
941
942    pub fn scroll_down(&mut self, n: u16) {
943        self.scroll_offset = self.scroll_offset.saturating_add(n).min(self.max_scroll);
944        if self.scroll_offset >= self.max_scroll {
945            self.follow_bottom = true;
946        }
947    }
948
949    pub fn scroll_to_top(&mut self) {
950        self.follow_bottom = false;
951        self.scroll_offset = 0;
952    }
953
954    pub fn scroll_to_bottom(&mut self) {
955        self.follow_bottom = true;
956        self.scroll_offset = self.max_scroll;
957    }
958
959    pub fn clear_conversation(&mut self) {
960        self.messages.clear();
961        self.current_response.clear();
962        self.current_thinking.clear();
963        self.current_tool_calls.clear();
964        self.streaming_segments.clear();
965        self.scroll_offset = 0;
966        self.max_scroll = 0;
967        self.follow_bottom = true;
968        self.usage = TokenUsage::default();
969        self.last_input_tokens = 0;
970        self.status_message = None;
971        self.paste_blocks.clear();
972        self.chips.clear();
973        self.attachments.clear();
974        self.conversation_title = None;
975        self.selection.clear();
976        self.visual_lines.clear();
977        self.todos.clear();
978        self.message_line_map.clear();
979        self.tool_line_map.clear();
980        self.expanded_tool_calls.clear();
981        self.esc_hint_until = None;
982        self.context_menu.close();
983        self.pending_question = None;
984        self.pending_permission = None;
985        self.active_subagent = None;
986        self.background_subagents.clear();
987        self.message_queue.clear();
988        self.render_cache = None;
989        self.mark_dirty();
990    }
991
992    pub fn insert_char(&mut self, c: char) {
993        let pos = self.cursor_pos;
994        self.input.insert(pos, c);
995        let len = c.len_utf8();
996        self.adjust_chips(pos, 0, len);
997        self.cursor_pos += len;
998    }
999
1000    pub fn delete_char_before(&mut self) {
1001        if self.cursor_pos > 0 {
1002            let prev = self.input[..self.cursor_pos]
1003                .chars()
1004                .last()
1005                .map(|c| c.len_utf8())
1006                .unwrap_or(0);
1007            self.cursor_pos -= prev;
1008            self.input.remove(self.cursor_pos);
1009            self.adjust_chips(self.cursor_pos, prev, 0);
1010        }
1011    }
1012
1013    pub fn move_cursor_left(&mut self) {
1014        if self.cursor_pos > 0 {
1015            let prev = self.input[..self.cursor_pos]
1016                .chars()
1017                .last()
1018                .map(|c| c.len_utf8())
1019                .unwrap_or(0);
1020            self.cursor_pos -= prev;
1021        }
1022    }
1023
1024    pub fn move_cursor_right(&mut self) {
1025        if self.cursor_pos < self.input.len() {
1026            let next = self.input[self.cursor_pos..]
1027                .chars()
1028                .next()
1029                .map(|c| c.len_utf8())
1030                .unwrap_or(0);
1031            self.cursor_pos += next;
1032        }
1033    }
1034
1035    pub fn move_cursor_home(&mut self) {
1036        self.cursor_pos = 0;
1037    }
1038
1039    pub fn move_cursor_end(&mut self) {
1040        self.cursor_pos = self.input.len();
1041    }
1042
1043    pub fn delete_word_before(&mut self) {
1044        if self.cursor_pos == 0 {
1045            return;
1046        }
1047        let before = &self.input[..self.cursor_pos];
1048        let trimmed = before.trim_end();
1049        let new_end = if trimmed.is_empty() {
1050            0
1051        } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
1052            pos + trimmed[pos..]
1053                .chars()
1054                .next()
1055                .map(|c| c.len_utf8())
1056                .unwrap_or(1)
1057        } else {
1058            0
1059        };
1060        let old_len = self.cursor_pos - new_end;
1061        self.input.replace_range(new_end..self.cursor_pos, "");
1062        self.adjust_chips(new_end, old_len, 0);
1063        self.cursor_pos = new_end;
1064    }
1065
1066    pub fn delete_to_end(&mut self) {
1067        let old_len = self.input.len() - self.cursor_pos;
1068        self.input.truncate(self.cursor_pos);
1069        self.adjust_chips(self.cursor_pos, old_len, 0);
1070    }
1071
1072    pub fn delete_to_start(&mut self) {
1073        let old_len = self.cursor_pos;
1074        self.input.replace_range(..self.cursor_pos, "");
1075        self.adjust_chips(0, old_len, 0);
1076        self.cursor_pos = 0;
1077    }
1078
1079    pub fn extract_selected_text(&self) -> Option<String> {
1080        let ((sc, sr), (ec, er)) = self.selection.ordered()?;
1081        if self.visual_lines.is_empty() || self.content_width == 0 {
1082            return None;
1083        }
1084        let mut text = String::new();
1085        for row in sr..=er {
1086            if row as usize >= self.visual_lines.len() {
1087                break;
1088            }
1089            let line = &self.visual_lines[row as usize];
1090            let chars: Vec<char> = line.chars().collect();
1091            let start_col = if row == sr {
1092                (sc as usize).min(chars.len())
1093            } else {
1094                0
1095            };
1096            let end_col = if row == er {
1097                (ec as usize).min(chars.len())
1098            } else {
1099                chars.len()
1100            };
1101            if start_col <= end_col {
1102                let s = start_col.min(chars.len());
1103                let e = end_col.min(chars.len());
1104                text.extend(&chars[s..e]);
1105            }
1106            if row < er {
1107                text.push('\n');
1108            }
1109        }
1110        Some(text)
1111    }
1112
1113    pub fn move_cursor_up(&mut self) -> bool {
1114        let before = &self.input[..self.cursor_pos];
1115        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1116        if line_start == 0 {
1117            return false;
1118        }
1119        let col = before[line_start..].chars().count();
1120        let prev_end = line_start - 1;
1121        let prev_start = self.input[..prev_end]
1122            .rfind('\n')
1123            .map(|p| p + 1)
1124            .unwrap_or(0);
1125        let prev_line = &self.input[prev_start..prev_end];
1126        let target_col = col.min(prev_line.chars().count());
1127        let offset: usize = prev_line
1128            .chars()
1129            .take(target_col)
1130            .map(|c| c.len_utf8())
1131            .sum();
1132        self.cursor_pos = prev_start + offset;
1133        true
1134    }
1135
1136    pub fn move_cursor_down(&mut self) -> bool {
1137        let after = &self.input[self.cursor_pos..];
1138        let next_nl = after.find('\n');
1139        let Some(nl_offset) = next_nl else {
1140            return false;
1141        };
1142        let before = &self.input[..self.cursor_pos];
1143        let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1144        let col = before[line_start..].chars().count();
1145        let next_start = self.cursor_pos + nl_offset + 1;
1146        let next_end = self.input[next_start..]
1147            .find('\n')
1148            .map(|p| next_start + p)
1149            .unwrap_or(self.input.len());
1150        let next_line = &self.input[next_start..next_end];
1151        let target_col = col.min(next_line.chars().count());
1152        let offset: usize = next_line
1153            .chars()
1154            .take(target_col)
1155            .map(|c| c.len_utf8())
1156            .sum();
1157        self.cursor_pos = next_start + offset;
1158        true
1159    }
1160
1161    pub fn history_prev(&mut self) {
1162        if self.history.is_empty() {
1163            return;
1164        }
1165        match self.history_index {
1166            None => {
1167                self.history_draft = self.input.clone();
1168                self.history_index = Some(self.history.len() - 1);
1169            }
1170            Some(0) => return,
1171            Some(i) => {
1172                self.history_index = Some(i - 1);
1173            }
1174        }
1175        self.input = self.history[self.history_index.unwrap()].clone();
1176        self.cursor_pos = self.input.len();
1177        self.paste_blocks.clear();
1178        self.chips.clear();
1179    }
1180
1181    pub fn history_next(&mut self) {
1182        let Some(idx) = self.history_index else {
1183            return;
1184        };
1185        if idx + 1 >= self.history.len() {
1186            self.history_index = None;
1187            self.input = std::mem::take(&mut self.history_draft);
1188        } else {
1189            self.history_index = Some(idx + 1);
1190            self.input = self.history[idx + 1].clone();
1191        }
1192        self.cursor_pos = self.input.len();
1193        self.paste_blocks.clear();
1194        self.chips.clear();
1195    }
1196}
1197
1198pub fn copy_to_clipboard(text: &str) {
1199    let encoded =
1200        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
1201    let osc = format!("\x1b]52;c;{}\x07", encoded);
1202    let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
1203
1204    #[cfg(target_os = "macos")]
1205    {
1206        use std::process::{Command, Stdio};
1207        if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
1208            if let Some(ref mut stdin) = child.stdin {
1209                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1210            }
1211            let _ = child.wait();
1212        }
1213    }
1214
1215    #[cfg(target_os = "linux")]
1216    {
1217        use std::process::{Command, Stdio};
1218        let result = Command::new("xclip")
1219            .args(["-selection", "clipboard"])
1220            .stdin(Stdio::piped())
1221            .spawn();
1222        if let Ok(mut child) = result {
1223            if let Some(ref mut stdin) = child.stdin {
1224                let _ = std::io::Write::write_all(stdin, text.as_bytes());
1225            }
1226            let _ = child.wait();
1227        }
1228    }
1229}