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