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 App {
304 pub messages: Vec<ChatMessage>,
305 pub input: String,
306 pub cursor_pos: usize,
307 pub scroll_offset: u32,
308 pub max_scroll: u32,
309 pub is_streaming: bool,
310 pub current_response: String,
311 pub current_thinking: String,
312 pub should_quit: bool,
313 pub mode: AppMode,
314 pub usage: TokenUsage,
315 pub model_name: String,
316 pub provider_name: String,
317 pub agent_name: String,
318 pub theme: Theme,
319 pub tick_count: u64,
320 pub layout: LayoutRects,
321
322 pub pending_tool_name: Option<String>,
323 pub pending_tool_input: String,
324 pub current_tool_calls: Vec<ToolCallDisplay>,
325 pub streaming_segments: Vec<StreamSegment>,
326 pub status_message: Option<StatusMessage>,
327 pub model_selector: ModelSelector,
328 pub agent_selector: AgentSelector,
329 pub command_palette: CommandPalette,
330 pub thinking_selector: ThinkingSelector,
331 pub session_selector: SessionSelector,
332 pub help_popup: HelpPopup,
333 pub streaming_started: Option<Instant>,
334
335 pub thinking_expanded: bool,
336 pub thinking_budget: u32,
337 pub last_escape_time: Option<Instant>,
338 pub follow_bottom: bool,
339
340 pub paste_blocks: Vec<PasteBlock>,
341 pub attachments: Vec<ImageAttachment>,
342 pub conversation_title: Option<String>,
343 pub vim_mode: bool,
344
345 pub selection: TextSelection,
346
347 pub content_width: u16,
348
349 pub context_window: u32,
350 pub last_input_tokens: u32,
351
352 pub esc_hint_until: Option<Instant>,
353 pub todos: Vec<TodoItem>,
354 pub message_line_map: Vec<usize>,
355 pub tool_line_map: Vec<Option<(usize, usize)>>,
356 pub expanded_tool_calls: HashSet<(usize, usize)>,
357 pub context_menu: MessageContextMenu,
358 pub pending_question: Option<PendingQuestion>,
359 pub pending_permission: Option<PendingPermission>,
360 pub message_queue: VecDeque<QueuedMessage>,
361 pub history: Vec<String>,
362 pub history_index: Option<usize>,
363 pub history_draft: String,
364 pub skill_entries: Vec<(String, String)>,
365 pub custom_command_names: Vec<String>,
366 pub rename_input: String,
367 pub rename_visible: bool,
368 pub favorite_models: Vec<String>,
369 pub file_picker: FilePicker,
370 pub login_popup: LoginPopup,
371 pub welcome_screen: WelcomeScreen,
372 pub aside_popup: AsidePopup,
373 pub chips: Vec<InputChip>,
374 pub active_subagent: Option<SubagentState>,
375 pub background_subagents: Vec<BackgroundSubagentInfo>,
376
377 pub render_dirty: bool,
378 pub render_cache: Option<RenderCache>,
379 pub tool_call_complete_ticks: HashMap<(usize, usize), u64>,
380 pub input_at_top: bool,
381
382 pub cached_model_groups: Option<Vec<(String, Vec<String>)>>,
383 pub model_fetch_rx: Option<ModelFetchReceiver>,
384
385 pub cursor_shape: CursorShape,
386 pub cursor_blink: bool,
387 pub cursor_shape_normal: Option<CursorShape>,
388 pub cursor_blink_normal: Option<bool>,
389}
390impl App {
391 #[allow(clippy::too_many_arguments)]
392 pub fn new(
393 model_name: String,
394 provider_name: String,
395 agent_name: String,
396 theme_name: &str,
397 vim_mode: bool,
398 cursor_shape: CursorShape,
399 cursor_blink: bool,
400 cursor_shape_normal: Option<CursorShape>,
401 cursor_blink_normal: Option<bool>,
402 ) -> Self {
403 Self {
404 messages: Vec::new(),
405 input: String::new(),
406 cursor_pos: 0,
407 scroll_offset: 0,
408 max_scroll: 0,
409 is_streaming: false,
410 current_response: String::new(),
411 current_thinking: String::new(),
412 should_quit: false,
413 mode: AppMode::Insert,
414 usage: TokenUsage::default(),
415 model_name,
416 provider_name,
417 agent_name,
418 theme: Theme::from_config(theme_name),
419 tick_count: 0,
420 layout: LayoutRects::default(),
421 pending_tool_name: None,
422 pending_tool_input: String::new(),
423 current_tool_calls: Vec::new(),
424 streaming_segments: Vec::new(),
425 status_message: None,
426 model_selector: ModelSelector::new(),
427 agent_selector: AgentSelector::new(),
428 command_palette: CommandPalette::new(),
429 thinking_selector: ThinkingSelector::new(),
430 session_selector: SessionSelector::new(),
431 help_popup: HelpPopup::new(),
432 streaming_started: None,
433 thinking_expanded: false,
434 thinking_budget: 0,
435 last_escape_time: None,
436 follow_bottom: true,
437 paste_blocks: Vec::new(),
438 attachments: Vec::new(),
439 conversation_title: None,
440 vim_mode,
441 selection: TextSelection::default(),
442
443 content_width: 0,
444 context_window: 0,
445 last_input_tokens: 0,
446 esc_hint_until: None,
447 todos: Vec::new(),
448 message_line_map: Vec::new(),
449 tool_line_map: Vec::new(),
450 expanded_tool_calls: HashSet::new(),
451 context_menu: MessageContextMenu::new(),
452 pending_question: None,
453 pending_permission: None,
454 message_queue: VecDeque::new(),
455 history: Vec::new(),
456 history_index: None,
457 history_draft: String::new(),
458 skill_entries: Vec::new(),
459 custom_command_names: Vec::new(),
460 rename_input: String::new(),
461 rename_visible: false,
462 favorite_models: Vec::new(),
463 file_picker: FilePicker::new(),
464 login_popup: LoginPopup::new(),
465 welcome_screen: WelcomeScreen::new(),
466 aside_popup: AsidePopup::new(),
467 chips: Vec::new(),
468 active_subagent: None,
469 background_subagents: Vec::new(),
470 render_dirty: true,
471 render_cache: None,
472 tool_call_complete_ticks: HashMap::new(),
473 input_at_top: false,
474 cached_model_groups: None,
475 model_fetch_rx: None,
476 cursor_shape,
477 cursor_blink,
478 cursor_shape_normal,
479 cursor_blink_normal,
480 }
481 }
482
483 pub fn mark_dirty(&mut self) {
484 self.render_dirty = true;
485 }
486
487 pub fn streaming_elapsed_secs(&self) -> Option<f64> {
488 self.streaming_started
489 .map(|start| start.elapsed().as_secs_f64())
490 }
491
492 pub fn thinking_level(&self) -> ThinkingLevel {
493 ThinkingLevel::from_budget(self.thinking_budget)
494 }
495
496 pub fn handle_agent_event(&mut self, event: AgentEvent) {
497 match event {
498 AgentEvent::TextDelta(text) => {
499 self.current_response.push_str(&text);
500 self.mark_dirty();
501 }
502 AgentEvent::ThinkingDelta(text) => {
503 self.current_thinking.push_str(&text);
504 self.mark_dirty();
505 }
506 AgentEvent::TextComplete(text) => {
507 if !text.is_empty()
508 || !self.current_response.is_empty()
509 || !self.streaming_segments.is_empty()
510 {
511 if !self.current_response.is_empty() {
512 self.streaming_segments
513 .push(StreamSegment::Text(std::mem::take(
514 &mut self.current_response,
515 )));
516 }
517 let content: String = self
518 .streaming_segments
519 .iter()
520 .filter_map(|s| {
521 if let StreamSegment::Text(t) = s {
522 Some(t.as_str())
523 } else {
524 None
525 }
526 })
527 .collect();
528 let content = if content.is_empty() {
529 text.clone()
530 } else {
531 content
532 };
533 let thinking = if self.current_thinking.is_empty() {
534 None
535 } else {
536 Some(self.current_thinking.clone())
537 };
538 self.messages.push(ChatMessage {
539 role: "assistant".to_string(),
540 content,
541 tool_calls: std::mem::take(&mut self.current_tool_calls),
542 thinking,
543 model: Some(self.model_name.clone()),
544 segments: Some(std::mem::take(&mut self.streaming_segments)),
545 chips: None,
546 });
547 self.mark_dirty();
548 }
549 self.current_response.clear();
550 self.current_thinking.clear();
551 self.streaming_segments.clear();
552 self.is_streaming = false;
553 self.streaming_started = None;
554 self.scroll_to_bottom();
555 }
556 AgentEvent::ToolCallStart { name, .. } => {
557 self.pending_tool_name = Some(name);
558 self.pending_tool_input.clear();
559 self.mark_dirty();
560 }
561 AgentEvent::ToolCallInputDelta(delta) => {
562 self.pending_tool_input.push_str(&delta);
563 self.mark_dirty();
564 }
565 AgentEvent::ToolCallExecuting { name, input, .. } => {
566 self.pending_tool_name = Some(name.clone());
567 self.pending_tool_input = input;
568 self.mark_dirty();
569 }
570 AgentEvent::ToolCallResult {
571 name,
572 output,
573 is_error,
574 ..
575 } => {
576 if !self.current_response.is_empty() {
577 self.streaming_segments
578 .push(StreamSegment::Text(std::mem::take(
579 &mut self.current_response,
580 )));
581 }
582 let input = std::mem::take(&mut self.pending_tool_input);
583 let category = ToolCategory::from_name(&name);
584 let detail = extract_tool_detail(&name, &input);
585 let display = ToolCallDisplay {
586 name: name.clone(),
587 input,
588 output: Some(output),
589 is_error,
590 category,
591 detail,
592 };
593 self.current_tool_calls.push(display.clone());
594 self.streaming_segments
595 .push(StreamSegment::ToolCall(display));
596 self.pending_tool_name = None;
597 self.mark_dirty();
598 }
599 AgentEvent::Done { usage } => {
600 self.is_streaming = false;
601 self.streaming_started = None;
602 self.last_input_tokens = usage.input_tokens;
603 self.usage.input_tokens += usage.input_tokens;
604 self.usage.output_tokens += usage.output_tokens;
605 self.scroll_to_bottom();
606 }
607 AgentEvent::Error(msg) => {
608 self.is_streaming = false;
609 self.streaming_started = None;
610 self.status_message = Some(StatusMessage::error(msg));
611 }
612 AgentEvent::Compacting => {
613 self.messages.push(ChatMessage {
614 role: "compact".to_string(),
615 content: "\u{26a1} context compacted".to_string(),
616 tool_calls: Vec::new(),
617 thinking: None,
618 model: None,
619 segments: None,
620 chips: None,
621 });
622 }
623 AgentEvent::TitleGenerated(title) => {
624 self.conversation_title = Some(title);
625 }
626 AgentEvent::Compacted { messages_removed } => {
627 if let Some(last) = self.messages.last_mut()
628 && last.role == "compact"
629 {
630 last.content = format!(
631 "\u{26a1} compacted \u{2014} {} messages summarized",
632 messages_removed
633 );
634 }
635 }
636 AgentEvent::TodoUpdate(items) => {
637 self.todos = items;
638 }
639 AgentEvent::Question {
640 question,
641 options,
642 responder,
643 ..
644 } => {
645 self.pending_question = Some(PendingQuestion {
646 question,
647 options,
648 selected: 0,
649 custom_input: String::new(),
650 responder: Some(responder),
651 });
652 }
653 AgentEvent::PermissionRequest {
654 tool_name,
655 input_summary,
656 responder,
657 } => {
658 self.pending_permission = Some(PendingPermission {
659 tool_name,
660 input_summary,
661 selected: 0,
662 responder: Some(responder),
663 });
664 }
665 AgentEvent::SubagentStart {
666 id,
667 description,
668 background,
669 } => {
670 if background {
671 self.background_subagents.push(BackgroundSubagentInfo {
672 id,
673 description,
674 output: String::new(),
675 tools_completed: 0,
676 done: false,
677 started: Instant::now(),
678 current_tool: None,
679 current_tool_detail: None,
680 tool_history: Vec::new(),
681 tokens: 0,
682 cost: 0.0,
683 text_lines: Vec::new(),
684 });
685 } else {
686 self.active_subagent = Some(SubagentState {
687 id,
688 description,
689 output: String::new(),
690 current_tool: None,
691 current_tool_detail: None,
692 tools_completed: 0,
693 background: false,
694 });
695 }
696 }
697 AgentEvent::SubagentDelta { id, text } => {
698 if let Some(ref mut state) = self.active_subagent
699 && state.id == id
700 {
701 state.output.push_str(&text);
702 } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
703 bg.output.push_str(&text);
704 let lines: Vec<String> = bg.output.lines().map(|l| l.to_string()).collect();
705 let start = lines.len().saturating_sub(20);
706 bg.text_lines = lines[start..].to_vec();
707 }
708 }
709 AgentEvent::SubagentToolStart {
710 id,
711 tool_name,
712 detail,
713 } => {
714 if let Some(ref mut state) = self.active_subagent
715 && state.id == id
716 {
717 state.current_tool = Some(tool_name);
718 state.current_tool_detail = Some(detail);
719 } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
720 bg.current_tool = Some(tool_name.clone());
721 bg.current_tool_detail = Some(detail.clone());
722 bg.tool_history.push(SubagentToolEntry {
723 name: tool_name,
724 detail,
725 done: false,
726 is_error: false,
727 });
728 }
729 }
730 AgentEvent::SubagentToolComplete { id, .. } => {
731 if let Some(ref mut state) = self.active_subagent
732 && state.id == id
733 {
734 state.current_tool = None;
735 state.current_tool_detail = None;
736 state.tools_completed += 1;
737 } else if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
738 bg.current_tool = None;
739 bg.current_tool_detail = None;
740 bg.tools_completed += 1;
741 if let Some(entry) = bg.tool_history.iter_mut().rev().find(|e| !e.done) {
742 entry.done = true;
743 }
744 }
745 }
746 AgentEvent::SubagentComplete { id, .. } => {
747 if self.active_subagent.as_ref().is_some_and(|s| s.id == id) {
748 self.active_subagent = None;
749 }
750 }
751 AgentEvent::SubagentBackgroundDone {
752 id, description, ..
753 } => {
754 if let Some(bg) = self.background_subagents.iter_mut().find(|b| b.id == id) {
755 bg.done = true;
756 }
757 self.status_message = Some(StatusMessage::success(format!(
758 "Background subagent done: {}",
759 description
760 )));
761 }
762 AgentEvent::MemoryExtracted {
763 added,
764 updated,
765 deleted,
766 } => {
767 let parts: Vec<String> = [
768 (added > 0).then(|| format!("+{added}")),
769 (updated > 0).then(|| format!("~{updated}")),
770 (deleted > 0).then(|| format!("-{deleted}")),
771 ]
772 .into_iter()
773 .flatten()
774 .collect();
775 if !parts.is_empty() {
776 self.status_message = Some(StatusMessage::success(format!(
777 "memory {}",
778 parts.join(" ")
779 )));
780 }
781 }
782 AgentEvent::AsideDelta(text) => {
783 self.aside_popup.response.push_str(&text);
784 }
785 AgentEvent::AsideDone => {
786 self.aside_popup.done = true;
787 }
788 AgentEvent::AsideError(msg) => {
789 self.aside_popup.response = format!("Error: {msg}");
790 self.aside_popup.done = true;
791 }
792 }
793 self.mark_dirty();
794 }
795
796 pub fn take_input(&mut self) -> Option<String> {
797 let trimmed = self.input.trim().to_string();
798 if trimmed.is_empty() && self.attachments.is_empty() {
799 return None;
800 }
801 let display = if self.attachments.is_empty() {
802 trimmed.clone()
803 } else {
804 let att_names: Vec<String> = self
805 .attachments
806 .iter()
807 .map(|a| {
808 Path::new(&a.path)
809 .file_name()
810 .map(|f| f.to_string_lossy().to_string())
811 .unwrap_or_else(|| a.path.clone())
812 })
813 .collect();
814 if trimmed.is_empty() {
815 format!("[{}]", att_names.join(", "))
816 } else {
817 format!("{} [{}]", trimmed, att_names.join(", "))
818 }
819 };
820 let chips = std::mem::take(&mut self.chips);
821 self.messages.push(ChatMessage {
822 role: "user".to_string(),
823 content: display,
824 tool_calls: Vec::new(),
825 thinking: None,
826 model: None,
827 segments: None,
828 chips: if chips.is_empty() { None } else { Some(chips) },
829 });
830 self.input.clear();
831 self.cursor_pos = 0;
832 self.paste_blocks.clear();
833 self.history.push(trimmed.clone());
834 self.history_index = None;
835 self.history_draft.clear();
836 self.is_streaming = true;
837 self.streaming_started = Some(Instant::now());
838 self.current_response.clear();
839 self.current_thinking.clear();
840 self.current_tool_calls.clear();
841 self.streaming_segments.clear();
842 self.status_message = None;
843 self.scroll_to_bottom();
844 self.mark_dirty();
845 Some(trimmed)
846 }
847
848 pub fn take_attachments(&mut self) -> Vec<ImageAttachment> {
849 std::mem::take(&mut self.attachments)
850 }
851
852 pub fn queue_input(&mut self) -> bool {
853 let trimmed = self.input.trim().to_string();
854 if trimmed.is_empty() && self.attachments.is_empty() {
855 return false;
856 }
857 let display = if self.attachments.is_empty() {
858 trimmed.clone()
859 } else {
860 let names: Vec<String> = self
861 .attachments
862 .iter()
863 .map(|a| {
864 Path::new(&a.path)
865 .file_name()
866 .map(|f| f.to_string_lossy().to_string())
867 .unwrap_or_else(|| a.path.clone())
868 })
869 .collect();
870 if trimmed.is_empty() {
871 format!("[{}]", names.join(", "))
872 } else {
873 format!("{} [{}]", trimmed, names.join(", "))
874 }
875 };
876 let chips = std::mem::take(&mut self.chips);
877 self.messages.push(ChatMessage {
878 role: "user".to_string(),
879 content: display,
880 tool_calls: Vec::new(),
881 thinking: None,
882 model: None,
883 segments: None,
884 chips: if chips.is_empty() { None } else { Some(chips) },
885 });
886 let images: Vec<(String, String)> = self
887 .attachments
888 .drain(..)
889 .map(|a| (a.media_type, a.data))
890 .collect();
891 self.history.push(trimmed.clone());
892 self.history_index = None;
893 self.history_draft.clear();
894 self.message_queue.push_back(QueuedMessage {
895 text: trimmed,
896 images,
897 });
898 self.input.clear();
899 self.cursor_pos = 0;
900 self.paste_blocks.clear();
901 self.scroll_to_bottom();
902 self.mark_dirty();
903 true
904 }
905
906 pub fn input_height(&self, width: u16) -> u16 {
907 if self.is_streaming && self.input.is_empty() && self.attachments.is_empty() {
908 return 1;
909 }
910 let w = width as usize;
911 if w < 4 {
912 return 1;
913 }
914 let has_input = !self.input.is_empty() || !self.attachments.is_empty();
915 if !has_input {
916 return 1;
917 }
918 let mut visual = 0usize;
919 if !self.attachments.is_empty() {
920 visual += 1;
921 }
922 let display = self.display_input();
923 if display.is_empty() {
924 if self.attachments.is_empty() {
925 visual += 1;
926 }
927 } else {
928 for line in display.split('\n') {
929 let total = 2 + line.chars().count();
930 visual += if total == 0 {
931 1
932 } else {
933 total.div_ceil(w).max(1)
934 };
935 }
936 }
937 (visual as u16).clamp(1, 12)
938 }
939
940 pub fn handle_paste(&mut self, text: String) {
941 let line_count = text.lines().count();
942 let start = self.cursor_pos;
943 let len = text.len();
944 self.input.insert_str(start, &text);
945 self.adjust_chips(start, 0, len);
946 self.cursor_pos = start + len;
947 if line_count >= PASTE_COLLAPSE_THRESHOLD {
948 self.paste_blocks.push(PasteBlock {
949 start,
950 end: start + len,
951 line_count,
952 });
953 }
954 }
955
956 pub fn paste_block_at_cursor(&self) -> Option<usize> {
957 self.paste_blocks
958 .iter()
959 .position(|pb| self.cursor_pos > pb.start && self.cursor_pos <= pb.end)
960 }
961
962 pub fn delete_paste_block(&mut self, idx: usize) {
963 let pb = self.paste_blocks.remove(idx);
964 let len = pb.end - pb.start;
965 self.input.replace_range(pb.start..pb.end, "");
966 self.cursor_pos = pb.start;
967 for remaining in &mut self.paste_blocks {
968 if remaining.start >= pb.end {
969 remaining.start -= len;
970 remaining.end -= len;
971 }
972 }
973 }
974
975 pub fn chip_at_cursor(&self) -> Option<usize> {
976 self.chips
977 .iter()
978 .position(|c| self.cursor_pos > c.start && self.cursor_pos <= c.end)
979 }
980
981 pub fn delete_chip(&mut self, idx: usize) {
982 let chip = self.chips.remove(idx);
983 let len = chip.end - chip.start;
984 self.input.replace_range(chip.start..chip.end, "");
985 self.cursor_pos = chip.start;
986 self.adjust_chips(chip.start, len, 0);
987 }
988
989 pub fn adjust_chips(&mut self, edit_start: usize, old_len: usize, new_len: usize) {
990 let edit_end = edit_start + old_len;
991 let delta = new_len as isize - old_len as isize;
992 self.chips.retain_mut(|c| {
993 if c.start >= edit_end {
994 c.start = (c.start as isize + delta) as usize;
995 c.end = (c.end as isize + delta) as usize;
996 true
997 } else {
998 c.end <= edit_start
999 }
1000 });
1001 }
1002
1003 pub fn add_image_attachment(&mut self, path: &str) -> Result<(), String> {
1004 let resolved = if path.starts_with('~') {
1005 if let Ok(home) = std::env::var("HOME") {
1006 path.replacen('~', &home, 1)
1007 } else {
1008 path.to_string()
1009 }
1010 } else {
1011 path.to_string()
1012 };
1013
1014 let fs_path = Path::new(&resolved);
1015 if !fs_path.exists() {
1016 return Err(format!("file not found: {}", path));
1017 }
1018
1019 let media_type = media_type_for_path(&resolved)
1020 .ok_or_else(|| format!("unsupported image format: {}", path))?;
1021
1022 let data = std::fs::read(fs_path).map_err(|e| format!("failed to read {}: {}", path, e))?;
1023 let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data);
1024
1025 if self.attachments.iter().any(|a| a.path == resolved) {
1026 return Ok(());
1027 }
1028
1029 self.attachments.push(ImageAttachment {
1030 path: resolved,
1031 media_type,
1032 data: encoded,
1033 });
1034 Ok(())
1035 }
1036
1037 pub fn insert_file_reference(&mut self, path: &str) {
1038 let text = format!("@{} ", path);
1039 let start = self.cursor_pos;
1040 let len = text.len();
1041 self.input.insert_str(start, &text);
1042 self.adjust_chips(start, 0, len);
1043 let chip_end = start + 1 + path.len();
1044 self.chips.push(InputChip {
1045 start,
1046 end: chip_end,
1047 kind: ChipKind::File,
1048 });
1049 self.cursor_pos = start + len;
1050 }
1051
1052 pub fn display_input(&self) -> String {
1053 if self.paste_blocks.is_empty() {
1054 return self.input.clone();
1055 }
1056 let mut result = String::new();
1057 let mut pos = 0;
1058 let mut sorted_blocks: Vec<&PasteBlock> = self.paste_blocks.iter().collect();
1059 sorted_blocks.sort_by_key(|pb| pb.start);
1060 for pb in sorted_blocks {
1061 if pb.start > pos {
1062 result.push_str(&self.input[pos..pb.start]);
1063 }
1064 result.push_str(&format!("[pasted {} lines]", pb.line_count));
1065 pos = pb.end;
1066 }
1067 if pos < self.input.len() {
1068 result.push_str(&self.input[pos..]);
1069 }
1070 result
1071 }
1072
1073 pub fn scroll_up(&mut self, n: u32) {
1074 self.follow_bottom = false;
1075 self.scroll_offset = self.scroll_offset.saturating_sub(n);
1076 }
1077
1078 pub fn scroll_down(&mut self, n: u32) {
1079 let target = self.scroll_offset.saturating_add(n);
1080 if target >= self.max_scroll {
1081 self.follow_bottom = true;
1082 self.scroll_offset = self.max_scroll;
1083 } else {
1084 self.scroll_offset = target;
1085 }
1086 }
1087
1088 pub fn scroll_to_top(&mut self) {
1089 self.follow_bottom = false;
1090 self.scroll_offset = 0;
1091 }
1092
1093 pub fn scroll_to_bottom(&mut self) {
1094 self.follow_bottom = true;
1095 self.scroll_offset = self.max_scroll;
1096 }
1097
1098 pub fn clear_conversation(&mut self) {
1099 self.messages.clear();
1100 self.current_response.clear();
1101 self.current_thinking.clear();
1102 self.current_tool_calls.clear();
1103 self.streaming_segments.clear();
1104 self.scroll_offset = 0;
1105 self.max_scroll = 0;
1106 self.follow_bottom = true;
1107 self.usage = TokenUsage::default();
1108 self.last_input_tokens = 0;
1109 self.status_message = None;
1110 self.paste_blocks.clear();
1111 self.chips.clear();
1112 self.attachments.clear();
1113 self.conversation_title = None;
1114 self.selection.clear();
1115
1116 self.todos.clear();
1117 self.message_line_map.clear();
1118 self.tool_line_map.clear();
1119 self.expanded_tool_calls.clear();
1120 self.esc_hint_until = None;
1121 self.context_menu.close();
1122 self.pending_question = None;
1123 self.pending_permission = None;
1124 self.active_subagent = None;
1125 self.background_subagents.clear();
1126 self.message_queue.clear();
1127 self.render_cache = None;
1128 self.tool_call_complete_ticks.clear();
1129 self.mark_dirty();
1130 }
1131
1132 pub fn insert_char(&mut self, c: char) {
1133 let pos = self.cursor_pos;
1134 self.input.insert(pos, c);
1135 let len = c.len_utf8();
1136 self.adjust_chips(pos, 0, len);
1137 self.cursor_pos += len;
1138 }
1139
1140 pub fn delete_char_before(&mut self) {
1141 if self.cursor_pos > 0 {
1142 let prev = self.input[..self.cursor_pos]
1143 .chars()
1144 .last()
1145 .map(|c| c.len_utf8())
1146 .unwrap_or(0);
1147 self.cursor_pos -= prev;
1148 self.input.remove(self.cursor_pos);
1149 self.adjust_chips(self.cursor_pos, prev, 0);
1150 }
1151 }
1152
1153 pub fn move_cursor_left(&mut self) {
1154 if self.cursor_pos > 0 {
1155 let prev = self.input[..self.cursor_pos]
1156 .chars()
1157 .last()
1158 .map(|c| c.len_utf8())
1159 .unwrap_or(0);
1160 self.cursor_pos -= prev;
1161 }
1162 }
1163
1164 pub fn move_cursor_right(&mut self) {
1165 if self.cursor_pos < self.input.len() {
1166 let next = self.input[self.cursor_pos..]
1167 .chars()
1168 .next()
1169 .map(|c| c.len_utf8())
1170 .unwrap_or(0);
1171 self.cursor_pos += next;
1172 }
1173 }
1174
1175 pub fn move_cursor_home(&mut self) {
1176 self.cursor_pos = 0;
1177 }
1178
1179 pub fn move_cursor_end(&mut self) {
1180 self.cursor_pos = self.input.len();
1181 }
1182
1183 pub fn delete_word_before(&mut self) {
1184 if self.cursor_pos == 0 {
1185 return;
1186 }
1187 let before = &self.input[..self.cursor_pos];
1188 let trimmed = before.trim_end();
1189 let new_end = if trimmed.is_empty() {
1190 0
1191 } else if let Some(pos) = trimmed.rfind(|c: char| c.is_whitespace()) {
1192 pos + trimmed[pos..]
1193 .chars()
1194 .next()
1195 .map(|c| c.len_utf8())
1196 .unwrap_or(1)
1197 } else {
1198 0
1199 };
1200 let old_len = self.cursor_pos - new_end;
1201 self.input.replace_range(new_end..self.cursor_pos, "");
1202 self.adjust_chips(new_end, old_len, 0);
1203 self.cursor_pos = new_end;
1204 }
1205
1206 pub fn delete_to_end(&mut self) {
1207 let old_len = self.input.len() - self.cursor_pos;
1208 self.input.truncate(self.cursor_pos);
1209 self.adjust_chips(self.cursor_pos, old_len, 0);
1210 }
1211
1212 pub fn delete_to_start(&mut self) {
1213 let old_len = self.cursor_pos;
1214 self.input.replace_range(..self.cursor_pos, "");
1215 self.adjust_chips(0, old_len, 0);
1216 self.cursor_pos = 0;
1217 }
1218
1219 pub fn extract_selected_text(&self) -> Option<String> {
1220 let ((sc, sr), (ec, er)) = self.selection.ordered()?;
1221 let cache = self.render_cache.as_ref()?;
1222 if cache.lines.is_empty() || self.content_width == 0 {
1223 return None;
1224 }
1225 let mut text = String::new();
1226 for row in sr..=er {
1227 if row as usize >= cache.lines.len() {
1228 break;
1229 }
1230 let line_text: String = cache.lines[row as usize]
1231 .spans
1232 .iter()
1233 .map(|s| s.content.as_ref())
1234 .collect();
1235 let chars: Vec<char> = line_text.chars().collect();
1236 let start_col = if row == sr {
1237 (sc as usize).min(chars.len())
1238 } else {
1239 0
1240 };
1241 let end_col = if row == er {
1242 (ec as usize).min(chars.len())
1243 } else {
1244 chars.len()
1245 };
1246 if start_col <= end_col {
1247 let s = start_col.min(chars.len());
1248 let e = end_col.min(chars.len());
1249 text.extend(&chars[s..e]);
1250 }
1251 if row < er {
1252 text.push('\n');
1253 }
1254 }
1255 Some(text)
1256 }
1257
1258 pub fn move_cursor_up(&mut self) -> bool {
1259 let before = &self.input[..self.cursor_pos];
1260 let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1261 if line_start == 0 {
1262 return false;
1263 }
1264 let col = before[line_start..].chars().count();
1265 let prev_end = line_start - 1;
1266 let prev_start = self.input[..prev_end]
1267 .rfind('\n')
1268 .map(|p| p + 1)
1269 .unwrap_or(0);
1270 let prev_line = &self.input[prev_start..prev_end];
1271 let target_col = col.min(prev_line.chars().count());
1272 let offset: usize = prev_line
1273 .chars()
1274 .take(target_col)
1275 .map(|c| c.len_utf8())
1276 .sum();
1277 self.cursor_pos = prev_start + offset;
1278 true
1279 }
1280
1281 pub fn move_cursor_down(&mut self) -> bool {
1282 let after = &self.input[self.cursor_pos..];
1283 let next_nl = after.find('\n');
1284 let Some(nl_offset) = next_nl else {
1285 return false;
1286 };
1287 let before = &self.input[..self.cursor_pos];
1288 let line_start = before.rfind('\n').map(|p| p + 1).unwrap_or(0);
1289 let col = before[line_start..].chars().count();
1290 let next_start = self.cursor_pos + nl_offset + 1;
1291 let next_end = self.input[next_start..]
1292 .find('\n')
1293 .map(|p| next_start + p)
1294 .unwrap_or(self.input.len());
1295 let next_line = &self.input[next_start..next_end];
1296 let target_col = col.min(next_line.chars().count());
1297 let offset: usize = next_line
1298 .chars()
1299 .take(target_col)
1300 .map(|c| c.len_utf8())
1301 .sum();
1302 self.cursor_pos = next_start + offset;
1303 true
1304 }
1305
1306 pub fn history_prev(&mut self) {
1307 if self.history.is_empty() {
1308 return;
1309 }
1310 match self.history_index {
1311 None => {
1312 self.history_draft = self.input.clone();
1313 self.history_index = Some(self.history.len() - 1);
1314 }
1315 Some(0) => return,
1316 Some(i) => {
1317 self.history_index = Some(i - 1);
1318 }
1319 }
1320 self.input = self.history[self.history_index.unwrap()].clone();
1321 self.cursor_pos = self.input.len();
1322 self.paste_blocks.clear();
1323 self.chips.clear();
1324 }
1325
1326 pub fn history_next(&mut self) {
1327 let Some(idx) = self.history_index else {
1328 return;
1329 };
1330 if idx + 1 >= self.history.len() {
1331 self.history_index = None;
1332 self.input = std::mem::take(&mut self.history_draft);
1333 } else {
1334 self.history_index = Some(idx + 1);
1335 self.input = self.history[idx + 1].clone();
1336 }
1337 self.cursor_pos = self.input.len();
1338 self.paste_blocks.clear();
1339 self.chips.clear();
1340 }
1341}
1342
1343pub fn copy_to_clipboard(text: &str) {
1344 let encoded =
1345 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, text.as_bytes());
1346 let osc = format!("\x1b]52;c;{}\x07", encoded);
1347 let _ = std::io::Write::write_all(&mut std::io::stderr(), osc.as_bytes());
1348
1349 #[cfg(target_os = "macos")]
1350 {
1351 use std::process::{Command, Stdio};
1352 if let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() {
1353 if let Some(ref mut stdin) = child.stdin {
1354 let _ = std::io::Write::write_all(stdin, text.as_bytes());
1355 }
1356 let _ = child.wait();
1357 }
1358 }
1359
1360 #[cfg(target_os = "linux")]
1361 {
1362 use std::process::{Command, Stdio};
1363 let result = Command::new("xclip")
1364 .args(["-selection", "clipboard"])
1365 .stdin(Stdio::piped())
1366 .spawn();
1367 if let Ok(mut child) = result {
1368 if let Some(ref mut stdin) = child.stdin {
1369 let _ = std::io::Write::write_all(stdin, text.as_bytes());
1370 }
1371 let _ = child.wait();
1372 }
1373 }
1374}