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