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}