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