Skip to main content

dot/tui/
app.rs

1use std::path::Path;
2use std::time::Instant;
3
4use ratatui::layout::Rect;
5
6use crate::agent::AgentEvent;
7use crate::tui::theme::Theme;
8use crate::tui::tools::{ToolCallDisplay, ToolCategory, extract_tool_detail};
9use crate::tui::widgets::{
10    AgentSelector, CommandPalette, HelpPopup, ModelSelector, SessionSelector, ThinkingLevel,
11    ThinkingSelector,
12};
13
14pub struct ChatMessage {
15    pub role: String,
16    pub content: String,
17    pub tool_calls: Vec<ToolCallDisplay>,
18    pub thinking: Option<String>,
19    pub model: Option<String>,
20}
21
22pub struct TokenUsage {
23    pub input_tokens: u32,
24    pub output_tokens: u32,
25    pub total_cost: f64,
26}
27
28impl Default for TokenUsage {
29    fn default() -> Self {
30        Self {
31            input_tokens: 0,
32            output_tokens: 0,
33            total_cost: 0.0,
34        }
35    }
36}
37
38#[derive(Debug, Clone)]
39pub struct PasteBlock {
40    pub start: usize,
41    pub end: usize,
42    pub line_count: usize,
43}
44
45#[derive(Debug, Clone)]
46pub struct ImageAttachment {
47    pub path: String,
48    pub media_type: String,
49    pub data: String,
50}
51
52const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"];
53
54pub fn media_type_for_path(path: &str) -> Option<String> {
55    let ext = Path::new(path).extension()?.to_str()?.to_lowercase();
56    match ext.as_str() {
57        "png" => Some("image/png".into()),
58        "jpg" | "jpeg" => Some("image/jpeg".into()),
59        "gif" => Some("image/gif".into()),
60        "webp" => Some("image/webp".into()),
61        "bmp" => Some("image/bmp".into()),
62        "svg" => Some("image/svg+xml".into()),
63        _ => None,
64    }
65}
66
67pub fn is_image_path(path: &str) -> bool {
68    Path::new(path)
69        .extension()
70        .and_then(|e| e.to_str())
71        .map(|e| IMAGE_EXTENSIONS.contains(&e.to_lowercase().as_str()))
72        .unwrap_or(false)
73}
74
75pub const PASTE_COLLAPSE_THRESHOLD: usize = 5;
76
77#[derive(PartialEq, Clone, Copy)]
78pub enum AppMode {
79    Normal,
80    Insert,
81}
82
83#[derive(Default)]
84pub struct LayoutRects {
85    pub header: Rect,
86    pub messages: Rect,
87    pub input: Rect,
88    pub status: Rect,
89    pub model_selector: Option<Rect>,
90    pub agent_selector: Option<Rect>,
91    pub command_palette: Option<Rect>,
92    pub thinking_selector: Option<Rect>,
93    pub session_selector: Option<Rect>,
94    pub help_popup: Option<Rect>,
95}
96
97pub struct App {
98    pub messages: Vec<ChatMessage>,
99    pub input: String,
100    pub cursor_pos: usize,
101    pub scroll_offset: u16,
102    pub max_scroll: u16,
103    pub is_streaming: bool,
104    pub current_response: String,
105    pub current_thinking: String,
106    pub should_quit: bool,
107    pub mode: AppMode,
108    pub usage: TokenUsage,
109    pub model_name: String,
110    pub provider_name: String,
111    pub agent_name: String,
112    pub theme: Theme,
113    pub tick_count: u64,
114    pub layout: LayoutRects,
115
116    pub pending_tool_name: Option<String>,
117    pub pending_tool_input: String,
118    pub current_tool_calls: Vec<ToolCallDisplay>,
119    pub error_message: Option<String>,
120    pub model_selector: ModelSelector,
121    pub agent_selector: AgentSelector,
122    pub command_palette: CommandPalette,
123    pub thinking_selector: ThinkingSelector,
124    pub session_selector: SessionSelector,
125    pub help_popup: HelpPopup,
126    pub streaming_started: Option<Instant>,
127
128    pub thinking_expanded: bool,
129    pub thinking_budget: u32,
130    pub last_escape_time: Option<Instant>,
131    pub follow_bottom: bool,
132
133    pub paste_blocks: Vec<PasteBlock>,
134    pub attachments: Vec<ImageAttachment>,
135    pub conversation_title: Option<String>,
136    pub vim_mode: bool,
137}
138
139impl App {
140    pub fn new(
141        model_name: String,
142        provider_name: String,
143        agent_name: String,
144        theme_name: &str,
145        vim_mode: bool,
146    ) -> Self {
147        Self {
148            messages: Vec::new(),
149            input: String::new(),
150            cursor_pos: 0,
151            scroll_offset: 0,
152            max_scroll: 0,
153            is_streaming: false,
154            current_response: String::new(),
155            current_thinking: String::new(),
156            should_quit: false,
157            mode: AppMode::Insert,
158            usage: TokenUsage::default(),
159            model_name,
160            provider_name,
161            agent_name,
162            theme: Theme::from_config(theme_name),
163            tick_count: 0,
164            layout: LayoutRects::default(),
165            pending_tool_name: None,
166            pending_tool_input: String::new(),
167            current_tool_calls: Vec::new(),
168            error_message: None,
169            model_selector: ModelSelector::new(),
170            agent_selector: AgentSelector::new(),
171            command_palette: CommandPalette::new(),
172            thinking_selector: ThinkingSelector::new(),
173            session_selector: SessionSelector::new(),
174            help_popup: HelpPopup::new(),
175            streaming_started: None,
176            thinking_expanded: false,
177            thinking_budget: 0,
178            last_escape_time: None,
179            follow_bottom: true,
180            paste_blocks: Vec::new(),
181            attachments: Vec::new(),
182            conversation_title: None,
183            vim_mode,
184        }
185    }
186
187    pub fn streaming_elapsed_secs(&self) -> Option<f64> {
188        self.streaming_started
189            .map(|start| start.elapsed().as_secs_f64())
190    }
191
192    pub fn thinking_level(&self) -> ThinkingLevel {
193        ThinkingLevel::from_budget(self.thinking_budget)
194    }
195
196    pub fn handle_agent_event(&mut self, event: AgentEvent) {
197        match event {
198            AgentEvent::TextDelta(text) => {
199                self.current_response.push_str(&text);
200            }
201            AgentEvent::ThinkingDelta(text) => {
202                self.current_thinking.push_str(&text);
203            }
204            AgentEvent::TextComplete(text) => {
205                if !text.is_empty() || !self.current_response.is_empty() {
206                    let content = if self.current_response.is_empty() {
207                        text
208                    } else {
209                        self.current_response.clone()
210                    };
211                    let thinking = if self.current_thinking.is_empty() {
212                        None
213                    } else {
214                        Some(self.current_thinking.clone())
215                    };
216                    self.messages.push(ChatMessage {
217                        role: "assistant".to_string(),
218                        content,
219                        tool_calls: std::mem::take(&mut self.current_tool_calls),
220                        thinking,
221                        model: Some(self.model_name.clone()),
222                    });
223                }
224                self.current_response.clear();
225                self.current_thinking.clear();
226            }
227            AgentEvent::ToolCallStart { name, .. } => {
228                self.pending_tool_name = Some(name);
229                self.pending_tool_input.clear();
230            }
231            AgentEvent::ToolCallInputDelta(delta) => {
232                self.pending_tool_input.push_str(&delta);
233            }
234            AgentEvent::ToolCallExecuting { name, input, .. } => {
235                self.pending_tool_name = Some(name.clone());
236                self.pending_tool_input = input;
237            }
238            AgentEvent::ToolCallResult {
239                name,
240                output,
241                is_error,
242                ..
243            } => {
244                let input = std::mem::take(&mut self.pending_tool_input);
245                let category = ToolCategory::from_name(&name);
246                let detail = extract_tool_detail(&name, &input);
247                self.current_tool_calls.push(ToolCallDisplay {
248                    name: name.clone(),
249                    input,
250                    output: Some(output),
251                    is_error,
252                    category,
253                    detail,
254                });
255                self.pending_tool_name = None;
256            }
257            AgentEvent::Done { usage } => {
258                self.is_streaming = false;
259                self.streaming_started = None;
260                self.usage.input_tokens += usage.input_tokens;
261                self.usage.output_tokens += usage.output_tokens;
262            }
263            AgentEvent::Error(msg) => {
264                self.is_streaming = false;
265                self.streaming_started = None;
266                self.error_message = Some(msg);
267            }
268            AgentEvent::Compacting => {
269                self.messages.push(ChatMessage {
270                    role: "compact".to_string(),
271                    content: "\u{26a1} compacting context\u{2026}".to_string(),
272                    tool_calls: Vec::new(),
273                    thinking: None,
274                    model: None,
275                });
276            }
277            AgentEvent::Compacted { messages_removed } => {
278                if let Some(last) = self.messages.last_mut()
279                    && last.role == "compact"
280                {
281                    last.content = format!(
282                        "\u{26a1} compacted \u{2014} {} messages summarized",
283                        messages_removed
284                    );
285                }
286            }
287        }
288    }
289
290    pub fn take_input(&mut self) -> Option<String> {
291        let trimmed = self.input.trim().to_string();
292        if trimmed.is_empty() && self.attachments.is_empty() {
293            return None;
294        }
295        let display = if self.attachments.is_empty() {
296            trimmed.clone()
297        } else {
298            let att_names: Vec<String> = self
299                .attachments
300                .iter()
301                .map(|a| {
302                    Path::new(&a.path)
303                        .file_name()
304                        .map(|f| f.to_string_lossy().to_string())
305                        .unwrap_or_else(|| a.path.clone())
306                })
307                .collect();
308            if trimmed.is_empty() {
309                format!("[{}]", att_names.join(", "))
310            } else {
311                format!("{} [{}]", trimmed, att_names.join(", "))
312            }
313        };
314        self.messages.push(ChatMessage {
315            role: "user".to_string(),
316            content: display,
317            tool_calls: Vec::new(),
318            thinking: None,
319            model: None,
320        });
321        if self.conversation_title.is_none() {
322            self.conversation_title = Some(trimmed.chars().take(60).collect());
323        }
324        self.input.clear();
325        self.cursor_pos = 0;
326        self.paste_blocks.clear();
327        self.is_streaming = true;
328        self.streaming_started = Some(Instant::now());
329        self.current_response.clear();
330        self.current_thinking.clear();
331        self.current_tool_calls.clear();
332        self.error_message = None;
333        self.scroll_to_bottom();
334        Some(trimmed)
335    }
336
337    pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
338        std::mem::take(&mut self.attachments)
339    }
340
341    pub fn input_height(&self) -> u16 {
342        if self.is_streaming {
343            return 3;
344        }
345        let lines = if self.input.is_empty() {
346            1
347        } else {
348            self.input.lines().count() + if self.input.ends_with('\n') { 1 } else { 0 }
349        };
350        (lines as u16 + 1).clamp(3, 12)
351    }
352
353    pub fn handle_paste(&mut self, text: String) {
354        let line_count = text.lines().count();
355        if line_count >= PASTE_COLLAPSE_THRESHOLD {
356            let start = self.cursor_pos;
357            self.input.insert_str(self.cursor_pos, &text);
358            let end = start + text.len();
359            self.cursor_pos = end;
360            self.paste_blocks.push(PasteBlock {
361                start,
362                end,
363                line_count,
364            });
365        } else {
366            self.input.insert_str(self.cursor_pos, &text);
367            self.cursor_pos += text.len();
368        }
369    }
370
371    pub fn paste_block_at_cursor(&self) -> Option<usize> {
372        self.paste_blocks
373            .iter()
374            .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
375    }
376
377    pub fn delete_paste_block(&mut self, idx: usize) {
378        let pb = self.paste_blocks.remove(idx);
379        let len = pb.end - pb.start;
380        self.input.replace_range(pb.start..pb.end, "");
381        self.cursor_pos = pb.start;
382        for remaining in &mut self.paste_blocks {
383            if remaining.start >= pb.end {
384                remaining.start -= len;
385                remaining.end -= len;
386            }
387        }
388    }
389
390    pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
391        let resolved = if path.starts_with('~') {
392            if let Ok(home) = std::env::var("HOME") {
393                path.replacen('~', &home, 1)
394            } else {
395                path.to_string()
396            }
397        } else {
398            path.to_string()
399        };
400
401        let fs_path = Path::new(&resolved);
402        if !fs_path.exists() {
403            return Err(format!("file not found: {}", path));
404        }
405
406        let media_type = media_type_for_path(&resolved)
407            .ok_or_else(|| format!("unsupported image format: {}", path))?;
408
409        let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
410        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
411
412        if self.attachments.iter().any(|a| a.path == resolved) {
413            return Ok(());
414        }
415
416        self.attachments.push(ImageAttachment {
417            path: resolved,
418            media_type,
419            data: encoded,
420        });
421        Ok(())
422    }
423
424    pub fn display_input(&self) -> String {
425        if self.paste_blocks.is_empty() {
426            return self.input.clone();
427        }
428        let mut result = String::new();
429        let mut pos = 0;
430        let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
431        sorted_blocks.sort_by_key(|pb| pb.start);
432        for pb in sorted_blocks {
433            if pb.start > pos {
434                result.push_str(&self.input[pos..pb.start]);
435            }
436            result.push_str(&format!("[pasted {} lines]", pb.line_count));
437            pos = pb.end;
438        }
439        if pos < self.input.len() {
440            result.push_str(&self.input[pos..]);
441        }
442        result
443    }
444
445    pub fn scroll_up(&mut self, n: u16) {
446        self.follow_bottom = false;
447        self.scroll_offset = self.scroll_offset.saturating_sub(n);
448    }
449
450    pub fn scroll_down(&mut self, n: u16) {
451        self.scroll_offset = (self.scroll_offset + n).min(self.max_scroll);
452        if self.scroll_offset >= self.max_scroll {
453            self.follow_bottom = true;
454        }
455    }
456
457    pub fn scroll_to_top(&mut self) {
458        self.follow_bottom = false;
459        self.scroll_offset = 0;
460    }
461
462    pub fn scroll_to_bottom(&mut self) {
463        self.follow_bottom = true;
464        self.scroll_offset = self.max_scroll;
465    }
466
467    pub fn clear_conversation(&mut self) {
468        self.messages.clear();
469        self.current_response.clear();
470        self.current_thinking.clear();
471        self.current_tool_calls.clear();
472        self.scroll_offset = 0;
473        self.max_scroll = 0;
474        self.follow_bottom = true;
475        self.usage = TokenUsage::default();
476        self.error_message = None;
477        self.paste_blocks.clear();
478        self.attachments.clear();
479        self.conversation_title = None;
480    }
481
482    pub fn insert_char(&mut self, c: char) {
483        self.input.insert(self.cursor_pos, c);
484        self.cursor_pos += c.len_utf8();
485    }
486
487    pub fn delete_char_before(&mut self) {
488        if self.cursor_pos > 0 {
489            let prev = self.input[..self.cursor_pos]
490                .chars()
491                .last()
492                .map(|c| c.len_utf8())
493                .unwrap_or(0);
494            self.cursor_pos -= prev;
495            self.input.remove(self.cursor_pos);
496        }
497    }
498
499    pub fn move_cursor_left(&mut self) {
500        if self.cursor_pos > 0 {
501            let prev = self.input[..self.cursor_pos]
502                .chars()
503                .last()
504                .map(|c| c.len_utf8())
505                .unwrap_or(0);
506            self.cursor_pos -= prev;
507        }
508    }
509
510    pub fn move_cursor_right(&mut self) {
511        if self.cursor_pos < self.input.len() {
512            let next = self.input[self.cursor_pos..]
513                .chars()
514                .next()
515                .map(|c| c.len_utf8())
516                .unwrap_or(0);
517            self.cursor_pos += next;
518        }
519    }
520
521    pub fn move_cursor_home(&mut self) {
522        self.cursor_pos = 0;
523    }
524
525    pub fn move_cursor_end(&mut self) {
526        self.cursor_pos = self.input.len();
527    }
528
529    pub fn delete_word_before(&mut self) {
530        if self.cursor_pos == 0 {
531            return;
532        }
533        let before = &self.input[..self.cursor_pos];
534        let trimmed = before.trim_end();
535        let new_end = if trimmed.is_empty() {
536            0
537        } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
538            pos + trimmed[pos..].chars().next().map(|c| c.len_utf8()).unwrap_or(1)
539        } else {
540            0
541        };
542        self.input.replace_range(new_end..self.cursor_pos, "");
543        self.cursor_pos = new_end;
544    }
545
546    pub fn delete_to_end(&mut self) {
547        self.input.truncate(self.cursor_pos);
548    }
549
550    pub fn delete_to_start(&mut self) {
551        self.input.replace_range(..self.cursor_pos, "");
552        self.cursor_pos = 0;
553    }
554}