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