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